- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
404 si la page n'existe pas, événements centralisés. Les 3 paires
publish/unpublish déléguent (sharing = front, board, v2) :
· board : mise à jour aveugle → 404 + contrôle de session ajouté
· board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
le share dialog reste l'unique propriétaire de ces drapeaux
· v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
· `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
→ `settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
écriture. Reste : client httpx partagé (52 créations, cache par event loop)
tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired
suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2140 lines
89 KiB
Python
2140 lines
89 KiB
Python
"""FlowDeck — Board Kanban Notion-style + multi-vues + propriétés custom + AI keywords."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.config import settings
|
|
from app.db import get_conn
|
|
from app.routers.dashboard import _get_app_version
|
|
from app.routers.sidebar_config import get_sidebar_config_sync
|
|
from app.services.automations import fire_event
|
|
from app.services.gitea_client import gitea
|
|
from app.services.permission_manager import PermissionManager
|
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["board"], prefix="/board")
|
|
|
|
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
|
|
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
|
|
|
|
AI_KEYWORD_COLORS = [
|
|
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
|
|
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
|
]
|
|
|
|
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
|
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
|
|
|
Allowed to edit a locked page: admins and the user who locked it
|
|
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
|
"""
|
|
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row or not row["is_locked"]:
|
|
return
|
|
uid = (user or {}).get("id")
|
|
is_admin = bool((user or {}).get("is_admin"))
|
|
if is_admin or (uid and row["locked_by"] == uid):
|
|
return
|
|
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
|
|
|
|
|
def _ensure_block_ids(blocks) -> None:
|
|
"""Assign unique ids to blocks missing one, recursively.
|
|
|
|
Built-in page templates ship without ids (the editor used to assign them
|
|
client-side only). Without persisted ids, the realtime layer and the editor
|
|
disagree on block identity, which duplicated lines / shuffled blocks when
|
|
editing a template-created page. We now materialize ids at creation time.
|
|
"""
|
|
import uuid
|
|
if not isinstance(blocks, list):
|
|
return
|
|
for b in blocks:
|
|
if isinstance(b, dict):
|
|
if not b.get("id"):
|
|
b["id"] = "b" + uuid.uuid4().hex[:12]
|
|
if isinstance(b.get("children"), list):
|
|
_ensure_block_ids(b["children"])
|
|
|
|
|
|
@router.get("/api/wiki/pages")
|
|
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
|
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
|
every non-deleted page the current user can see (single source: pages)."""
|
|
q = (q or "").strip().lower()
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT id, title, page_icon, workspace FROM pages
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY updated_at DESC LIMIT 500"""
|
|
).fetchall()
|
|
results = []
|
|
for r in rows:
|
|
title = r["title"] or "Untitled"
|
|
if q:
|
|
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
|
hay = title.lower()
|
|
it = iter(hay)
|
|
subseq = all(ch in it for ch in q)
|
|
if q not in hay and not subseq:
|
|
continue
|
|
results.append({
|
|
"id": r["id"],
|
|
"title": title,
|
|
"icon": r["page_icon"] or "",
|
|
"workspace": r["workspace"] or "",
|
|
})
|
|
if len(results) >= 20:
|
|
break
|
|
return {"pages": results}
|
|
|
|
|
|
@router.get("/api/wiki/titles")
|
|
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
|
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
|
parsed: list[int] = []
|
|
for part in (ids or "").split(","):
|
|
part = part.strip()
|
|
if part.isdigit():
|
|
parsed.append(int(part))
|
|
parsed = parsed[:200]
|
|
out: dict[str, str] = {}
|
|
if parsed:
|
|
placeholders = ",".join("?" * len(parsed))
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
|
parsed,
|
|
).fetchall()
|
|
for r in rows:
|
|
if r["deleted_at"]:
|
|
out[str(r["id"])] = "Deleted page"
|
|
else:
|
|
icon = (r["page_icon"] or "")
|
|
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
|
return {"titles": out}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/lock")
|
|
async def set_page_lock(request: Request, page_id: int):
|
|
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
|
admins and the page creator)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
locked = bool(body.get("locked"))
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
|
(page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
is_admin = 1 if user.get("is_admin") else 0
|
|
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
|
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
|
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
|
(1 if locked else 0, user["id"] if locked else None, page_id))
|
|
conn.commit()
|
|
await fire_event("page.locked" if locked else "page.unlocked",
|
|
{"page_id": page_id, "by": user["id"]})
|
|
return {"status": "ok", "is_locked": int(locked)}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/options")
|
|
async def set_page_options(request: Request, page_id: int):
|
|
"""v5.12.0: page layout options — full-width and compact typography."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
updates = {}
|
|
for key in ("full_width", "font_small"):
|
|
if key in body:
|
|
updates[key] = 1 if body[key] else 0
|
|
if not updates:
|
|
raise HTTPException(400, "nothing to update")
|
|
sets = ", ".join(f"{k}=?" for k in updates)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(*updates.values(), page_id))
|
|
conn.commit()
|
|
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
|
|
|
|
|
@router.get("/api/page-templates")
|
|
async def list_page_templates_api(request: Request):
|
|
"""v5.12.0: built-in + user global page templates for the picker."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = (user or {}).get("id")
|
|
from app.services.block_templates import template_list
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT id, name, icon, description, created_by
|
|
FROM page_global_templates
|
|
WHERE created_by IS NULL OR created_by=?
|
|
ORDER BY created_at""",
|
|
(uid,),
|
|
).fetchall()
|
|
mine = [dict(r) for r in rows]
|
|
for t in mine:
|
|
t["builtin"] = False
|
|
return {"templates": template_list() + mine}
|
|
|
|
|
|
@router.post("/api/page-templates")
|
|
async def create_page_template(request: Request):
|
|
"""v5.12.0: save the current page (or a raw block list) as a personal
|
|
global template: {name, icon?, description?, page_id? | blocks?}."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
name = (body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "name is required")
|
|
blocks = body.get("blocks")
|
|
if body.get("page_id"):
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
|
(int(body["page_id"]),)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
if row["content_format"] == "blocks" and row["content"]:
|
|
try:
|
|
blocks = json.loads(row["content"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise HTTPException(400, "Page content is not block JSON") from None
|
|
if not isinstance(blocks, list) or not blocks:
|
|
raise HTTPException(400, "blocks (or page_id) required")
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(name, body.get("icon") or "📄", body.get("description") or "",
|
|
json.dumps(blocks), user["id"]),
|
|
)
|
|
conn.commit()
|
|
tid = cur.lastrowid
|
|
return {"status": "ok", "id": tid}
|
|
|
|
|
|
@router.post("/api/page-templates/{template_id}/use")
|
|
async def use_page_template(request: Request, template_id: int):
|
|
"""v5.12.0: instantiate a page from a template (built-in or user).
|
|
|
|
Body: {key?} for built-ins OR uses the row id for user templates.
|
|
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
|
"""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
title = (body.get("title") or "").strip()
|
|
blocks_json = None
|
|
if template_id == 0:
|
|
from app.services.block_templates import blocks_json_for
|
|
key = body.get("key") or "empty"
|
|
blocks_json = blocks_json_for(key)
|
|
name = key
|
|
if blocks_json is None:
|
|
raise HTTPException(404, "Unknown built-in template")
|
|
else:
|
|
with get_conn() as conn:
|
|
uid = (user or {}).get("id")
|
|
row = conn.execute(
|
|
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
|
(template_id, uid),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Template not found")
|
|
blocks_json = row["blocks_json"]
|
|
name = row["name"]
|
|
title = title or row["name"]
|
|
# Resolve the target workspace so the new page actually shows up in the
|
|
# active local workspace (bugfix: template pages previously got
|
|
# workspace_id = NULL and never appeared in the sidebar/tree).
|
|
uid = (user or {}).get("id")
|
|
ws_id_raw = body.get("workspace_id")
|
|
ws_id = None
|
|
if ws_id_raw is not None:
|
|
try:
|
|
ws_id = int(ws_id_raw)
|
|
except (TypeError, ValueError):
|
|
ws_id = None
|
|
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
|
if ws_id is not None:
|
|
with get_conn() as conn:
|
|
ws_row = conn.execute(
|
|
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
|
).fetchone()
|
|
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
|
ws_key = ws_row["name"] or ws_key
|
|
else:
|
|
ws_id = None
|
|
else:
|
|
body_ws = (body.get("workspace") or "").strip()
|
|
if body_ws:
|
|
ws_key = body_ws
|
|
# Optional target folder: instantiate the template as a child of it.
|
|
parent_id = body.get("parent_id")
|
|
try:
|
|
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
|
except (TypeError, ValueError):
|
|
parent_id = None
|
|
try:
|
|
parsed_blocks = json.loads(blocks_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise HTTPException(500, "Template content corrupted") from None
|
|
_ensure_block_ids(parsed_blocks)
|
|
blocks_json = json.dumps(parsed_blocks)
|
|
with get_conn() as conn:
|
|
if parent_id is not None:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
|
(parent_id,),
|
|
).fetchone()[0]
|
|
elif ws_id is not None:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
|
(ws_id,),
|
|
).fetchone()[0]
|
|
else:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
|
(ws_key,),
|
|
).fetchone()[0]
|
|
cur = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
|
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
|
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
|
)
|
|
conn.commit()
|
|
page_id = cur.lastrowid
|
|
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
|
"workspace": ws_key, "from_template": name})
|
|
return {"status": "ok", "id": page_id, "title": title or name}
|
|
|
|
|
|
# ── Core helpers ──
|
|
|
|
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
|
if issue.get("state") == "closed":
|
|
return "Terminé" if "Terminé" in columns else columns[-1]
|
|
for lbl in issue.get("labels", []):
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
|
(board_id, lbl["name"]),
|
|
).fetchone()
|
|
if row and row["column_name"] in columns:
|
|
return row["column_name"]
|
|
return columns[0] if columns else "Backlog"
|
|
|
|
|
|
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
|
title = issue.get("title", "Untitled")
|
|
status = "todo"
|
|
if issue.get("state") == "closed":
|
|
status = "done"
|
|
labels = issue.get("labels", [])
|
|
for lbl in labels:
|
|
name = lbl.get("name", "").lower()
|
|
if "progress" in name or "doing" in name:
|
|
status = "progress"
|
|
elif "done" in name or "complete" in name or "terminé" in name:
|
|
status = "done"
|
|
|
|
assignee = issue.get("assignee", {}) or {}
|
|
assignee_name = assignee.get("login", "")
|
|
tag = labels[0].get("name", "") if labels else ""
|
|
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
|
if tag_color and not tag_color.startswith("#"):
|
|
tag_color = f"#{tag_color}"
|
|
|
|
icon_map = {
|
|
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
|
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
|
}
|
|
icon = "file"
|
|
for lbl in labels:
|
|
for kw, emoji in icon_map.items():
|
|
if kw in lbl.get("name", "").lower():
|
|
icon = emoji
|
|
break
|
|
|
|
# Load custom property values
|
|
props = {}
|
|
if owner and repo:
|
|
with get_conn() as conn:
|
|
pvs = conn.execute("""
|
|
SELECT pp.name, pp.prop_type, pv.value
|
|
FROM property_values pv
|
|
JOIN project_properties pp ON pp.id = pv.property_id
|
|
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
|
|
""", (owner, repo, issue.get("number", 0))).fetchall()
|
|
for pv in pvs:
|
|
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
|
|
|
|
# AI keywords from DB
|
|
keywords = []
|
|
if owner and repo:
|
|
with get_conn() as conn:
|
|
kw_rows = conn.execute(
|
|
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
|
|
(owner, repo),
|
|
).fetchall()
|
|
# Filter: show keywords matching this issue's labels
|
|
label_names = {lbl.get("name", "").lower() for lbl in labels}
|
|
for kw in kw_rows:
|
|
if kw["keyword"].lower() in label_names or any(
|
|
kw["keyword"].lower() in lbl for lbl in label_names
|
|
):
|
|
keywords.append({"name": kw["keyword"], "color": kw["color"]})
|
|
|
|
return {
|
|
"id": str(issue.get("number", 0)),
|
|
"title": title,
|
|
"status": status,
|
|
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
|
|
"status_label": STATUS_LABELS.get(status, "To-do"),
|
|
"icon": icon,
|
|
"assignee": assignee_name,
|
|
"tag": tag if tag else None,
|
|
"tag_color": tag_color,
|
|
"due_date": issue.get("due_date", ""),
|
|
"url": issue.get("html_url", ""),
|
|
"keywords": keywords,
|
|
"custom_props": props,
|
|
}
|
|
|
|
|
|
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
|
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
|
if depth >= max_depth:
|
|
return []
|
|
rows = conn.execute(
|
|
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
|
(ws_key, parent_id),
|
|
).fetchall()
|
|
items = []
|
|
for row in rows:
|
|
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
|
items.append({
|
|
"id": f"page/{row['id']}",
|
|
"db_id": row["id"],
|
|
"name": row["title"] or "New page",
|
|
"icon": "📄",
|
|
"url": f"/pages/{row['id']}",
|
|
"active": False,
|
|
"depth": depth,
|
|
"has_children": len(children) > 0,
|
|
"children": children,
|
|
})
|
|
return items
|
|
|
|
|
|
def _file_icon(name: str, content_format: str = "") -> str:
|
|
"""Map file extension to icon name (SVG-safe)."""
|
|
import re
|
|
# FlowDeck internal pages (no extension)
|
|
if content_format and content_format != 'file':
|
|
return 'edit'
|
|
n = name.lower()
|
|
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
|
return 'image'
|
|
if n.endswith('.pdf'):
|
|
return 'file'
|
|
if re.search(r'\.(md|markdown)$', n):
|
|
return 'edit'
|
|
if n.endswith('.py'):
|
|
return 'file'
|
|
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
|
return 'file'
|
|
if re.search(r'\.(html?|xml)$', n):
|
|
return 'file'
|
|
if n.endswith('.css'):
|
|
return 'file'
|
|
if n.endswith('.json'):
|
|
return 'file'
|
|
if n.endswith('.sql'):
|
|
return 'file'
|
|
if re.search(r'\.(sh|bash|zsh)$', n):
|
|
return 'file'
|
|
if n.endswith('.ps1'):
|
|
return 'file'
|
|
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
|
return 'file'
|
|
if re.search(r'\.(txt|log)$', n):
|
|
return 'file'
|
|
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
|
return 'file'
|
|
return 'file'
|
|
|
|
|
|
def _load_workspace_pages(ws_cookie: str) -> list:
|
|
"""Load top-level pages with children for the active workspace."""
|
|
if not ws_cookie:
|
|
return []
|
|
try:
|
|
ws_id = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format, "
|
|
"is_shared, share_mode, COALESCE(published,0) AS published "
|
|
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
|
(ws_id,),
|
|
).fetchall()
|
|
items = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
title = r["title"] or "Untitled"
|
|
sub_children = _load_children(r["id"])
|
|
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
|
items.append({
|
|
"db_id": r["id"], "name": title,
|
|
"id": f"page/{r['id']}",
|
|
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
|
"is_folder": is_folder,
|
|
"is_shared": is_shared,
|
|
"child_count": len(sub_children),
|
|
"children": sub_children,
|
|
})
|
|
return items
|
|
except (ValueError, Exception):
|
|
return []
|
|
|
|
|
|
def _load_children(parent_id: int) -> list:
|
|
"""Recursively load children of a page."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format, "
|
|
"is_shared, share_mode, COALESCE(published,0) AS published "
|
|
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
|
(parent_id,),
|
|
).fetchall()
|
|
children = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
title = r["title"] or "Untitled"
|
|
sub_children = _load_children(r["id"])
|
|
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
|
children.append({
|
|
"db_id": r["id"], "name": title,
|
|
"id": f"page/{r['id']}",
|
|
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
|
"is_folder": is_folder,
|
|
"is_shared": is_shared,
|
|
"child_count": len(sub_children),
|
|
"children": sub_children,
|
|
})
|
|
return children
|
|
|
|
|
|
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
|
"""Return list of local workspaces for a user."""
|
|
if not user:
|
|
return []
|
|
try:
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
|
(user["id"],)
|
|
).fetchall()
|
|
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
|
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
|
|
with get_conn() as conn:
|
|
own_ws = (
|
|
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
|
|
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
|
|
)
|
|
own_ws_names = (
|
|
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
|
|
"UNION SELECT w.name FROM workspaces w "
|
|
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
|
|
)
|
|
# Scope "shared by me"-style lists to pages in the user's own workspaces
|
|
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
|
|
scope_cond = (
|
|
f"(workspace_id IN ({own_ws}) "
|
|
f"OR (workspace_id IS NULL AND lower(workspace) IN "
|
|
f"(SELECT lower(name) FROM ({own_ws_names}))) "
|
|
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
|
|
f"(SELECT login FROM users WHERE id=?))))"
|
|
)
|
|
scope_params = (user_id, user_id, user_id, user_id, user_id)
|
|
|
|
made_nominal = conn.execute(
|
|
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
|
"JOIN pages p ON p.id=s.page_id "
|
|
"WHERE s.created_by=? AND p.deleted_at IS NULL",
|
|
(user_id,),
|
|
).fetchall()
|
|
made_link = conn.execute(
|
|
f"SELECT id, title, workspace, updated_at FROM pages "
|
|
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
|
|
f"AND {scope_cond}",
|
|
scope_params,
|
|
).fetchall()
|
|
made_flag = conn.execute(
|
|
f"SELECT id, title, workspace, updated_at FROM pages "
|
|
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
|
|
f"AND {scope_cond}",
|
|
scope_params,
|
|
).fetchall()
|
|
published_rows = conn.execute(
|
|
f"SELECT id, title, workspace, updated_at FROM pages "
|
|
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
|
|
f"ORDER BY updated_at DESC LIMIT 20",
|
|
scope_params,
|
|
).fetchall()
|
|
try:
|
|
received_rows = conn.execute(
|
|
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
|
"JOIN pages p ON p.id=s.page_id "
|
|
"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=?) AND p.deleted_at IS NULL",
|
|
(user_id, user_id, user_id),
|
|
).fetchall()
|
|
except Exception:
|
|
received_rows = conn.execute(
|
|
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
|
"JOIN pages p ON p.id=s.page_id "
|
|
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
|
(user_id,),
|
|
).fetchall()
|
|
|
|
def _entry(r, icon):
|
|
return {
|
|
"id": f"page/{r['id']}",
|
|
"db_id": r["id"],
|
|
"name": r["title"] or "New page",
|
|
"icon": icon,
|
|
"url": f"/pages/{r['id']}",
|
|
"active": False,
|
|
"indent": 0,
|
|
"depth": 0,
|
|
"has_children": False,
|
|
"children": [],
|
|
}
|
|
|
|
made_map = {}
|
|
for r in (*made_nominal, *made_link, *made_flag):
|
|
made_map.setdefault(r["id"], r)
|
|
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
|
shared_made = [_entry(r, "link") for r in made_sorted]
|
|
received_sorted = [r for r in received_rows if r["id"] not in made_map]
|
|
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
|
shared_received = [_entry(r, "users") for r in received_sorted]
|
|
published = [_entry(r, "globe") for r in published_rows]
|
|
shared_all = [_entry(r, "link") for r in made_sorted]
|
|
return shared_made, shared_received, published, shared_all
|
|
|
|
|
|
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
|
|
|
# Active workspace name from cookie (for local workspace display)
|
|
from app.routers.dashboard import WORKSPACE_COOKIE
|
|
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
|
active_ws_name = "Workspace"
|
|
workspace_pages = []
|
|
gitea_workspace = False
|
|
gitea_owner = ""
|
|
gitea_repo = ""
|
|
has_active_workspace = False
|
|
local_ws_id = 0
|
|
|
|
if ws_cookie and ws_cookie.startswith("gitea:"):
|
|
# Gitea workspace: preserve context across pages. Also load the local
|
|
# mirror workspace so it appears in "My Workspaces" in the top section
|
|
# of the sidebar, in parallel with the Gitea repository tree.
|
|
parts = ws_cookie.split(":", 2)
|
|
if len(parts) >= 3:
|
|
gitea_owner = parts[1]
|
|
gitea_repo = parts[2]
|
|
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
|
gitea_workspace = True
|
|
has_active_workspace = True
|
|
# Get local workspace ID for mirror and load its tree
|
|
if user:
|
|
try:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
|
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
|
).fetchone()
|
|
if row:
|
|
local_ws_id = row["id"]
|
|
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
|
except Exception:
|
|
logger.exception("_sidebar_data")
|
|
elif ws_cookie and user:
|
|
try:
|
|
wsi = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(wsi, user["id"])
|
|
).fetchone()
|
|
if row:
|
|
active_ws_name = row["name"]
|
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
|
has_active_workspace = True
|
|
except (ValueError, Exception):
|
|
pass
|
|
recent = []
|
|
if owner and repo:
|
|
view_map = {
|
|
"Kanban board": "kanban", "Detailed board": "detailed",
|
|
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
|
}
|
|
first = True
|
|
for label, view in view_map.items():
|
|
indent = 0 if first else 1
|
|
active = first
|
|
recent.append({
|
|
"id": f"{owner}/{repo}/{view}",
|
|
"name": label, "icon": "folder" if first else "",
|
|
"url": f"/board/{owner}/{repo}?view={view}",
|
|
"active": active, "indent": indent,
|
|
"depth": indent, "has_children": False, "children": [],
|
|
})
|
|
first = False
|
|
# Load pages as nested tree for this project workspace
|
|
with get_conn() as conn:
|
|
tree_pages = _build_page_tree(conn, None, ws_key)
|
|
for p in tree_pages:
|
|
recent.append(p)
|
|
# Private pages: same as recent but filtered for page/ items (non-board views)
|
|
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
|
|
|
# Load favorite pages from DB
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
fav_rows = conn.execute(
|
|
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
|
"JOIN pages p ON p.id = f.page_id "
|
|
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
|
).fetchall()
|
|
favorites = []
|
|
for r in fav_rows:
|
|
favorites.append({
|
|
"id": f"page/{r['id']}",
|
|
"db_id": r["id"],
|
|
"name": r["title"] or "New page",
|
|
"icon": "📄",
|
|
"url": f"/pages/{r['id']}",
|
|
"active": False,
|
|
"indent": 0,
|
|
"depth": 0,
|
|
"has_children": False,
|
|
"children": [],
|
|
})
|
|
|
|
# Load shared pages
|
|
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
|
|
|
|
# Auth method & OAuth badge data
|
|
auth_method = "local"
|
|
gitea_linked = False
|
|
github_linked = False
|
|
if user and user.get("id"):
|
|
try:
|
|
with get_conn() as conn:
|
|
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
if am_row and am_row["auth_method"]:
|
|
auth_method = am_row["auth_method"]
|
|
tokens = conn.execute(
|
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
|
).fetchall()
|
|
for t in tokens:
|
|
if t["provider"] == "gitea":
|
|
gitea_linked = True
|
|
elif t["provider"] == "github":
|
|
github_linked = True
|
|
except Exception:
|
|
logger.exception("_sidebar_data")
|
|
|
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
|
"active_ws_name": active_ws_name,
|
|
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
|
|
"workspace_pages": workspace_pages,
|
|
"gitea_workspace": gitea_workspace,
|
|
"gitea_owner": gitea_owner,
|
|
"gitea_repo": gitea_repo,
|
|
"local_ws_id": local_ws_id,
|
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
|
"recent_pages": recent, "private_pages": private_items,
|
|
"favorite_pages": favorites, "shared_pages": shared_pages,
|
|
"shared_made_pages": shared_made_pages,
|
|
"shared_received_pages": shared_received_pages,
|
|
"published_pages": published_pages,
|
|
"user": user,
|
|
"auth_method": auth_method,
|
|
"gitea_linked": gitea_linked,
|
|
"github_linked": github_linked,
|
|
"has_active_workspace": has_active_workspace,
|
|
"app_version": _get_app_version(),
|
|
"local_workspaces": _local_workspaces_for_user(user),
|
|
"sidebar_config": get_sidebar_config_sync(uid)}
|
|
|
|
|
|
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
|
"""Extract and persist AI keywords from issue labels and body."""
|
|
if not owner or not repo:
|
|
return
|
|
candidates = set()
|
|
for lbl in labels:
|
|
name = lbl.get("name", "").strip().lower()
|
|
if name and len(name) > 1:
|
|
candidates.add(name)
|
|
# Simple extraction from body: single words > 3 chars
|
|
import re
|
|
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
|
|
if word not in ("this", "that", "with", "from", "have", "when", "will"):
|
|
candidates.add(word)
|
|
|
|
with get_conn() as conn:
|
|
for kw in candidates:
|
|
kw = kw[:30]
|
|
existing = conn.execute(
|
|
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
|
|
(owner, repo, kw),
|
|
).fetchone()
|
|
if existing:
|
|
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
|
|
(existing["usage_count"] + 1, existing["id"]))
|
|
else:
|
|
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
|
|
conn.execute(
|
|
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
|
|
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
|
|
(owner, repo),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
async def asyncio_get_labels(owner: str, repo: str):
|
|
return await gitea.get_labels(owner, repo)
|
|
|
|
|
|
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
|
|
if status_filter:
|
|
allowed = set(status_filter.split(","))
|
|
cards = [c for c in cards if c["status"] in allowed]
|
|
if filters:
|
|
for f in filters.split(","):
|
|
if ":" in f:
|
|
prop, val = f.split(":", 1)
|
|
val_lower = val.lower()
|
|
if prop == "assignee":
|
|
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
|
|
elif prop == "tag":
|
|
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
|
|
elif prop == "keyword":
|
|
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
|
|
return cards
|
|
|
|
|
|
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
|
if not sorts:
|
|
return cards
|
|
order = {"todo": 0, "progress": 1, "done": 2}
|
|
for spec in reversed(sorts.split(",")):
|
|
if ":" not in spec:
|
|
continue
|
|
field, direction = spec.split(":", 1)
|
|
rev = direction == "desc"
|
|
if field == "name":
|
|
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
|
|
elif field == "status":
|
|
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
|
|
elif field == "assignee":
|
|
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
|
|
elif field == "deadline":
|
|
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
|
|
return cards
|
|
|
|
|
|
# ═══════════ Library page ═══════════
|
|
|
|
@router.get("/library", response_class=HTMLResponse)
|
|
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
# Load all pages for the workspace from DB
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
with get_conn() as conn:
|
|
if ws_key:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, updated_at FROM pages "
|
|
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
|
(ws_key,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, updated_at FROM pages "
|
|
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
|
).fetchall()
|
|
all_pages = []
|
|
for r in rows:
|
|
page = dict(r)
|
|
all_pages.append({
|
|
"id": f"page/{page['id']}",
|
|
"name": page["title"] or "Untitled",
|
|
"icon": "📄",
|
|
"url": f"/pages/{page['id']}",
|
|
"created_by": "You",
|
|
"source": page.get("workspace") or "Private",
|
|
"last_edited": page.get("updated_at", "now"),
|
|
"last_visited": page.get("updated_at", "now"),
|
|
})
|
|
sidebar["recent_pages"] = all_pages
|
|
sidebar["favorite_pages"] = []
|
|
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
|
sidebar["shared_pages"] = []
|
|
sidebar["shared_made_pages"] = []
|
|
sidebar["shared_received_pages"] = []
|
|
template = env.get_template("library.html")
|
|
return template.render(**sidebar)
|
|
|
|
# ═══════════ Favorites API ═══════════
|
|
|
|
@router.get("/api/favorites")
|
|
async def list_favorites(request: Request):
|
|
"""List favorited page IDs for the current user."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
|
).fetchall()
|
|
return {"favorites": [r["page_id"] for r in rows]}
|
|
|
|
|
|
@router.post("/api/favorites/{page_id:int}")
|
|
async def add_favorite(request: Request, page_id: int):
|
|
"""Add a page to favorites."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
|
).fetchone()
|
|
if not existing:
|
|
pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
|
).fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
|
(uid, page_id, pos),
|
|
)
|
|
conn.commit()
|
|
try:
|
|
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
|
except Exception:
|
|
logger.exception("add_favorite")
|
|
return {"status": "added", "page_id": page_id}
|
|
|
|
|
|
@router.delete("/api/favorites/{page_id:int}")
|
|
async def remove_favorite(request: Request, page_id: int):
|
|
"""Remove a page from favorites."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
|
conn.commit()
|
|
try:
|
|
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
|
except Exception:
|
|
logger.exception("remove_favorite")
|
|
return {"status": "removed", "page_id": page_id}
|
|
|
|
# ═══════════ Share API ═══════════
|
|
|
|
@router.post("/api/share/{page_id:int}")
|
|
async def update_share(request: Request, page_id: int):
|
|
"""Save share settings for a page."""
|
|
body = await request.json()
|
|
mode = body.get("mode", "private")
|
|
published = body.get("published", False)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
|
(mode, 1 if published else 0, page_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "share_mode": mode, "published": published}
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/publish")
|
|
async def publish_page(request: Request, page_id: int):
|
|
"""Publish a page to the web (generates publish_slug)."""
|
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
|
raise HTTPException(401, "Authentication required")
|
|
slug, title = publish(page_id)
|
|
await fire_published(page_id, slug)
|
|
return {"is_published": True, "publish_slug": slug, "title": title}
|
|
|
|
|
|
@router.delete("/api/pages/{page_id:int}/publish")
|
|
async def unpublish_page(request: Request, page_id: int):
|
|
"""Unpublish a page from the web."""
|
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
|
raise HTTPException(401, "Authentication required")
|
|
unpublish(page_id)
|
|
await fire_unpublished(page_id)
|
|
return {"is_published": False}
|
|
|
|
|
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
|
|
|
@router.get("/api/trash")
|
|
async def list_trash(request: Request):
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
|
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
|
|
|
|
|
@router.post("/api/trash/{page_id}/restore")
|
|
async def restore_page(request: Request, page_id: int):
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
try:
|
|
await fire_event("page.restored", {"page_id": page_id})
|
|
except Exception:
|
|
logger.exception("restore_page")
|
|
return {"status": "ok", "restored": page_id}
|
|
|
|
|
|
@router.delete("/api/trash/{page_id}")
|
|
async def permanent_delete(request: Request, page_id: int):
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "deleted": page_id}
|
|
|
|
|
|
@router.get("/trash", response_class=HTMLResponse)
|
|
async def trash_page(request: Request):
|
|
from app.templating import ENV
|
|
env = ENV
|
|
template = env.get_template("trash.html")
|
|
return template.render(**_sidebar_data(request))
|
|
|
|
|
|
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
|
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
|
|
|
@router.get("/api/synced-blocks")
|
|
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
|
"""List synced blocks for a workspace."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
from app.services.synced_blocks import list_synced_blocks
|
|
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
|
|
|
|
|
@router.post("/api/synced-blocks")
|
|
async def create_synced_block_api(request: Request):
|
|
"""Create a new synced block."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
from app.services.synced_blocks import create_synced_block
|
|
sid = create_synced_block(
|
|
workspace=body.get("workspace", ""),
|
|
title=body.get("title", "Synced block"),
|
|
content=body.get("content", []),
|
|
created_by=user.get("id"),
|
|
)
|
|
return {"status": "ok", "synced_block_id": sid}
|
|
|
|
|
|
@router.put("/api/synced-blocks/{sid}")
|
|
async def update_synced_block_api(request: Request, sid: int):
|
|
"""Update a synced block's content (propagates to all pages)."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
from app.services.synced_blocks import (
|
|
get_synced_block,
|
|
page_ids_for_synced,
|
|
sync_synced_blocks_in_page,
|
|
update_synced_block,
|
|
)
|
|
sb = get_synced_block(sid)
|
|
if not sb:
|
|
raise HTTPException(404, "Synced block not found")
|
|
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
|
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
|
# content pages included), THEN push the realtime update so open rooms
|
|
# reload the fresh content from the DB.
|
|
for pid in page_ids_for_synced(sid):
|
|
sync_synced_blocks_in_page(pid)
|
|
from app.services.realtime_server import manager
|
|
await manager._propagate_synced(sid)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/synced-blocks/{sid}")
|
|
async def delete_synced_block_api(request: Request, sid: int):
|
|
"""Delete a synced block."""
|
|
from app.services.synced_blocks import (
|
|
delete_synced_block,
|
|
get_synced_block,
|
|
mark_synced_block_deleted,
|
|
page_ids_for_synced,
|
|
)
|
|
sb = get_synced_block(sid)
|
|
if not sb:
|
|
raise HTTPException(404, "Synced block not found")
|
|
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
|
# refs, rewrite their stored content (deleted state), then broadcast.
|
|
pids = page_ids_for_synced(sid)
|
|
delete_synced_block(sid)
|
|
mark_synced_block_deleted(sid, pids)
|
|
from app.services.realtime_server import manager
|
|
await manager._broadcast_synced_to(pids, sid)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/synced-blocks/{sid}")
|
|
async def get_synced_block_api(sid: int):
|
|
"""Get a synced block by id."""
|
|
from app.services.synced_blocks import get_synced_block
|
|
sb = get_synced_block(sid)
|
|
if not sb:
|
|
raise HTTPException(404, "Synced block not found")
|
|
return dict(sb)
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/synced")
|
|
async def add_synced_to_page(request: Request, page_id: int):
|
|
"""Add a synced block reference to a page."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
from app.services.synced_blocks import add_page_synced, get_synced_block
|
|
sid = body.get("synced_block_id")
|
|
sb = get_synced_block(sid)
|
|
if not sb:
|
|
raise HTTPException(404, "Synced block not found")
|
|
add_page_synced(page_id, sid, body.get("block_index", 0))
|
|
return {"status": "ok", "synced_block_id": sid}
|
|
|
|
|
|
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
|
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
|
"""Remove a synced block reference from a page (unsync)."""
|
|
from app.services.synced_blocks import remove_page_synced
|
|
remove_page_synced(page_id, sid)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/synced")
|
|
async def get_page_synced_refs(request: Request, page_id: int):
|
|
"""Get all synced block references for a page."""
|
|
from app.services.synced_blocks import get_page_synced
|
|
return {"synced_blocks": get_page_synced(page_id)}
|
|
|
|
|
|
# ═══════════ Board page ═══════════
|
|
|
|
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
|
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
template = env.get_template("board.html")
|
|
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
|
initial_view=view, **sidebar)
|
|
|
|
|
|
# ═══════════ View fragments ═══════════
|
|
|
|
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
|
async def board_view(
|
|
request: Request, owner: str, repo: str, view: str,
|
|
status: str = Query(default=""),
|
|
filter: str = Query(default=""),
|
|
sort: str = Query(default=""),
|
|
):
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
issues_only = [i for i in issues if not i.get("pull_request")]
|
|
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
|
|
cards = _apply_filters(cards, status, filter)
|
|
cards = _apply_sorts(cards, sort)
|
|
except Exception as e:
|
|
logger.error("Board view error: %s", e)
|
|
cards = []
|
|
|
|
from app.templating import ENV
|
|
env = ENV
|
|
|
|
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
|
group_names = ["Design", "Engineering", "No Team"]
|
|
groups = []
|
|
for gname in group_names:
|
|
gid = gname.lower().replace(" ", "-")
|
|
gcards = cards
|
|
groups.append({
|
|
"id": gid, "name": gname,
|
|
"counts": {
|
|
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
|
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
|
"done": len([c for c in gcards if c["status"] == "done"]),
|
|
},
|
|
"cards": gcards,
|
|
})
|
|
|
|
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
|
|
|
template_map = {
|
|
"table": "table_view.html",
|
|
"status": "status_overview.html",
|
|
"teamload": "team_load.html",
|
|
"detailed": "detailed_board.html",
|
|
}
|
|
|
|
if view == "table":
|
|
grouped = {g["name"]: g["cards"] for g in groups}
|
|
ctx["grouped_cards"] = grouped
|
|
elif view == "status":
|
|
counts = {"todo": 0, "progress": 0, "done": 0}
|
|
for c in cards:
|
|
if c["status"] in counts:
|
|
counts[c["status"]] += 1
|
|
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
|
|
elif view == "teamload":
|
|
members = {}
|
|
for c in cards:
|
|
name = c.get("assignee") or "Unassigned"
|
|
if name not in members:
|
|
members[name] = {"name": name, "initial": name[0].upper(),
|
|
"todo": 0, "progress": 0, "complete": 0, "total": 0}
|
|
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
|
members[name][sk] += 1
|
|
members[name]["total"] += 1
|
|
ctx["team_data"] = list(members.values())
|
|
elif view == "detailed":
|
|
pass
|
|
else:
|
|
template_map["kanban"] = "board_fragment.html"
|
|
|
|
template_name = template_map.get(view, "board_fragment.html")
|
|
template = env.get_template(template_name)
|
|
return template.render(**ctx)
|
|
|
|
|
|
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
|
|
|
@router.get("/api/properties/{owner}/{repo}")
|
|
async def get_properties(owner: str, repo: str):
|
|
return {"properties": _get_project_properties(owner, repo)}
|
|
|
|
|
|
@router.post("/api/properties/{owner}/{repo}")
|
|
async def create_property(owner: str, repo: str, name: str = Query(...),
|
|
prop_type: str = Query(default="select"),
|
|
options: str = Query(default="")):
|
|
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
|
with get_conn() as conn:
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
|
|
(owner, repo, name, prop_type, opts),
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, f"Property already exists: {e}") from e
|
|
return {"status": "ok", "name": name, "type": prop_type}
|
|
|
|
|
|
@router.delete("/api/properties/{owner}/{repo}")
|
|
async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
|
(owner, repo, name),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/properties/{owner}/{repo}/values")
|
|
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
|
name: str = Query(...), value: str = Query(default="")):
|
|
with get_conn() as conn:
|
|
prop = conn.execute(
|
|
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
|
(owner, repo, name),
|
|
).fetchone()
|
|
if not prop:
|
|
raise HTTPException(404, f"Property '{name}' not found")
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
|
|
(prop["id"], issue_id, value),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
|
|
|
@router.get("/api/ai-keywords/{owner}/{repo}")
|
|
async def get_ai_keywords(owner: str, repo: str):
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
|
(owner, repo),
|
|
).fetchall()
|
|
return {"keywords": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
|
|
async def extract_ai_keywords(owner: str, repo: str):
|
|
"""Re-extract keywords from all issues in the repo."""
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
for issue in issues:
|
|
if not issue.get("pull_request"):
|
|
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e)) from e
|
|
return {"status": "ok", "issues_scanned": len(issues)}
|
|
|
|
|
|
# ═══════════ Pages Markdown ═══════════
|
|
|
|
@router.post("/api/pages")
|
|
async def create_page(request: Request, title: str = Query(default=""),
|
|
section: str = Query(default="Private"),
|
|
project: str = Query(default=""),
|
|
parent_id: int = Query(default=0)):
|
|
"""Create a new Markdown page, optionally as a sub-page."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
|
raise HTTPException(401, "Authentication required")
|
|
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
|
page_title = title.strip() if title else ""
|
|
try:
|
|
with get_conn() as conn:
|
|
# Compute next sort_order for this parent
|
|
next_order = 0
|
|
parent_val = parent_id if parent_id > 0 else None
|
|
row = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
|
(ws_key, parent_val),
|
|
).fetchone()
|
|
if row:
|
|
next_order = row[0]
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
|
(ws_key, page_title, section, parent_val, next_order),
|
|
)
|
|
conn.commit()
|
|
page_id = cur.lastrowid
|
|
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
|
"workspace": ws_key, "parent_id": parent_id})
|
|
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
|
except Exception as e:
|
|
logger.error("create_page failed: %s", e)
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
|
|
|
|
|
@router.get("/api/pages/{page_id}")
|
|
async def get_page(request: Request, page_id: int):
|
|
"""Get a Markdown page."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
|
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
|
raise HTTPException(404, "Page not found")
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
return dict(row)
|
|
|
|
|
|
@router.put("/api/pages/{page_id}")
|
|
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
|
content: str = Query(default=""),
|
|
content_format: str = Query(default="")):
|
|
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(403, "Authentication required")
|
|
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
|
# page the caller cannot edit yields 403.
|
|
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
|
if not pm.can_view_page(page_id):
|
|
raise HTTPException(404, "Page not found")
|
|
if not pm.can_edit_page(page_id):
|
|
raise HTTPException(403, "You don't have edit access to this page")
|
|
with get_conn() as conn:
|
|
_ensure_page_editable(conn, page_id, user)
|
|
if title:
|
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
|
# v6.5.0: renaming a database row's content page updates the row.
|
|
from app.services.row_pages import sync_page_title_to_row
|
|
sync_page_title_to_row(conn, page_id)
|
|
if content:
|
|
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
|
if content_format:
|
|
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
|
conn.commit()
|
|
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
|
"content_format": content_format or "markdown",
|
|
"actor_id": user.get("id")})
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/blocks")
|
|
async def save_page_blocks(request: Request, page_id: int):
|
|
"""Save blocks JSON content (Notion-style block editor).
|
|
|
|
v5.4.0: a version snapshot is recorded (if the block content actually
|
|
changed) so the UI can browse the version history and restore any of them.
|
|
v5.14.0: synced block references are tracked in page_synced_blocks.
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
blocks = body.get("blocks", [])
|
|
blocks_json = json.dumps(blocks)
|
|
title = body.get("title", "")
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = (user or {}).get("id")
|
|
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
|
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
|
raise HTTPException(403, "You don't have edit access to this page")
|
|
|
|
# Extract synced block ids from the blocks
|
|
def _extract_synced(blocks: list[dict]) -> set[int]:
|
|
ids: set[int] = set()
|
|
for b in blocks:
|
|
if b.get("type") == "synced" and b.get("synced_id"):
|
|
ids.add(b["synced_id"])
|
|
if isinstance(b.get("children"), list):
|
|
ids |= _extract_synced(b["children"])
|
|
return ids
|
|
|
|
synced_ids = _extract_synced(blocks)
|
|
|
|
with get_conn() as conn:
|
|
_ensure_page_editable(conn, page_id, user)
|
|
if title:
|
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
|
# v6.5.0: renaming a database row's content page updates the row.
|
|
from app.services.row_pages import sync_page_title_to_row
|
|
sync_page_title_to_row(conn, page_id)
|
|
conn.execute(
|
|
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(blocks_json, page_id),
|
|
)
|
|
_record_version(conn, page_id, uid, title or "", blocks_json)
|
|
# Update synced block references
|
|
existing = {r["synced_block_id"] for r in conn.execute(
|
|
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
|
).fetchall()}
|
|
for sid in synced_ids:
|
|
if sid not in existing:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
|
(page_id, sid),
|
|
)
|
|
for sid in existing - synced_ids:
|
|
conn.execute(
|
|
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
|
(page_id, sid),
|
|
)
|
|
conn.commit()
|
|
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
|
"content_format": "blocks",
|
|
"actor_id": uid})
|
|
return {"status": "ok", "id": page_id}
|
|
|
|
|
|
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
|
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
|
prev = conn.execute(
|
|
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
|
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
|
(page_id,),
|
|
).fetchone()
|
|
if prev is not None and prev["blocks_json"] == blocks_json:
|
|
if prev["title"] != (title or ""):
|
|
conn.execute(
|
|
"UPDATE page_versions SET title=? WHERE id="
|
|
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
|
(title or "", page_id),
|
|
)
|
|
return
|
|
conn.execute(
|
|
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
|
(page_id, user_id, title or "", blocks_json),
|
|
)
|
|
|
|
|
|
def _block_texts(b: dict) -> list[str]:
|
|
"""Flatten a block (including children) into searchable text chunks."""
|
|
out = []
|
|
raw = b.get("content")
|
|
if isinstance(raw, str) and raw.strip():
|
|
out.append(raw)
|
|
for child in b.get("children") or []:
|
|
out.extend(_block_texts(child))
|
|
return out
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/backlinks")
|
|
async def page_backlinks(request: Request, page_id: int):
|
|
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
|
|
|
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
|
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
|
"""
|
|
target = f"/pages/{page_id}" if page_id else None
|
|
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
|
backlinks = []
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, content, content_format, updated_at "
|
|
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
|
(page_id,),
|
|
).fetchall()
|
|
for r in rows:
|
|
fmt = r["content_format"]
|
|
hits = False
|
|
if fmt == "blocks" and r["content"]:
|
|
try:
|
|
blocks = json.loads(r["content"])
|
|
for b in blocks if isinstance(blocks, list) else []:
|
|
for text in _block_texts(b):
|
|
if target and (target in text or (wiki_target and wiki_target in text)):
|
|
hits = True
|
|
break
|
|
if hits:
|
|
break
|
|
except (json.JSONDecodeError, TypeError):
|
|
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
|
elif fmt == "markdown":
|
|
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
|
elif r["content"]:
|
|
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
|
if not hits and target:
|
|
hits = f"/pages/{page_id}" in (r["content"] or "")
|
|
if hits:
|
|
backlinks.append({
|
|
"id": r["id"],
|
|
"title": r["title"] or "Untitled",
|
|
"workspace": r["workspace"] or "",
|
|
"updated_at": r["updated_at"] or "",
|
|
})
|
|
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
|
return {"backlinks": backlinks}
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/versions")
|
|
async def page_versions(request: Request, page_id: int):
|
|
"""v5.4.0: version history for a block-editor page."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
|
"COALESCE(u.login, '') AS author "
|
|
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
|
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
|
(page_id,),
|
|
).fetchall()
|
|
return {"versions": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
|
async def restore_version(request: Request, page_id: int, version_id: int):
|
|
"""v5.4.0: restore a page from a version snapshot."""
|
|
with get_conn() as conn:
|
|
ver = conn.execute(
|
|
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
|
(version_id, page_id),
|
|
).fetchone()
|
|
if not ver:
|
|
raise HTTPException(404, "Version not found")
|
|
conn.execute(
|
|
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(ver["blocks_json"], ver["title"] or "", page_id),
|
|
)
|
|
conn.commit()
|
|
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
|
"content_format": "blocks"})
|
|
return {"status": "ok", "restored": version_id}
|
|
|
|
|
|
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/duplicate")
|
|
async def duplicate_page(request: Request, page_id: int):
|
|
"""Duplicate a page (block/markdown content included) as a sibling."""
|
|
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
page = dict(row)
|
|
|
|
def copy_tree(src_id: int, parent_id) -> int:
|
|
with get_conn() as conn:
|
|
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
|
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
|
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
|
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
|
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
|
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
|
(parent_id, src_id),
|
|
)
|
|
new_id = cur.lastrowid
|
|
conn.commit()
|
|
for child in conn.execute(
|
|
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
|
).fetchall():
|
|
copy_tree(child["id"], new_id)
|
|
return new_id
|
|
|
|
new_id = copy_tree(page_id, page.get("parent_id"))
|
|
title = (page.get("title") or "Untitled") + " copy"
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
|
conn.commit()
|
|
await fire_event("page.created", {"page_id": new_id, "title": title,
|
|
"workspace": page.get("workspace")})
|
|
return {"status": "ok", "id": new_id, "title": title}
|
|
|
|
|
|
# ═══════════ v5.5.0: Cover & icon ═══════════
|
|
|
|
|
|
def _upload_root() -> Path:
|
|
return Path(settings.data_dir)
|
|
|
|
|
|
def _ws_id_for(request: Request, page_id: int) -> int:
|
|
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
|
cookie = request.cookies.get("flowdeck_workspace", "")
|
|
try:
|
|
ws_id = int(cookie)
|
|
if ws_id > 0:
|
|
return ws_id
|
|
except (ValueError, TypeError):
|
|
pass
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if row and row["workspace_id"]:
|
|
return int(row["workspace_id"])
|
|
return 1
|
|
|
|
|
|
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
|
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
|
{file_url, file_path, mime_type, size, file_name}."""
|
|
import datetime
|
|
import re as _re
|
|
|
|
form = await request.form()
|
|
upload = form.get("file")
|
|
if upload is None or not hasattr(upload, "filename"):
|
|
raise HTTPException(400, "file field required")
|
|
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
|
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
|
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
|
raise HTTPException(400, "Unsupported image format")
|
|
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
|
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
final = f"{stamp}_{name}"
|
|
(folder / final).write_bytes(await upload.read())
|
|
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
|
return {
|
|
"file_url": f"/api/files/{ws_id}/{final}",
|
|
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
|
"mime_type": mime,
|
|
"size": (folder / final).stat().st_size,
|
|
"file_name": name,
|
|
}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/cover")
|
|
async def set_page_cover(request: Request, page_id: int):
|
|
"""v5.5.0: upload an image cover for a page.
|
|
|
|
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
|
an image stored in the workspace's uploads directory.
|
|
"""
|
|
ctype = (request.headers.get("content-type") or "").lower()
|
|
if ctype.startswith("application/json"):
|
|
body = await request.json()
|
|
cover_url = (body.get("cover_url") or "").strip()
|
|
if not cover_url:
|
|
raise HTTPException(400, "cover_url required")
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
|
conn.commit()
|
|
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
|
ws_id = _ws_id_for(request, page_id)
|
|
meta = await _store_uploaded_file(request, ws_id)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
|
conn.commit()
|
|
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
|
|
|
|
|
@router.delete("/api/pages/{page_id}/cover")
|
|
async def remove_page_cover(request: Request, page_id: int):
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "page_id": page_id}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/icon")
|
|
async def set_page_icon(request: Request, page_id: int):
|
|
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
|
body = await request.json()
|
|
icon = (body.get("icon") or "").strip()
|
|
if len(icon) > 512:
|
|
raise HTTPException(400, "icon too long")
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
|
conn.commit()
|
|
return {"status": "ok", "page_id": page_id, "icon": icon}
|
|
|
|
|
|
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
|
|
|
|
|
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
|
|
"""Convert markdown → blocks (server-side, same mapping as the editor) and
|
|
create a page in the caller's workspace."""
|
|
from app.services.export import _md_to_blocks
|
|
|
|
blocks = _md_to_blocks(markdown or "")
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
|
file_title = title.strip() or "Import"
|
|
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
|
|
if not blocks:
|
|
blocks = [{"type": "paragraph", "content": markdown or ""}]
|
|
with get_conn() as conn:
|
|
next_order = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
|
(ws_key,),
|
|
).fetchone()[0]
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
|
|
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
|
|
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
@router.post("/api/pages/import")
|
|
async def import_page(request: Request):
|
|
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
markdown = body.get("markdown", "")
|
|
title = body.get("title", "")
|
|
if not markdown and not body.get("csv"):
|
|
raise HTTPException(400, "markdown field required")
|
|
if not markdown.strip():
|
|
raise HTTPException(400, "markdown is empty")
|
|
page_id = await _create_page_from_markdown(request, markdown, title)
|
|
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
|
|
"workspace": ""})
|
|
return {"status": "ok", "id": page_id}
|
|
|
|
|
|
@router.post("/api/pages/import/file")
|
|
async def import_file(request: Request):
|
|
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
|
|
markdown pages) into the workspace. Returns the created page ids."""
|
|
import io as _io
|
|
import zipfile
|
|
|
|
form = await request.form()
|
|
upload = form.get("file")
|
|
if upload is None or not hasattr(upload, "filename"):
|
|
raise HTTPException(400, "file field required")
|
|
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
|
|
data = await upload.read()
|
|
created_ids = []
|
|
|
|
if filename.lower().endswith(".zip"):
|
|
try:
|
|
zf = zipfile.ZipFile(_io.BytesIO(data))
|
|
except zipfile.BadZipFile:
|
|
raise HTTPException(400, "Invalid zip archive") from None
|
|
md_entries = sorted(
|
|
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
|
|
key=lambda n: (n.count("/"), n.lower()),
|
|
)
|
|
if not md_entries:
|
|
raise HTTPException(400, "No .md files found in archive")
|
|
for name in md_entries:
|
|
raw = zf.read(name).decode("utf-8", errors="replace")
|
|
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
|
|
try:
|
|
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
|
except Exception as exc: # noqa: BLE001 - keep importing the rest
|
|
logger.warning("import failed for %s: %s", name, exc)
|
|
else:
|
|
try:
|
|
raw = data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise HTTPException(400, "Only text/markdown files are supported") from None
|
|
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
|
|
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
|
|
|
if not created_ids:
|
|
raise HTTPException(422, "No pages could be imported")
|
|
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
|
|
|
|
|
|
@router.post("/api/og/metadata")
|
|
async def og_metadata(request: Request):
|
|
"""v5.5.0: Open Graph metadata for a bookmark card.
|
|
|
|
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
|
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
url = (body.get("url") or "").strip()
|
|
if not url:
|
|
raise HTTPException(400, "url required")
|
|
m = _REPO_REF_RE.match(url)
|
|
if m:
|
|
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
|
data = await _unfurl_repo(forge, owner, repo)
|
|
if data:
|
|
return {"ok": True, **data}
|
|
from app.services.og_fetcher import fetch_og_metadata
|
|
try:
|
|
data = await fetch_og_metadata(url)
|
|
except ValueError as exc:
|
|
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
|
raise HTTPException(400, str(exc)) from None
|
|
return {"ok": True, **data}
|
|
|
|
|
|
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
|
|
|
|
|
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
|
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
|
try:
|
|
if forge == "gitea":
|
|
from app.services.gitea_client import GiteaClient
|
|
info = await GiteaClient().get_repo_info(owner, repo)
|
|
site = "Gitea"
|
|
else:
|
|
from app.config import settings
|
|
from app.services.github_adapter import GitHubAdapter
|
|
token = getattr(settings, "github_token", None) or ""
|
|
if token:
|
|
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
|
else:
|
|
import httpx
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
r = await client.get(
|
|
f"https://api.github.com/repos/{owner}/{repo}",
|
|
headers={"Accept": "application/vnd.github+json"},
|
|
)
|
|
r.raise_for_status()
|
|
info = r.json()
|
|
site = "GitHub"
|
|
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
|
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
|
return None
|
|
branch = info.get("default_branch") or "main"
|
|
return {
|
|
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
|
"title": info.get("full_name") or f"{owner}/{repo}",
|
|
"description": (info.get("description") or f"{site} repository "
|
|
f"{owner}/{repo} · default branch: {branch}"),
|
|
"image": "",
|
|
"site_name": site,
|
|
"language": info.get("language") or "",
|
|
}
|
|
|
|
|
|
@router.post("/api/embed/resolve")
|
|
async def resolve_embed(request: Request):
|
|
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
|
|
|
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
|
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body") from None
|
|
url = (body.get("url") or "").strip()
|
|
if not url:
|
|
raise HTTPException(400, "url required")
|
|
from app.config import settings
|
|
from app.services.embeds import resolve_embed as _resolve
|
|
data = _resolve(url, parent=settings.app_base_url)
|
|
return {"ok": True, "url": url, **data}
|
|
|
|
|
|
@router.put("/api/pages/{page_id}/move")
|
|
async def move_page(request: Request, page_id: int):
|
|
"""Move a page to another workspace or reorder within tree.
|
|
|
|
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
|
- workspace_id: move page to a different workspace
|
|
- parent_id: change parent (0 = root level)
|
|
- new_order: position among siblings (0 = append)
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
new_ws_id = body.get("workspace_id")
|
|
new_parent_id = body.get("parent_id", 0)
|
|
new_order = body.get("new_order", 0)
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
|
|
if new_ws_id:
|
|
# Move to a different workspace: get the workspace name
|
|
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
|
if not ws_row:
|
|
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
|
conn.execute(
|
|
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(new_ws_id, ws_row["name"], page_id),
|
|
)
|
|
else:
|
|
# Reorder within same workspace
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(new_parent_id if new_parent_id > 0 else None, page_id),
|
|
)
|
|
conn.execute(
|
|
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(new_order, page_id),
|
|
)
|
|
|
|
conn.commit()
|
|
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
|
"parent_id": new_parent_id})
|
|
return {"status": "ok", "id": page_id}
|
|
|
|
|
|
@router.delete("/api/pages/{page_id}")
|
|
async def delete_page(request: Request, page_id: int):
|
|
"""Move a page to trash (soft delete)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = (user or {}).get("id")
|
|
if not uid:
|
|
raise HTTPException(403, "Authentication required")
|
|
# v6.0.0: granular page permissions — need at least edit access to trash.
|
|
if not PermissionManager(uid).can_edit_page(page_id):
|
|
raise HTTPException(403, "You don't have edit access to this page")
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
import datetime
|
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
|
conn.commit()
|
|
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
|
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
|
|
|
|
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
|
async def view_page(request: Request, page_id: int):
|
|
"""Render a page as HTML, or a file viewer for uploaded files.
|
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
|
embed = request.query_params.get("embed") == "1"
|
|
from app.templating import ENV
|
|
env = ENV
|
|
# v6.0.0: granular page permissions — hide restricted pages (404).
|
|
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
|
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
|
page = dict(row)
|
|
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
|
# even when the stored cache is stale).
|
|
from app.services.synced_blocks import resolve_content_json
|
|
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
|
|
|
ws = page.get("workspace", "")
|
|
parts = ws.split("/") if "/" in ws else ["", ""]
|
|
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
# Check if page is favorited
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
fav = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
|
).fetchone()
|
|
|
|
# Build page_data, including file metadata for uploaded files
|
|
_locked = bool(page.get("is_locked", 0))
|
|
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
|
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
|
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
|
"is_locked": _locked,
|
|
"locked_by": _locked_by,
|
|
"can_edit": _can_edit,
|
|
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
|
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
|
|
|
# For file pages, extract file metadata and add to page_data
|
|
if page.get("content_format") == "file":
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(page.get("content", "{}"))
|
|
except _json.JSONDecodeError:
|
|
meta = {}
|
|
file_path = meta.get("file_path", "").replace("\\", "/")
|
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
|
file_size = meta.get("size", 0)
|
|
# Build workspace_id from file_path
|
|
fp_parts = file_path.split("/")
|
|
ws_id = ""
|
|
for p in fp_parts:
|
|
if p.startswith("workspace_"):
|
|
ws_id = p.replace("workspace_", "")
|
|
break
|
|
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
|
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
|
page_data["file_url"] = file_url
|
|
page_data["file_mime"] = mime_type
|
|
page_data["file_size"] = file_size
|
|
page_data["file_name"] = filename
|
|
|
|
from app.routers.dashboard import _nav_breadcrumb
|
|
with get_conn() as conn:
|
|
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
|
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
|
"page_share_mode": page.get("share_mode", "private"),
|
|
"page_published": bool(page.get("published", 0)),
|
|
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
|
"page_data": page_data,
|
|
"breadcrumb_items": nav_crumbs,
|
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
|
"nav_page_id": page_id,
|
|
"embed_mode": embed}
|
|
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
|
|
|
|
|
@router.post("/api/sync/{owner}/{repo}")
|
|
async def sync_project(owner: str, repo: str):
|
|
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
issues_only = [i for i in issues if not i.get("pull_request")]
|
|
with get_conn() as conn:
|
|
board = conn.execute(
|
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
|
(owner, repo),
|
|
).fetchone()
|
|
if board:
|
|
board_id = board["id"]
|
|
columns = json.loads(board["columns_json"])
|
|
# A23 : un seul executemany pour toutes les cards.
|
|
conn.executemany(
|
|
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
|
[
|
|
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
|
for issue in issues_only
|
|
],
|
|
)
|
|
for issue in issues_only:
|
|
# Extract AI keywords from each issue
|
|
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
|
conn.commit()
|
|
return {"status": "ok", "issues_synced": len(issues_only)}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e)) from e
|