diff --git a/main.py b/main.py index 6deb1bd..f4b02d9 100644 --- a/main.py +++ b/main.py @@ -155,12 +155,48 @@ async def dashboard(request: Request): """)) stats = dict(result.first()._mapping) + # Overdue projects (target_date in the past) + result = await db.execute(text(""" + SELECT p.id, p.name, p.priority, p.target_date, p.status, + d.name as domain_name, d.color as domain_color, + count(t.id) FILTER (WHERE t.is_deleted = false) as task_count, + count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count + FROM projects p + LEFT JOIN domains d ON p.domain_id = d.id + LEFT JOIN tasks t ON t.project_id = p.id + WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold') + AND p.target_date < CURRENT_DATE + GROUP BY p.id, d.name, d.color + ORDER BY p.target_date ASC + LIMIT 10 + """)) + overdue_projects = [dict(r._mapping) for r in result] + + # Upcoming project deadlines (next 30 days) + result = await db.execute(text(""" + SELECT p.id, p.name, p.priority, p.target_date, p.status, + d.name as domain_name, d.color as domain_color, + count(t.id) FILTER (WHERE t.is_deleted = false) as task_count, + count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count + FROM projects p + LEFT JOIN domains d ON p.domain_id = d.id + LEFT JOIN tasks t ON t.project_id = p.id + WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold') + AND p.target_date >= CURRENT_DATE AND p.target_date <= CURRENT_DATE + INTERVAL '30 days' + GROUP BY p.id, d.name, d.color + ORDER BY p.target_date ASC + LIMIT 10 + """)) + upcoming_projects = [dict(r._mapping) for r in result] + return templates.TemplateResponse("dashboard.html", { "request": request, "sidebar": sidebar, "focus_items": focus_items, "overdue_tasks": overdue_tasks, "upcoming_tasks": upcoming_tasks, + "overdue_projects": overdue_projects, + "upcoming_projects": upcoming_projects, "stats": stats, "page_title": "Dashboard", "active_nav": "dashboard", diff --git a/routers/admin.py b/routers/admin.py index 08d90b8..6deb0af 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -27,8 +27,7 @@ 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": "link_folders", "label": "Link 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/capture.py b/routers/capture.py index 57d5489..14c01b6 100644 --- a/routers/capture.py +++ b/routers/capture.py @@ -13,6 +13,7 @@ from sqlalchemy import text from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data +from routers.weblinks import get_default_folder_id router = APIRouter(prefix="/capture", tags=["capture"]) templates = Jinja2Templates(directory="templates") @@ -24,7 +25,7 @@ CONVERT_TYPES = { "list_item": "List Item", "contact": "Contact", "decision": "Decision", - "weblink": "Weblink", + "link": "Link", } @@ -309,8 +310,8 @@ async def convert_to_decision( return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303) -@router.post("/{capture_id}/to-weblink") -async def convert_to_weblink( +@router.post("/{capture_id}/to-link") +async def convert_to_link( capture_id: str, request: Request, label: Optional[str] = Form(None), url: Optional[str] = Form(None), @@ -321,7 +322,7 @@ async def convert_to_weblink( if not item: return RedirectResponse(url="/capture", status_code=303) - weblink_repo = BaseRepository("weblinks", db) + link_repo = BaseRepository("links", db) raw = item["raw_text"] url_match = re.search(r'https?://\S+', raw) link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw) @@ -330,13 +331,20 @@ async def convert_to_weblink( link_label = link_url data = {"label": link_label, "url": link_url} - weblink = await weblink_repo.create(data) + link = await link_repo.create(data) + + # Assign to Default folder + default_fid = await get_default_folder_id(db) + await db.execute(text(""" + INSERT INTO folder_links (folder_id, link_id) + VALUES (:fid, :lid) ON CONFLICT DO NOTHING + """), {"fid": default_fid, "lid": link["id"]}) await capture_repo.update(capture_id, { - "processed": True, "converted_to_type": "weblink", - "converted_to_id": str(weblink["id"]), + "processed": True, "converted_to_type": "link", + "converted_to_id": str(link["id"]), }) - return RedirectResponse(url="/weblinks", status_code=303) + return RedirectResponse(url="/links", status_code=303) @router.post("/{capture_id}/dismiss") diff --git a/routers/history.py b/routers/history.py index 36c42f5..4325f49 100644 --- a/routers/history.py +++ b/routers/history.py @@ -24,7 +24,6 @@ HISTORY_ENTITIES = [ ("meetings", "title", "Meeting", "/meetings"), ("decisions", "title", "Decision", "/decisions"), ("lists", "name", "List", "/lists"), - ("weblinks", "label", "Weblink", "/weblinks"), ("appointments", "title", "Appointment", "/appointments"), ("links", "label", "Link", "/links"), ("files", "original_filename", "File", "/files"), diff --git a/routers/links.py b/routers/links.py index df1687d..fadba1d 100644 --- a/routers/links.py +++ b/routers/links.py @@ -1,4 +1,4 @@ -"""Links: URL references attached to domains/projects.""" +"""Links: URL references attached to domains/projects/tasks/meetings.""" from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates @@ -10,6 +10,7 @@ from typing import Optional from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data +from routers.weblinks import get_default_folder_id router = APIRouter(prefix="/links", tags=["links"]) templates = Jinja2Templates(directory="templates") @@ -45,7 +46,14 @@ async def list_links(request: Request, domain_id: Optional[str] = None, db: Asyn @router.get("/create") -async def create_form(request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db)): +async def create_form( + request: Request, + domain_id: Optional[str] = None, + project_id: Optional[str] = None, + task_id: Optional[str] = None, + meeting_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): sidebar = await get_sidebar_data(db) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() @@ -54,22 +62,48 @@ async def create_form(request: Request, domain_id: Optional[str] = None, project return templates.TemplateResponse("link_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "page_title": "New Link", "active_nav": "links", - "item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "", + "item": None, + "prefill_domain_id": domain_id or "", + "prefill_project_id": project_id or "", + "prefill_task_id": task_id or "", + "prefill_meeting_id": meeting_id or "", }) @router.post("/create") async def create_link( request: Request, label: str = Form(...), url: str = Form(...), - domain_id: str = Form(...), project_id: Optional[str] = Form(None), - description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), + domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), + task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), ): repo = BaseRepository("links", db) - data = {"label": label, "url": url, "domain_id": domain_id, "description": description} + data = {"label": label, "url": url, "description": description} + if domain_id and domain_id.strip(): + data["domain_id"] = domain_id if project_id and project_id.strip(): data["project_id"] = project_id - await repo.create(data) - referer = request.headers.get("referer", "/links") + if task_id and task_id.strip(): + data["task_id"] = task_id + if meeting_id and meeting_id.strip(): + data["meeting_id"] = meeting_id + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + link = await repo.create(data) + + # Assign to Default folder + default_fid = await get_default_folder_id(db) + await db.execute(text(""" + INSERT INTO folder_links (folder_id, link_id) + VALUES (:fid, :lid) ON CONFLICT DO NOTHING + """), {"fid": default_fid, "lid": link["id"]}) + + # Redirect back to context if created from task/meeting + if task_id and task_id.strip(): + return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303) + if meeting_id and meeting_id.strip(): + return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303) return RedirectResponse(url="/links", status_code=303) @@ -87,22 +121,31 @@ async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(g return templates.TemplateResponse("link_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "page_title": "Edit Link", "active_nav": "links", - "item": item, "prefill_domain_id": "", "prefill_project_id": "", + "item": item, + "prefill_domain_id": "", "prefill_project_id": "", + "prefill_task_id": "", "prefill_meeting_id": "", }) @router.post("/{link_id}/edit") async def update_link( link_id: str, label: str = Form(...), url: str = Form(...), - domain_id: str = Form(...), project_id: Optional[str] = Form(None), - description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), + domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), ): repo = BaseRepository("links", db) - await repo.update(link_id, { - "label": label, "url": url, "domain_id": domain_id, + data = { + "label": label, "url": url, + "domain_id": domain_id if domain_id and domain_id.strip() else None, "project_id": project_id if project_id and project_id.strip() else None, "description": description, - }) + } + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + else: + data["tags"] = None + await repo.update(link_id, data) return RedirectResponse(url="/links", status_code=303) diff --git a/routers/meetings.py b/routers/meetings.py index 96d4e08..b0add87 100644 --- a/routers/meetings.py +++ b/routers/meetings.py @@ -118,12 +118,24 @@ async def meeting_detail( if not item: return RedirectResponse(url="/meetings", status_code=303) + # Linked projects (always shown in header) + result = await db.execute(text(""" + SELECT p.id, p.name, d.color as domain_color + FROM projects p + JOIN project_meetings pm ON pm.project_id = p.id + LEFT JOIN domains d ON p.domain_id = d.id + WHERE pm.meeting_id = :mid AND p.is_deleted = false + ORDER BY p.name + """), {"mid": meeting_id}) + projects = [dict(r._mapping) for r in result] + # Overview data (always needed for overview tab) action_items = [] decisions = [] domains = [] tab_data = [] all_contacts = [] + all_decisions = [] if tab == "overview": # Action items @@ -160,9 +172,9 @@ async def meeting_detail( """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] - elif tab == "weblinks": + elif tab == "links": result = await db.execute(text(""" - SELECT * FROM weblinks + SELECT * FROM links WHERE meeting_id = :mid AND is_deleted = false ORDER BY sort_order, label """), {"mid": meeting_id}) @@ -187,6 +199,20 @@ async def meeting_detail( """), {"mid": meeting_id}) tab_data = [dict(r._mapping) for r in result] + elif tab == "decisions": + result = await db.execute(text(""" + SELECT * FROM decisions + WHERE meeting_id = :mid AND is_deleted = false + ORDER BY created_at DESC + """), {"mid": meeting_id}) + tab_data = [dict(r._mapping) for r in result] + result = await db.execute(text(""" + SELECT id, title FROM decisions + WHERE (meeting_id IS NULL) AND is_deleted = false + ORDER BY created_at DESC LIMIT 50 + """)) + all_decisions = [dict(r._mapping) for r in result] + elif tab == "contacts": result = await db.execute(text(""" SELECT c.*, cm.role, cm.created_at as linked_at @@ -206,9 +232,10 @@ async def meeting_detail( counts = {} for count_tab, count_sql in [ ("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"), - ("weblinks", "SELECT count(*) FROM weblinks WHERE meeting_id = :mid AND is_deleted = false"), + ("links", "SELECT count(*) FROM links WHERE meeting_id = :mid AND is_deleted = false"), ("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"), ("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"), + ("decisions", "SELECT count(*) FROM decisions WHERE meeting_id = :mid AND is_deleted = false"), ("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"), ]: result = await db.execute(text(count_sql), {"mid": meeting_id}) @@ -217,8 +244,10 @@ async def meeting_detail( return templates.TemplateResponse("meeting_detail.html", { "request": request, "sidebar": sidebar, "item": item, "action_items": action_items, "decisions": decisions, - "domains": domains, "tab": tab, "tab_data": tab_data, - "all_contacts": all_contacts, "counts": counts, + "domains": domains, "projects": projects, + "tab": tab, "tab_data": tab_data, + "all_contacts": all_contacts, "all_decisions": all_decisions, + "counts": counts, "page_title": item["title"], "active_nav": "meetings", }) @@ -325,6 +354,31 @@ async def create_action_item( return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) +# ---- Decision linking ---- + +@router.post("/{meeting_id}/decisions/add") +async def add_decision( + meeting_id: str, + decision_id: str = Form(...), + db: AsyncSession = Depends(get_db), +): + await db.execute(text(""" + UPDATE decisions SET meeting_id = :mid WHERE id = :did + """), {"mid": meeting_id, "did": decision_id}) + return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303) + + +@router.post("/{meeting_id}/decisions/{decision_id}/remove") +async def remove_decision( + meeting_id: str, decision_id: str, + db: AsyncSession = Depends(get_db), +): + await db.execute(text(""" + UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid + """), {"did": decision_id, "mid": meeting_id}) + return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303) + + # ---- Contact linking ---- @router.post("/{meeting_id}/contacts/add") diff --git a/routers/projects.py b/routers/projects.py index fe998d5..8dbd6d2 100644 --- a/routers/projects.py +++ b/routers/projects.py @@ -192,6 +192,7 @@ async def project_detail( links = [] tab_data = [] all_contacts = [] + all_meetings = [] if tab == "notes": result = await db.execute(text(""" @@ -235,6 +236,21 @@ async def project_detail( """), {"pid": project_id}) tab_data = [dict(r._mapping) for r in result] + elif tab == "meetings": + result = await db.execute(text(""" + SELECT m.*, pm.created_at as linked_at + FROM meetings m + JOIN project_meetings pm ON pm.meeting_id = m.id + WHERE pm.project_id = :pid AND m.is_deleted = false + ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST + """), {"pid": project_id}) + tab_data = [dict(r._mapping) for r in result] + result = await db.execute(text(""" + SELECT id, title, meeting_date FROM meetings + WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50 + """)) + all_meetings = [dict(r._mapping) for r in result] + elif tab == "contacts": result = await db.execute(text(""" SELECT c.*, cp.role, cp.created_at as linked_at @@ -258,6 +274,7 @@ async def project_detail( ("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"), ("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"), ("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"), + ("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"), ("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"), ]: result = await db.execute(text(count_sql), {"pid": project_id}) @@ -267,7 +284,8 @@ async def project_detail( "request": request, "sidebar": sidebar, "item": item, "domain": domain, "area": area, "tasks": tasks, "notes": notes, "links": links, - "tab_data": tab_data, "all_contacts": all_contacts, "counts": counts, + "tab_data": tab_data, "all_contacts": all_contacts, + "all_meetings": all_meetings, "counts": counts, "progress": progress, "task_count": total, "done_count": done, "tab": tab, "page_title": item["name"], "active_nav": "projects", @@ -332,6 +350,32 @@ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)): return RedirectResponse(url="/projects", status_code=303) +# ---- Meeting linking ---- + +@router.post("/{project_id}/meetings/add") +async def add_meeting( + project_id: str, + meeting_id: str = Form(...), + db: AsyncSession = Depends(get_db), +): + await db.execute(text(""" + INSERT INTO project_meetings (project_id, meeting_id) + VALUES (:pid, :mid) ON CONFLICT DO NOTHING + """), {"pid": project_id, "mid": meeting_id}) + return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303) + + +@router.post("/{project_id}/meetings/{meeting_id}/remove") +async def remove_meeting( + project_id: str, meeting_id: str, + db: AsyncSession = Depends(get_db), +): + await db.execute(text( + "DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid" + ), {"pid": project_id, "mid": meeting_id}) + return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303) + + # ---- Contact linking ---- @router.post("/{project_id}/contacts/add") diff --git a/routers/search.py b/routers/search.py index 89ff9a4..451bbf6 100644 --- a/routers/search.py +++ b/routers/search.py @@ -88,13 +88,6 @@ SEARCH_ENTITIES = [ "domain_col": "NULL", "project_col": "NULL", "url": "/decisions/{id}", "icon": "decision", }, - { - "type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w", - "name_col": "w.label", "status_col": "NULL", - "joins": "", - "domain_col": "NULL", "project_col": "NULL", - "url": "/weblinks", "icon": "weblink", - }, { "type": "processes", "label": "Processes", "table": "processes", "alias": "p", "name_col": "p.name", "status_col": "p.status", diff --git a/routers/tasks.py b/routers/tasks.py index b087fd8..cd6073b 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -238,9 +238,9 @@ async def task_detail( """), {"tid": task_id}) tab_data = [dict(r._mapping) for r in result] - elif tab == "weblinks": + elif tab == "links": result = await db.execute(text(""" - SELECT * FROM weblinks WHERE task_id = :tid AND is_deleted = false + SELECT * FROM links WHERE task_id = :tid AND is_deleted = false ORDER BY sort_order, label """), {"tid": task_id}) tab_data = [dict(r._mapping) for r in result] @@ -291,7 +291,7 @@ async def task_detail( counts = {} for count_tab, count_sql in [ ("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"), - ("weblinks", "SELECT count(*) FROM weblinks WHERE task_id = :tid AND is_deleted = false"), + ("links", "SELECT count(*) FROM links WHERE task_id = :tid AND is_deleted = false"), ("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"), ("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"), ("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"), diff --git a/routers/weblinks.py b/routers/weblinks.py index baad7bb..a3f7d3b 100644 --- a/routers/weblinks.py +++ b/routers/weblinks.py @@ -1,4 +1,4 @@ -"""Weblinks: organized bookmark directory with recursive folders.""" +"""Bookmarks: organized folder directory for links.""" from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates @@ -11,12 +11,25 @@ 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"]) +router = APIRouter(prefix="/weblinks", tags=["bookmarks"]) templates = Jinja2Templates(directory="templates") +async def get_default_folder_id(db: AsyncSession) -> str: + """Return the Default folder id, creating it if it doesn't exist.""" + result = await db.execute(text( + "SELECT id FROM link_folders WHERE name = 'Default' AND is_deleted = false ORDER BY created_at LIMIT 1" + )) + row = result.first() + if row: + return str(row[0]) + repo = BaseRepository("link_folders", db) + folder = await repo.create({"name": "Default", "sort_order": 0}) + return str(folder["id"]) + + @router.get("/") -async def list_weblinks( +async def list_bookmarks( request: Request, folder_id: Optional[str] = None, db: AsyncSession = Depends(get_db), @@ -25,10 +38,10 @@ async def list_weblinks( # 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 + SELECT lf.*, (SELECT count(*) FROM folder_links fl WHERE fl.folder_id = lf.id) as link_count + FROM link_folders lf + WHERE lf.is_deleted = false + ORDER BY lf.sort_order, lf.name """)) all_folders = [dict(r._mapping) for r in result] @@ -48,20 +61,20 @@ async def list_weblinks( current_folder = f break - # Get weblinks (filtered by folder or all unfiled) + # Get links (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 + SELECT l.* FROM links l + JOIN folder_links fl ON fl.link_id = l.id + WHERE fl.folder_id = :fid AND l.is_deleted = false + ORDER BY fl.sort_order, l.label """), {"fid": folder_id}) else: - # Show all weblinks + # Show all links result = await db.execute(text(""" - SELECT w.* FROM weblinks w - WHERE w.is_deleted = false - ORDER BY w.sort_order, w.label + SELECT l.* FROM links l + WHERE l.is_deleted = false + ORDER BY l.sort_order, l.label """)) items = [dict(r._mapping) for r in result] @@ -70,7 +83,7 @@ async def list_weblinks( "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", + "page_title": "Bookmarks", "active_nav": "bookmarks", }) @@ -84,14 +97,14 @@ async def create_form( ): sidebar = await get_sidebar_data(db) result = await db.execute(text( - "SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" + "SELECT id, name FROM link_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", + "page_title": "New Link", "active_nav": "bookmarks", "item": None, "prefill_folder_id": folder_id or "", "prefill_task_id": task_id or "", @@ -100,7 +113,7 @@ async def create_form( @router.post("/create") -async def create_weblink( +async def create_link( request: Request, label: str = Form(...), url: str = Form(...), @@ -111,7 +124,7 @@ async def create_weblink( tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): - repo = BaseRepository("weblinks", db) + repo = BaseRepository("links", db) data = {"label": label, "url": url, "description": description} if task_id and task_id.strip(): data["task_id"] = task_id @@ -120,55 +133,55 @@ async def create_weblink( if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] - weblink = await repo.create(data) + link = 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"]}) + # Add to folder (default if none specified) + effective_folder = folder_id if folder_id and folder_id.strip() else await get_default_folder_id(db) + await db.execute(text(""" + INSERT INTO folder_links (folder_id, link_id) + VALUES (:fid, :lid) ON CONFLICT DO NOTHING + """), {"fid": effective_folder, "lid": link["id"]}) if task_id and task_id.strip(): - return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303) + return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303) if meeting_id and meeting_id.strip(): - return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", status_code=303) + return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303) 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) +@router.get("/{link_id}/edit") +async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("links", db) sidebar = await get_sidebar_data(db) - item = await repo.get(weblink_id) + item = await repo.get(link_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" + "SELECT id, name FROM link_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}) + "SELECT folder_id FROM folder_links WHERE link_id = :lid LIMIT 1" + ), {"lid": link_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", + "page_title": "Edit Link", "active_nav": "bookmarks", "item": item, "prefill_folder_id": current_folder_id, }) -@router.post("/{weblink_id}/edit") -async def update_weblink( - weblink_id: str, +@router.post("/{link_id}/edit") +async def update_link( + link_id: str, label: str = Form(...), url: str = Form(...), description: Optional[str] = Form(None), @@ -176,7 +189,7 @@ async def update_weblink( tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): - repo = BaseRepository("weblinks", db) + repo = BaseRepository("links", db) data = { "label": label, "url": url, "description": description if description and description.strip() else None, @@ -186,23 +199,23 @@ async def update_weblink( else: data["tags"] = None - await repo.update(weblink_id, data) + await repo.update(link_id, data) # Update folder assignment - await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id}) + await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_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}) + INSERT INTO folder_links (folder_id, link_id) + VALUES (:fid, :lid) ON CONFLICT DO NOTHING + """), {"fid": folder_id, "lid": link_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) +@router.post("/{link_id}/delete") +async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("links", db) + await repo.soft_delete(link_id) referer = request.headers.get("referer", "/weblinks") return RedirectResponse(url=referer, status_code=303) @@ -213,14 +226,14 @@ async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = D 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" + "SELECT id, name FROM link_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", + "page_title": "New Folder", "active_nav": "bookmarks", "item": None, }) @@ -232,7 +245,7 @@ async def create_folder( parent_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): - repo = BaseRepository("weblink_folders", db) + repo = BaseRepository("link_folders", db) data = {"name": name} if parent_id and parent_id.strip(): data["parent_id"] = parent_id @@ -242,6 +255,13 @@ async def create_folder( @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) + # Prevent deleting the Default folder + result = await db.execute(text( + "SELECT name FROM link_folders WHERE id = :id" + ), {"id": folder_id}) + row = result.first() + if row and row[0] == "Default": + return RedirectResponse(url="/weblinks", status_code=303) + repo = BaseRepository("link_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 5284987..041edbc 100644 --- a/static/style.css +++ b/static/style.css @@ -1092,6 +1092,22 @@ a:hover { color: var(--accent-hover); } .weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; } .weblinks-content { flex: 1; min-width: 0; } +/* ---- Project Progress Mini Bar (dashboard) ---- */ +.project-progress-mini { + width: 60px; + height: 6px; + background: var(--surface2); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} +.project-progress-bar { + height: 100%; + background: var(--green); + border-radius: 3px; + transition: width 0.3s; +} + /* ---- Scrollbar ---- */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/templates/base.html b/templates/base.html index ed306a7..4a06282 100644 --- a/templates/base.html +++ b/templates/base.html @@ -87,9 +87,9 @@ Files - + - Weblinks + Bookmarks @@ -234,7 +234,7 @@ Decisions Contacts Processes - Weblinks + Bookmarks Admin diff --git a/templates/capture.html b/templates/capture.html index 2fbe9f7..021682d 100644 --- a/templates/capture.html +++ b/templates/capture.html @@ -75,8 +75,8 @@ {% if item.converted_to_id %} {% if item.converted_to_type == 'list_item' and item.list_id %} View → - {% elif item.converted_to_type == 'weblink' %} - View → + {% elif item.converted_to_type == 'link' %} + View → {% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %} View → {% endif %} diff --git a/templates/capture_convert.html b/templates/capture_convert.html index 404dbf6..5cffd08 100644 --- a/templates/capture_convert.html +++ b/templates/capture_convert.html @@ -132,7 +132,7 @@ - {% elif convert_type == 'weblink' %} + {% elif convert_type == 'link' %}
diff --git a/templates/dashboard.html b/templates/dashboard.html index 0389354..1ffc779 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -91,4 +91,50 @@ {% endif %}
+ + +{% if overdue_projects or upcoming_projects %} +
+
+

Project Deadlines

+ All Projects +
+ + {% if overdue_projects %} +
OVERDUE
+ {% for p in overdue_projects %} +
+ + {{ p.name }} + {% if p.domain_name %} + {{ p.domain_name }} + {% endif %} +
+
+
+ {{ p.done_count }}/{{ p.task_count }} + {{ p.target_date }} +
+ {% endfor %} + {% endif %} + + {% if upcoming_projects %} +
NEXT 30 DAYS
+ {% for p in upcoming_projects %} +
+ + {{ p.name }} + {% if p.domain_name %} + {{ p.domain_name }} + {% endif %} +
+
+
+ {{ p.done_count }}/{{ p.task_count }} + {{ p.target_date }} +
+ {% endfor %} + {% endif %} +
+{% endif %} {% endblock %} diff --git a/templates/link_form.html b/templates/link_form.html index a893434..753207f 100644 --- a/templates/link_form.html +++ b/templates/link_form.html @@ -7,11 +7,14 @@
-
-
+
+
+
+ {% if prefill_task_id is defined and prefill_task_id %}{% endif %} + {% if prefill_meeting_id is defined and prefill_meeting_id %}{% endif %}
Cancel
diff --git a/templates/meeting_detail.html b/templates/meeting_detail.html index 891a021..a1e822c 100644 --- a/templates/meeting_detail.html +++ b/templates/meeting_detail.html @@ -23,6 +23,11 @@ {% if item.start_at and item.end_at %} {{ item.start_at.strftime('%H:%M') }} - {{ item.end_at.strftime('%H:%M') }} {% endif %} + {% if projects %} + {% for p in projects %} + {{ p.name }} + {% endfor %} + {% endif %} {% if item.tags %}
{% for tag in item.tags %}{{ tag }}{% endfor %}
{% endif %} @@ -32,9 +37,10 @@
Overview Notes{% if counts.notes %} ({{ counts.notes }}){% endif %} - Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %} + Links{% if counts.links %} ({{ counts.links }}){% endif %} Files{% if counts.files %} ({{ counts.files }}){% endif %} Lists{% if counts.lists %} ({{ counts.lists }}){% endif %} + Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %} Processes Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}
@@ -122,15 +128,15 @@
No notes linked to this meeting
{% endfor %} -{% elif tab == 'weblinks' %} -+ New Weblink +{% elif tab == 'links' %} ++ New Link {% for w in tab_data %}
{{ w.label }} {{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}
{% else %} -
No weblinks linked to this meeting
+
No links linked to this meeting
{% endfor %} {% elif tab == 'files' %} @@ -155,6 +161,38 @@
No lists linked to this meeting
{% endfor %} +{% elif tab == 'decisions' %} +
+
+
+ + +
+ +
+
++ New Decision +{% for d in tab_data %} +
+ {{ d.title }} + {{ d.status }} + {% if d.impact %}{{ d.impact }}{% endif %} + {% if d.decided_at %}{{ d.decided_at.strftime('%Y-%m-%d') if d.decided_at else '' }}{% endif %} +
+
+ +
+
+
+{% else %} +
No decisions linked to this meeting
+{% endfor %} + {% elif tab == 'processes' %}
Process management coming soon
diff --git a/templates/project_detail.html b/templates/project_detail.html index 695c54c..e0e1cab 100644 --- a/templates/project_detail.html +++ b/templates/project_detail.html @@ -39,6 +39,7 @@ Files{% if counts.files %} ({{ counts.files }}){% endif %} Lists{% if counts.lists %} ({{ counts.lists }}){% endif %} Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %} + Meetings{% if counts.meetings %} ({{ counts.meetings }}){% endif %} Processes Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %} @@ -130,6 +131,37 @@
No decisions linked to this project
{% endfor %} +{% elif tab == 'meetings' %} ++ New Meeting +
+
+
+ + +
+ +
+
+{% for m in tab_data %} +
+ {{ m.title }} + {{ m.meeting_date }} + {{ m.status }} +
+
+ +
+
+
+{% else %} +
No meetings linked to this project
+{% endfor %} + {% elif tab == 'processes' %}
Process management coming soon
diff --git a/templates/task_detail.html b/templates/task_detail.html index 1803a6a..d14d667 100644 --- a/templates/task_detail.html +++ b/templates/task_detail.html @@ -58,7 +58,7 @@
Overview{% if counts.overview %} ({{ counts.overview }}){% endif %} Notes{% if counts.notes %} ({{ counts.notes }}){% endif %} - Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %} + Links{% if counts.links %} ({{ counts.links }}){% endif %} Files{% if counts.files %} ({{ counts.files }}){% endif %} Lists{% if counts.lists %} ({{ counts.lists }}){% endif %} Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %} @@ -107,15 +107,15 @@
No notes linked to this task
{% endfor %} -{% elif tab == 'weblinks' %} -+ New Weblink +{% elif tab == 'links' %} ++ New Link {% for w in tab_data %}
{{ w.label }} {{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}
{% else %} -
No weblinks linked to this task
+
No links linked to this task
{% endfor %} {% elif tab == 'files' %} diff --git a/templates/weblink_form.html b/templates/weblink_form.html index 278ad70..588f7b6 100644 --- a/templates/weblink_form.html +++ b/templates/weblink_form.html @@ -44,8 +44,8 @@ {% if prefill_task_id is defined and prefill_task_id %}{% endif %} {% if prefill_meeting_id is defined and prefill_meeting_id %}{% endif %}
- - Cancel + + Cancel
diff --git a/templates/weblinks.html b/templates/weblinks.html index c208c5e..0ffa959 100644 --- a/templates/weblinks.html +++ b/templates/weblinks.html @@ -1,10 +1,10 @@ {% extends "base.html" %} {% block content %} @@ -12,7 +12,7 @@ - +