Files
lifeos-prod/routers/weblinks.py
2026-03-03 00:44:33 +00:00

370 lines
13 KiB
Python

"""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)
available_links = []
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})
items = [dict(r._mapping) for r in result]
# Links NOT in this folder (for "add existing" dropdown)
result = await db.execute(text("""
SELECT l.id, l.label FROM links l
WHERE l.is_deleted = false
AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
ORDER BY l.label
"""), {"fid": folder_id})
available_links = [dict(r._mapping) for r in result]
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 "",
"available_links": available_links,
"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)
# ---- Reorder links within a folder ----
@router.post("/folders/{folder_id}/reorder")
async def reorder_link(
folder_id: str,
link_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Get all folder_links for this folder, ordered by sort_order then created_at
result = await db.execute(text("""
SELECT link_id, sort_order FROM folder_links
WHERE folder_id = :fid
ORDER BY sort_order, created_at
"""), {"fid": folder_id})
rows = [dict(r._mapping) for r in result]
if not rows:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Lazy-initialize sort_order if all zeros
all_zero = all(r["sort_order"] == 0 for r in rows)
if all_zero:
for i, r in enumerate(rows):
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
r["sort_order"] = (i + 1) * 10
# Find target index
idx = None
for i, r in enumerate(rows):
if str(r["link_id"]) == link_id:
idx = i
break
if idx is None:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Determine swap partner
if direction == "up" and idx > 0:
swap_idx = idx - 1
elif direction == "down" and idx < len(rows) - 1:
swap_idx = idx + 1
else:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Swap sort_order values
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Add existing link to folder ----
@router.post("/folders/{folder_id}/add-link")
async def add_link_to_folder(
folder_id: str,
link_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Remove link from any existing folder (single-folder membership)
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
# Get max sort_order in target folder
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
"""), {"fid": folder_id})
max_so = result.scalar()
# Insert into target folder at end
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id, sort_order)
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", 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)