Files
flowdeck/app/routers/library.py
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00

523 lines
18 KiB
Python

"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query, Request
from app.auth.session import SessionManager
from app.db import get_conn
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 — convention Workspace (noms SVG, comme la sidebar) : la liste
# des favoris de la sidebar consomme item.icon via fdIconHtml.
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "folder"
elif content_format == "file":
icon = "file"
fn = title.lower()
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "image"
else:
icon = "edit"
return {
"id": page_id,
"title": title,
"icon": icon,
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_icon": {"local": "file", "gitea": "link",
"github": "external-link"}.get(source_type, "file"),
"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)),
"share_mode": db_row.get("share_mode", "private"),
"tags": [],
}
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:
items = [_build_item(dict(r), uid) for r in rows]
items = _attach_tags(items)
return _attach_provenance(items, uid)
def _attach_provenance(items: list, uid: int = 1) -> list:
"""Created by / Last edited by / Last visited (colonnes Library).
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
- edited_by : dernier auteur des versions, sinon created_by ;
- visited_at : dernier accès tracé de l'utilisateur (table recents).
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
"""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
try:
with get_conn() as conn:
ver = conn.execute(
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
).fetchall()
first: dict = {}
last: dict = {}
for r in ver:
first.setdefault(r["page_id"], r["name"] or "")
last[r["page_id"]] = r["name"] or ""
own = conn.execute(
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
f"LEFT JOIN users u ON u.id = w.owner_id "
f"WHERE p.id IN ({placeholders})", ids,
).fetchall()
owners = {r["id"]: r["owner"] or "" for r in own}
try:
vis = conn.execute(
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
f"GROUP BY page_id", [uid, *ids],
).fetchall()
except Exception:
vis = []
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
except Exception:
logger.exception("_attach_provenance")
return items
for it in items:
pid = it["id"]
author = first.get(pid) or owners.get(pid, "")
it["author"] = author
it["author_initial"] = (author or "?")[0].upper()
edited_by = last.get(pid) or author
it["edited_by"] = edited_by
it["edited_by_initial"] = (edited_by or "?")[0].upper()
it["visited_at"] = visited.get(pid, "")
return items
def _attach_tags(items: list) -> list:
"""Batch-load page tags for library items."""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
with get_conn() as conn:
rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
ids,
).fetchall()
tag_map: dict = {}
for r in rows:
tag_map.setdefault(r["page_id"], []).append({
"id": r["id"], "name": r["name"], "color": r["color"],
})
for it in items:
it["tags"] = tag_map.get(it["id"], [])
return items
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, p.page_icon"
)
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
NO_MEETING_BLOCK = (
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/meetings")
def library_meetings(
request: Request,
source_type: str = Query(default="all"),
):
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
exclues des onglets de navigation courante (recents/workspace/private).
"""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"{BASE_SELECT}, "
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 100"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it, r in zip(items, rows, strict=True):
d = dict(r)
st = d.get("meeting_status") or "idle"
it["is_meeting"] = True
it["meeting_status"] = st
it["source_label"] = (
"Summary ready" if (d.get("meeting_summary") or "").strip()
else ("Ready to record" if st == "idle"
else st.replace("_", " ").capitalize())
)
_enrich_children(items)
return {"items": items}
@router.get("/recents")
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 AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
if tree:
query += " AND p.parent_id IS NULL"
# 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")
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 = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
if tree:
query += " AND p.parent_id IS NULL"
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")
def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
dir: str = Query(default="all"),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
if dir not in ("made", "received", "all"):
dir = "all"
uid = _get_user_id(request)
# Page ids the user shares toward others (nominal page_shares) or receives
# (direct shares + group shares via group_members)
with get_conn() as conn:
made_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
(uid,),
).fetchall()
try:
recv_rows = conn.execute(
"""SELECT DISTINCT s.page_id FROM page_shares s
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
(uid, uid, uid),
).fetchall()
except Exception:
recv_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
(uid,),
).fetchall()
made_ids = {r[0] for r in made_rows}
recv_ids = {r[0] for r in recv_rows}
conds: list[str] = []
params: list = []
if dir in ("all", "made"):
conds.append("p.share_mode != 'private'")
if dir in ("all", "made") and made_ids:
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
params.extend(made_ids)
if dir in ("all", "received") and recv_ids:
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
params.extend(recv_ids)
if dir == "received" and not recv_ids:
return {"items": []}
query = (
f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
if tree:
query += " AND p.parent_id IS NULL"
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)
for it in items:
sid = it["id"]
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
is_recv = sid in recv_ids
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
_enrich_children(items)
return {"items": items}
@router.get("/published")
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)
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
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")
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 AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
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("/children/{page_id:int}")
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
uid = _get_user_id(request)
with get_conn() as conn:
rows = conn.execute(
f"{BASE_SELECT}, (SELECT COUNT(*) FROM pages c WHERE c.parent_id = p.id AND c.deleted_at IS NULL) as child_count "
f"FROM pages p WHERE p.parent_id = ? AND p.deleted_at IS NULL "
f"ORDER BY p.title COLLATE NOCASE",
[page_id],
).fetchall()
items = _rows_to_items(rows, uid)
_enrich_children(items)
return {"items": items}
@router.get("/repository")
def library_repository(
request: Request,
gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""),
):
"""Return Gitea repository pages/files for the Library Repository tab."""
if not gitea_owner or not gitea_repo:
return {"items": []}
uid = _get_user_id(request)
ws_key = f"{gitea_owner}/{gitea_repo}"
query = f"{BASE_SELECT} FROM pages p WHERE p.workspace = ? AND p.deleted_at IS NULL"
params = [ws_key]
query += " ORDER BY p.updated_at DESC LIMIT 100"
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")
def library_workspace(
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.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
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}