Links and Other Enhancements

This commit is contained in:
2026-03-02 19:55:04 +00:00
parent cf84d6d2dd
commit 0ed86ee2dc
24 changed files with 475 additions and 153 deletions

View File

@@ -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)