Add up/down arrow buttons to reorder links within a folder, with lazy sort_order initialization. Add dropdown to move existing links into the current folder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
13 KiB
Python
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)
|