Files
flowdeck/app/routers/library.py
T
bruno 6a2d56a7bd
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
fix: Library page — hover effects, workspace filter, tree, rename
1. Hover effects fixed: converted table to div-based flexbox layout
   (tr/td don't support display:flex). .lib-row now properly shows
   drag handle, checkbox, and OPEN button on hover.

2. Recents filter by workspace: /api/library/recents now accepts
   workspace_id parameter. Template passes active_workspace_id from
   sidebar context to API calls.

3. Tree hierarchy: API now enriches items with has_children via
   _enrich_children() batch query — shows expand chevrons for
   pages with sub-pages.

4. Rename on click: single click on title starts inline rename
   (was opening page). OPEN button still opens side peek.

5. Code cleanup: extracted _enrich_children() helper to eliminate
   duplicate code across 6 endpoints.
2026-07-18 11:10:55 -04:00

278 lines
8.6 KiB
Python

"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
SOURCE_TYPES = {"all", "local", "gitea", "github"}
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
def _detect_source_type(workspace: str) -> str:
ws = (workspace or "").strip()
if not ws:
return "local"
if ws.startswith("github:"):
return "github"
if "/" in ws:
return "gitea"
return "local"
def _source_label(source_type: str, workspace: str) -> str:
if source_type == "gitea":
return workspace
elif source_type == "github":
return workspace.replace("github:", "", 1)
return workspace or "Private"
def _build_item(db_row: dict, uid: int = 1) -> dict:
workspace = db_row.get("workspace", "") or ""
source_type = _detect_source_type(workspace)
page_id = db_row["id"]
title = db_row.get("title") or "Untitled"
# URL
if source_type in ("gitea", "github"):
ws = workspace.replace("github:", "", 1) if source_type == "github" else workspace
parts = ws.split("/", 1)
url = f"/gitea-workspace?owner={parts[0]}&repo={parts[1] if len(parts) > 1 else ''}"
else:
url = f"/pages/{page_id}"
# Icon
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "📁"
elif content_format == "file":
icon = "📄"
fn = title.lower()
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
else:
icon = "📝"
return {
"id": page_id,
"title": title,
"icon": icon,
"source_type": source_type,
"source_label": _source_label(source_type, workspace),
"workspace": workspace,
"workspace_name": _source_label(source_type, workspace),
"author": db_row.get("author") or "",
"author_initial": (db_row.get("author") or "?")[0].upper(),
"updated_at": db_row.get("updated_at", ""),
"visited_at": db_row.get("visited_at") or "",
"has_children": False,
"children": [],
"url": url,
"content_format": content_format,
"favorited": bool(db_row.get("favorited", 0)),
}
def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]:
if source_type == "local":
query += " AND (p.workspace = '' OR (p.workspace NOT LIKE '%/%' AND p.workspace NOT LIKE 'github:%'))"
elif source_type == "gitea":
query += " AND p.workspace LIKE '%/%' AND p.workspace NOT LIKE 'github:%'"
elif source_type == "github":
query += " AND p.workspace LIKE 'github:%'"
return query, params
def _rows_to_items(rows, uid: int = 1) -> list:
return [_build_item(dict(r), uid) for r in rows]
def _enrich_children(items: list) -> list:
"""Add has_children info to items in a single batch query."""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join(["?" for _ in ids])
with get_conn() as conn:
child_rows = conn.execute(
f"SELECT parent_id, COUNT(*) as cnt FROM pages "
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
f"GROUP BY parent_id",
ids,
).fetchall()
child_counts = {r["parent_id"]: r["cnt"] for r in child_rows}
for it in items:
it["has_children"] = bool(child_counts.get(it["id"], 0))
return items
# ═══════════ Tab endpoints ═══════════
BASE_SELECT = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section"
)
@router.get("/recents")
async def library_recents(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
workspace_id: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
# Filter by active workspace
if workspace_id:
query += " AND p.workspace_id = ?"
params.append(workspace_id)
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/favorites")
async def library_favorites(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY f.position"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/shared")
async def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/published")
async def library_published(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/private")
async def library_private(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/workspace")
async def library_workspace(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.workspace != '' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 50"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}