diff --git a/main.py b/main.py index 9d8f3ec..00c4abb 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,8 @@ from routers import ( files as files_router, meetings as meetings_router, decisions as decisions_router, + weblinks as weblinks_router, + appointments as appointments_router, ) @@ -179,3 +181,5 @@ app.include_router(lists_router.router) app.include_router(files_router.router) app.include_router(meetings_router.router) app.include_router(decisions_router.router) +app.include_router(weblinks_router.router) +app.include_router(appointments_router.router) diff --git a/routers/admin.py b/routers/admin.py index d4d8a51..85cf229 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -27,6 +27,9 @@ TRASH_ENTITIES = [ {"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"}, {"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"}, {"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"}, + {"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"}, + {"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"}, + {"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"}, {"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"}, {"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"}, ] diff --git a/routers/appointments.py b/routers/appointments.py new file mode 100644 index 0000000..2866b12 --- /dev/null +++ b/routers/appointments.py @@ -0,0 +1,302 @@ +"""Appointments CRUD: scheduling with contacts, recurrence, all-day support.""" + +from fastapi import APIRouter, Request, Depends, Form, Query +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from typing import Optional +from datetime import datetime + +from core.database import get_db +from core.base_repository import BaseRepository +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/appointments", tags=["appointments"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def list_appointments( + request: Request, + status: Optional[str] = None, + timeframe: Optional[str] = "upcoming", + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + repo = BaseRepository("appointments", db) + + # Build filter and sort based on timeframe + if timeframe == "past": + result = await db.execute(text(""" + SELECT * FROM appointments + WHERE is_deleted = false AND start_at < now() + ORDER BY start_at DESC + LIMIT 100 + """)) + elif timeframe == "all": + result = await db.execute(text(""" + SELECT * FROM appointments + WHERE is_deleted = false + ORDER BY start_at DESC + LIMIT 200 + """)) + else: + # upcoming (default) + result = await db.execute(text(""" + SELECT * FROM appointments + WHERE is_deleted = false AND start_at >= CURRENT_DATE + ORDER BY start_at ASC + LIMIT 100 + """)) + + appointments = [dict(r._mapping) for r in result] + + # Get contact counts per appointment + if appointments: + ids = [str(a["id"]) for a in appointments] + placeholders = ", ".join(f"'{i}'" for i in ids) + contact_result = await db.execute(text(f""" + SELECT appointment_id, count(*) as cnt + FROM contact_appointments + WHERE appointment_id IN ({placeholders}) + GROUP BY appointment_id + """)) + contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result} + for a in appointments: + a["contact_count"] = contact_counts.get(str(a["id"]), 0) + + count = len(appointments) + + return templates.TemplateResponse("appointments.html", { + "request": request, + "sidebar": sidebar, + "appointments": appointments, + "count": count, + "timeframe": timeframe or "upcoming", + "page_title": "Appointments", + "active_nav": "appointments", + }) + + +@router.get("/new") +async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)): + sidebar = await get_sidebar_data(db) + + # Load contacts for attendee selection + result = await db.execute(text(""" + SELECT id, first_name, last_name, company + FROM contacts WHERE is_deleted = false + ORDER BY first_name, last_name + """)) + contacts = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("appointment_form.html", { + "request": request, + "sidebar": sidebar, + "appointment": None, + "contacts": contacts, + "selected_contacts": [], + "page_title": "New Appointment", + "active_nav": "appointments", + }) + + +@router.post("/create") +async def create_appointment( + request: Request, + title: str = Form(...), + description: Optional[str] = Form(None), + location: Optional[str] = Form(None), + start_date: str = Form(...), + start_time: Optional[str] = Form(None), + end_date: Optional[str] = Form(None), + end_time: Optional[str] = Form(None), + all_day: Optional[str] = Form(None), + recurrence: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + contact_ids: Optional[list[str]] = Form(None), + db: AsyncSession = Depends(get_db), +): + is_all_day = all_day == "on" + + # Build start_at + if is_all_day: + start_at = f"{start_date}T00:00:00" + else: + start_at = f"{start_date}T{start_time or '09:00'}:00" + + # Build end_at + end_at = None + if end_date: + if is_all_day: + end_at = f"{end_date}T23:59:59" + else: + end_at = f"{end_date}T{end_time or '10:00'}:00" + + data = { + "title": title, + "description": description or None, + "location": location or None, + "start_at": start_at, + "end_at": end_at, + "all_day": is_all_day, + "recurrence": recurrence or None, + "tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None, + } + + repo = BaseRepository("appointments", db) + appointment = await repo.create(data) + + # Add contact associations + if contact_ids: + for cid in contact_ids: + if cid: + await db.execute(text(""" + INSERT INTO contact_appointments (contact_id, appointment_id) + VALUES (:cid, :aid) + ON CONFLICT DO NOTHING + """), {"cid": cid, "aid": str(appointment["id"])}) + + return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303) + + +@router.get("/{appointment_id}") +async def appointment_detail( + request: Request, + appointment_id: str, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + repo = BaseRepository("appointments", db) + appointment = await repo.get(appointment_id) + + if not appointment: + return RedirectResponse(url="/appointments", status_code=303) + + # Get associated contacts + result = await db.execute(text(""" + SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role + FROM contact_appointments ca + JOIN contacts c ON ca.contact_id = c.id + WHERE ca.appointment_id = :aid AND c.is_deleted = false + ORDER BY c.first_name, c.last_name + """), {"aid": appointment_id}) + contacts = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("appointment_detail.html", { + "request": request, + "sidebar": sidebar, + "appointment": appointment, + "contacts": contacts, + "page_title": appointment["title"], + "active_nav": "appointments", + }) + + +@router.get("/{appointment_id}/edit") +async def edit_appointment_form( + request: Request, + appointment_id: str, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + repo = BaseRepository("appointments", db) + appointment = await repo.get(appointment_id) + + if not appointment: + return RedirectResponse(url="/appointments", status_code=303) + + # All contacts + result = await db.execute(text(""" + SELECT id, first_name, last_name, company + FROM contacts WHERE is_deleted = false + ORDER BY first_name, last_name + """)) + contacts = [dict(r._mapping) for r in result] + + # Currently linked contacts + result = await db.execute(text(""" + SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid + """), {"aid": appointment_id}) + selected_contacts = [str(r._mapping["contact_id"]) for r in result] + + return templates.TemplateResponse("appointment_form.html", { + "request": request, + "sidebar": sidebar, + "appointment": appointment, + "contacts": contacts, + "selected_contacts": selected_contacts, + "page_title": f"Edit: {appointment['title']}", + "active_nav": "appointments", + }) + + +@router.post("/{appointment_id}/edit") +async def update_appointment( + request: Request, + appointment_id: str, + title: str = Form(...), + description: Optional[str] = Form(None), + location: Optional[str] = Form(None), + start_date: str = Form(...), + start_time: Optional[str] = Form(None), + end_date: Optional[str] = Form(None), + end_time: Optional[str] = Form(None), + all_day: Optional[str] = Form(None), + recurrence: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + contact_ids: Optional[list[str]] = Form(None), + db: AsyncSession = Depends(get_db), +): + is_all_day = all_day == "on" + + if is_all_day: + start_at = f"{start_date}T00:00:00" + else: + start_at = f"{start_date}T{start_time or '09:00'}:00" + + end_at = None + if end_date: + if is_all_day: + end_at = f"{end_date}T23:59:59" + else: + end_at = f"{end_date}T{end_time or '10:00'}:00" + + data = { + "title": title, + "description": description or None, + "location": location or None, + "start_at": start_at, + "end_at": end_at, + "all_day": is_all_day, + "recurrence": recurrence or None, + "tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None, + } + + repo = BaseRepository("appointments", db) + await repo.update(appointment_id, data) + + # Rebuild contact associations + await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id}) + if contact_ids: + for cid in contact_ids: + if cid: + await db.execute(text(""" + INSERT INTO contact_appointments (contact_id, appointment_id) + VALUES (:cid, :aid) + ON CONFLICT DO NOTHING + """), {"cid": cid, "aid": appointment_id}) + + return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303) + + +@router.post("/{appointment_id}/delete") +async def delete_appointment( + request: Request, + appointment_id: str, + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("appointments", db) + await repo.soft_delete(appointment_id) + return RedirectResponse(url="/appointments", status_code=303) diff --git a/routers/search.py b/routers/search.py index b1fb92b..0f8fc72 100644 --- a/routers/search.py +++ b/routers/search.py @@ -137,6 +137,34 @@ SEARCH_ENTITIES = [ "url": "/decisions/{id}", "icon": "decision", }, + { + "type": "weblinks", + "label": "Weblinks", + "query": """ + SELECT w.id, w.label as name, NULL as status, + NULL as domain_name, NULL as project_name, + ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank + FROM weblinks w + WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q) + ORDER BY rank DESC LIMIT :lim + """, + "url": "/weblinks", + "icon": "weblink", + }, + { + "type": "appointments", + "label": "Appointments", + "query": """ + SELECT a.id, a.title as name, NULL as status, + a.location as domain_name, NULL as project_name, + ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank + FROM appointments a + WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q) + ORDER BY rank DESC LIMIT :lim + """, + "url": "/appointments/{id}", + "icon": "appointment", + }, ] diff --git a/routers/weblinks.py b/routers/weblinks.py new file mode 100644 index 0000000..3971967 --- /dev/null +++ b/routers/weblinks.py @@ -0,0 +1,233 @@ +"""Weblinks: organized bookmark directory with recursive folders.""" + +from fastapi import APIRouter, Request, Form, Depends +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from typing import Optional + +from core.database import get_db +from core.base_repository import BaseRepository +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/weblinks", tags=["weblinks"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def list_weblinks( + request: Request, + folder_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + # Get all folders for tree nav + result = await db.execute(text(""" + SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count + FROM weblink_folders wf + WHERE wf.is_deleted = false + ORDER BY wf.sort_order, wf.name + """)) + all_folders = [dict(r._mapping) for r in result] + + # Top-level folders and child folders + top_folders = [f for f in all_folders if f.get("parent_id") is None] + child_folder_map = {} + for f in all_folders: + pid = f.get("parent_id") + if pid: + child_folder_map.setdefault(str(pid), []).append(f) + + # Current folder info + current_folder = None + if folder_id: + for f in all_folders: + if str(f["id"]) == folder_id: + current_folder = f + break + + # Get weblinks (filtered by folder or all unfiled) + if folder_id: + result = await db.execute(text(""" + SELECT w.* FROM weblinks w + JOIN folder_weblinks fw ON fw.weblink_id = w.id + WHERE fw.folder_id = :fid AND w.is_deleted = false + ORDER BY fw.sort_order, w.label + """), {"fid": folder_id}) + else: + # Show all weblinks + result = await db.execute(text(""" + SELECT w.* FROM weblinks w + WHERE w.is_deleted = false + ORDER BY w.sort_order, w.label + """)) + items = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("weblinks.html", { + "request": request, "sidebar": sidebar, "items": items, + "top_folders": top_folders, "child_folder_map": child_folder_map, + "current_folder": current_folder, + "current_folder_id": folder_id or "", + "page_title": "Weblinks", "active_nav": "weblinks", + }) + + +@router.get("/create") +async def create_form( + request: Request, + folder_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + result = await db.execute(text( + "SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" + )) + folders = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("weblink_form.html", { + "request": request, "sidebar": sidebar, + "folders": folders, + "page_title": "New Weblink", "active_nav": "weblinks", + "item": None, + "prefill_folder_id": folder_id or "", + }) + + +@router.post("/create") +async def create_weblink( + request: Request, + label: str = Form(...), + url: str = Form(...), + description: Optional[str] = Form(None), + folder_id: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("weblinks", db) + data = {"label": label, "url": url, "description": description} + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + + weblink = await repo.create(data) + + # Add to folder if specified + if folder_id and folder_id.strip(): + await db.execute(text(""" + INSERT INTO folder_weblinks (folder_id, weblink_id) + VALUES (:fid, :wid) ON CONFLICT DO NOTHING + """), {"fid": folder_id, "wid": weblink["id"]}) + + redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks" + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.get("/{weblink_id}/edit") +async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("weblinks", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(weblink_id) + if not item: + return RedirectResponse(url="/weblinks", status_code=303) + + result = await db.execute(text( + "SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" + )) + folders = [dict(r._mapping) for r in result] + + # Current folder assignment + result = await db.execute(text( + "SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1" + ), {"wid": weblink_id}) + row = result.first() + current_folder_id = str(row[0]) if row else "" + + return templates.TemplateResponse("weblink_form.html", { + "request": request, "sidebar": sidebar, + "folders": folders, + "page_title": "Edit Weblink", "active_nav": "weblinks", + "item": item, + "prefill_folder_id": current_folder_id, + }) + + +@router.post("/{weblink_id}/edit") +async def update_weblink( + weblink_id: str, + label: str = Form(...), + url: str = Form(...), + description: Optional[str] = Form(None), + folder_id: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("weblinks", db) + data = { + "label": label, "url": url, + "description": description if description and description.strip() else None, + } + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + else: + data["tags"] = None + + await repo.update(weblink_id, data) + + # Update folder assignment + await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id}) + if folder_id and folder_id.strip(): + await db.execute(text(""" + INSERT INTO folder_weblinks (folder_id, weblink_id) + VALUES (:fid, :wid) ON CONFLICT DO NOTHING + """), {"fid": folder_id, "wid": weblink_id}) + + return RedirectResponse(url="/weblinks", status_code=303) + + +@router.post("/{weblink_id}/delete") +async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("weblinks", db) + await repo.soft_delete(weblink_id) + referer = request.headers.get("referer", "/weblinks") + return RedirectResponse(url=referer, status_code=303) + + +# ---- Folders ---- + +@router.get("/folders/create") +async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)): + sidebar = await get_sidebar_data(db) + result = await db.execute(text( + "SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" + )) + parent_folders = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("weblink_folder_form.html", { + "request": request, "sidebar": sidebar, + "parent_folders": parent_folders, + "page_title": "New Folder", "active_nav": "weblinks", + "item": None, + }) + + +@router.post("/folders/create") +async def create_folder( + request: Request, + name: str = Form(...), + parent_id: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("weblink_folders", db) + data = {"name": name} + if parent_id and parent_id.strip(): + data["parent_id"] = parent_id + await repo.create(data) + return RedirectResponse(url="/weblinks", status_code=303) + + +@router.post("/folders/{folder_id}/delete") +async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("weblink_folders", db) + await repo.soft_delete(folder_id) + return RedirectResponse(url="/weblinks", status_code=303) diff --git a/static/style.css b/static/style.css index 4695c8f..2bd5859 100644 --- a/static/style.css +++ b/static/style.css @@ -983,6 +983,36 @@ a:hover { color: var(--accent-hover); } margin-top: 12px; } +/* ---- Weblinks Layout ---- */ +.weblinks-layout { + display: flex; + gap: 16px; + margin-top: 12px; +} +.weblinks-folders { + width: 200px; + flex-shrink: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 0; + align-self: flex-start; +} +.weblink-folder-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 0.85rem; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + text-decoration: none; +} +.weblink-folder-item:hover { background: var(--surface2); color: var(--text); } +.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; } +.weblinks-content { flex: 1; min-width: 0; } + /* ---- Scrollbar ---- */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } @@ -997,3 +1027,4 @@ a:hover { color: var(--accent-hover); } .dashboard-grid { grid-template-columns: 1fr; } .page-content { padding: 16px; } } +.search-type-appointments { background: var(--amber-soft); color: var(--amber); } diff --git a/templates/appointment_detail.html b/templates/appointment_detail.html new file mode 100644 index 0000000..7ed2712 --- /dev/null +++ b/templates/appointment_detail.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block content %} +
+ +