- app/db.py: ajout colonne shared_with_group_id (FK user_groups, migration
backfill v5.x) dans page_shares
- app/routers/sharing.py: POST /api/pages/{id}/share accepte group_id
(upsert, verif FK groupe), GET /shares expose kind/group_name, synchro
bidirectionnelle avec page_permissions (mirror grant/revoke) pour que
PermissionManager donne un acces effectif (view/comment/edit) aux membres
du groupe; PUT/DELETE gardent le miroir a jour
- app/routers/board.py, library.py: received/made incluent les partages via
groupes (JOIN group_members)
- app/templates/_page_editor_content.html, _page_editor_scripts.html:
dialogue Share — invite groups (fetch /api/v2/groups, filtre deja partages),
pickInviteGroup, shareInvite(group_id), rendu accessList avec avatar groupe
- tests/test_share_groups.py: 10 tests (CRUD groupe, kind, miroir ACL)
Chore: inclut evolutions v6.4.0 deja en working copy (webhooks prod,
migrations, sync, config/main) pour garder l'arbre coherent.
567 lines
19 KiB
Python
567 lines
19 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
|
|
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,
|
|
"page_icon": db_row.get("page_icon") or "",
|
|
"is_folder": bool(db_row.get("is_folder", 0)),
|
|
"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)),
|
|
"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]
|
|
return _attach_tags(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"
|
|
)
|
|
|
|
|
|
@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 = []
|
|
|
|
# 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")
|
|
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 = (
|
|
"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")
|
|
async 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")
|
|
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 = []
|
|
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")
|
|
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 = []
|
|
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("/local-workspace-children/{item_id:int}")
|
|
async def library_local_workspace_children(item_id: int, request: Request):
|
|
"""Return children of a local workspace item for tree expansion."""
|
|
_get_user_id(request)
|
|
with get_conn() as conn:
|
|
# Get the item to find its workspace
|
|
item = conn.execute(
|
|
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
|
[item_id],
|
|
).fetchone()
|
|
if not item:
|
|
return {"items": []}
|
|
|
|
rows = conn.execute(
|
|
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
|
"COALESCE(updated_at, created_at) as updated_at "
|
|
"FROM local_workspace_items "
|
|
"WHERE parent_id = ? AND deleted_at IS NULL "
|
|
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
|
[item_id],
|
|
).fetchall()
|
|
|
|
items = []
|
|
for r in rows:
|
|
name = r["name"] or "Untitled"
|
|
is_folder = bool(r["is_folder"])
|
|
icon = "📁" if is_folder else "📄"
|
|
fn = name.lower()
|
|
if not is_folder:
|
|
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 = "📜"
|
|
|
|
with get_conn() as conn:
|
|
child_count = conn.execute(
|
|
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
|
[r["id"]],
|
|
).fetchone()[0]
|
|
|
|
items.append({
|
|
"id": r["id"],
|
|
"title": name,
|
|
"icon": icon,
|
|
"is_folder": is_folder,
|
|
"source_type": "local-ws",
|
|
"source_label": "",
|
|
"workspace": "",
|
|
"workspace_name": "",
|
|
"author": "",
|
|
"author_initial": "?",
|
|
"updated_at": r["updated_at"] or "",
|
|
"visited_at": "",
|
|
"has_children": child_count > 0,
|
|
"children": [],
|
|
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
|
"content_format": r["content_format"] or "file",
|
|
"favorited": False,
|
|
"page_icon": "",
|
|
"tags": [],
|
|
"size_display": _format_size(r["size"]) if r["size"] else "",
|
|
})
|
|
|
|
return {"items": items}
|
|
|
|
|
|
@router.get("/children/{page_id:int}")
|
|
async 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")
|
|
async 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("/local-workspace")
|
|
async def library_local_workspace(
|
|
request: Request,
|
|
workspace_id: int = Query(default=0),
|
|
):
|
|
"""Return local workspace items (files/folders) formatted for Library display."""
|
|
from app.routers.dashboard import _get_active_workspace
|
|
uid = _get_user_id(request)
|
|
|
|
# Get the active workspace
|
|
ws = _get_active_workspace(request, user_id=uid)
|
|
if not ws:
|
|
return {"items": []}
|
|
|
|
ws_id = workspace_id or ws["id"]
|
|
|
|
# Query local workspace tree
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
|
"COALESCE(updated_at, created_at) as updated_at "
|
|
"FROM local_workspace_items "
|
|
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
|
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
|
[ws_id],
|
|
).fetchall()
|
|
|
|
items = []
|
|
for r in rows:
|
|
name = r["name"] or "Untitled"
|
|
is_folder = bool(r["is_folder"])
|
|
icon = "📁" if is_folder else "📄"
|
|
fn = name.lower()
|
|
if not is_folder:
|
|
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 = "📜"
|
|
|
|
# Check for children
|
|
child_count = conn.execute(
|
|
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
|
[r["id"]],
|
|
).fetchone()[0]
|
|
|
|
items.append({
|
|
"id": r["id"],
|
|
"title": name,
|
|
"icon": icon,
|
|
"is_folder": is_folder,
|
|
"source_type": "local-ws",
|
|
"source_label": ws.get("name", "Workspace"),
|
|
"workspace": ws.get("name", ""),
|
|
"workspace_name": ws.get("name", ""),
|
|
"author": "",
|
|
"author_initial": "?",
|
|
"updated_at": r["updated_at"] or "",
|
|
"visited_at": "",
|
|
"has_children": child_count > 0,
|
|
"children": [],
|
|
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
|
"content_format": r["content_format"] or "file",
|
|
"favorited": False,
|
|
"page_icon": "",
|
|
"tags": [],
|
|
"size_display": _format_size(r["size"]) if r["size"] else "",
|
|
})
|
|
|
|
return {"items": items}
|
|
|
|
|
|
def _format_size(size_bytes):
|
|
if not size_bytes:
|
|
return ""
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
if size_bytes < 1048576:
|
|
return f"{size_bytes/1024:.1f} KB"
|
|
if size_bytes < 1073741824:
|
|
return f"{size_bytes/1048576:.1f} MB"
|
|
return f"{size_bytes/1073741824:.1f} GB"
|
|
|
|
|
|
@router.get("/workspace")
|
|
async 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"
|
|
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}
|