"""Bookmarks: organized folder directory for links.""" 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=["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_bookmarks( 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 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] # 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 links (filtered by folder or all unfiled) if folder_id: result = await db.execute(text(""" 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 links result = await db.execute(text(""" 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] 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": "Bookmarks", "active_nav": "bookmarks", }) @router.get("/create") async def create_form( request: Request, folder_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) result = await db.execute(text( "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 Link", "active_nav": "bookmarks", "item": None, "prefill_folder_id": folder_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(...), description: Optional[str] = Form(None), folder_id: Optional[str] = Form(None), task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("links", db) data = {"label": label, "url": url, "description": description} 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) # 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=links", status_code=303) if meeting_id and meeting_id.strip(): 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("/{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(link_id) if not item: return RedirectResponse(url="/weblinks", status_code=303) result = await db.execute(text( "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_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 Link", "active_nav": "bookmarks", "item": item, "prefill_folder_id": current_folder_id, }) @router.post("/{link_id}/edit") async def update_link( link_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("links", 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(link_id, data) # Update folder assignment 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_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("/{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) # ---- 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 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": "bookmarks", "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("link_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)): # 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)