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