"""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}