diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8e381..d5eaf8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog - FlowDeck +## v7.72.0 (2026-10-20) — Fusion bloc 1 : barre d'apps commune + références croisées + +### Added + +- **Barre d'apps commune dracodev** (Fusion ACC-1.1, issue #17) : snippet + figé vendangé depuis `dracodev-commons` v0.1.0 + (`app/templates/_draco_bar.html`, `static/css/draco-barre.css`, + `static/js/vendor/draco-barre.js`) — navigation vers ObsiGate, + Flowdeck, TikTak et Kanbante, Flowdeck marquée active, domaine + mémorisé ; le rail pilote le hamburger natif et les alertes ouvrent + la cloche native de la page. Masquée en mode `embed`. +- **Références croisées `external_ref`** (Fusion ACC-1.3, issue #18) : + migration 50 (`external_refs`), service `app/services/external_refs.py` + (parseur des URL profondes du contrat figé) et endpoints + `GET/POST /api/pages/{id}/refs`, `GET/POST /api/collections/{id}/refs`, + `DELETE /api/refs/{id}` (permissions page/collection respectées, 422 + explicite sinon). Section « Liens » dans l'éditeur de page + (`static/js/draco-refs.js`) : ajout par collage d'URL, pastille de + couleur de l'app cible, retrait en un clic. +- **Route profonde `/collections/{id}`** (contrat ACC-0.1) : redirection + 303 vers la vue native de la base `/db/{id}` (404 si inconnue). La + route `/pages/{id}` existait déjà. + + ## v7.71.5 (2026-10-10) — Galerie système : +94 templates (135 au total) ### Added diff --git a/VERSION b/VERSION index 23ea335..f4b5250 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.71.5 \ No newline at end of file +7.72.0 diff --git a/app/main.py b/app/main.py index 1f871c0..dd2df3d 100644 --- a/app/main.py +++ b/app/main.py @@ -24,6 +24,7 @@ from app.routers import ( collections, dashboard, export, + external_refs, library, my_tasks, notes, @@ -191,7 +192,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.71.5", + version="7.72.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -228,6 +229,7 @@ app.include_router(projects.backups_router) app.include_router(api.router) app.include_router(webhooks.router) app.include_router(collections.router) +app.include_router(external_refs.router) # Fusion ACC-1.3 — références croisées app.include_router(my_tasks.router) app.include_router(workspace.router) # Partage public :-exempt de la dépendance d'authentification du router. diff --git a/app/migrations.py b/app/migrations.py index 422b65a..b7080d3 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -2025,3 +2025,37 @@ def _migration_template_runs(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_template_runs_tpl " "ON template_runs(template_id, created_at)" ) + + +@register(50, "fusion: références croisées `external_refs` (ACC-1.3)") +def _migration_external_refs(conn: sqlite3.Connection) -> None: + """Migration 50 — table des références croisées entre objets. + + Modèle figé par dracodev-commons (contrats/external_ref.schema.json, + v0.1.0) : la référence vit chez le propriétaire de l'objet source + (page ou projet/collection Flowdeck) ; une seule source de vérité + par objet, des liens — jamais des copies du contenu cible. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS external_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + owner_type TEXT NOT NULL, + owner_id INTEGER NOT NULL, + target_app TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + target_url TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + domain TEXT, + status TEXT NOT NULL DEFAULT 'ok', + created_by TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_external_refs_owner " + "ON external_refs(owner_type, owner_id)" + ) diff --git a/app/routers/board/page_ops.py b/app/routers/board/page_ops.py index e09c57b..3c46ad0 100644 --- a/app/routers/board/page_ops.py +++ b/app/routers/board/page_ops.py @@ -162,7 +162,10 @@ def view_page(request: Request, page_id: int): 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, + # Fusion ACC-1.3 — références croisées de la page (section « Liens »). + from app.services import external_refs as _refs_svc + page_refs = _refs_svc.list_refs("flowdeck.page", page_id) + ctx = {**sidebar, "page": page, "page_refs": page_refs, "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)), diff --git a/app/routers/external_refs.py b/app/routers/external_refs.py new file mode 100644 index 0000000..b8ac095 --- /dev/null +++ b/app/routers/external_refs.py @@ -0,0 +1,118 @@ +"""Endpoints des références croisées — Fusion ACC-1.3 (Flowdeck). + +Création par collage d'une URL profonde d'une autre application +(ObsiGate, TikTak, Kanbante, Flowdeck) — zéro couplage serveur : +l'URL est reconnue par les formes figées du contrat +``dracodev-commons/contrats/liens-profonds.md`` (voir +``app.services.external_refs``). La route ``GET /collections/{id}`` +honore la forme canonique du même contrat en redirigeant vers la vue +native de la base (``/db/{id}``). +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services import external_refs as refs_svc +from app.services.permission_manager import PermissionManager + +logger = logging.getLogger("flowdeck.external_refs") + +router = APIRouter(tags=["ExternalRefs"]) + +_FORMES_ACCEPTEES = ( + "URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, " + "ObsiGate #file=, Flowdeck /pages/ ou /collections/, " + "issue Gitea (carte Kanbante)" +) + + +def _user(request: Request) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Session requise") + return user + + +def _check(owner_type: str, owner_id: int, user: dict, edit: bool) -> None: + pm = PermissionManager(user["id"]) + if owner_type == "flowdeck.page": + ok = pm.can_edit_page(owner_id) if edit else pm.can_view_page(owner_id) + elif owner_type == "flowdeck.project": + ok = pm.can_edit_collection(owner_id) if edit else pm.can_view_collection(owner_id) + else: + raise HTTPException(400, "Type de propriétaire non géré ici") + if not ok: + raise HTTPException(403, "Accès refusé") + + +def _list(owner_type: str, owner_id: int, request: Request): + user = _user(request) + _check(owner_type, owner_id, user, edit=False) + return {"refs": refs_svc.list_refs(owner_type, owner_id)} + + +def _add(owner_type: str, owner_id: int, request: Request, payload: dict): + user = _user(request) + _check(owner_type, owner_id, user, edit=True) + parsed = refs_svc.parse_deep_link(payload.get("url") or "") + if parsed is None: + raise HTTPException(422, _FORMES_ACCEPTEES) + target_app, target_type, target_id, default_label = parsed + label = (payload.get("label") or "").strip() or default_label + ref = refs_svc.add_ref( + user["id"], owner_type, owner_id, target_app, target_type, target_id, + (payload.get("url") or "").strip(), label, + ) + return JSONResponse(ref, status_code=201) + + +@router.get("/api/pages/{page_id}/refs") +def list_page_refs(page_id: int, request: Request): + return _list("flowdeck.page", page_id, request) + + +@router.post("/api/pages/{page_id}/refs") +def add_page_ref(page_id: int, request: Request, payload: dict = Body(...)): + return _add("flowdeck.page", page_id, request, payload) + + +@router.get("/api/collections/{collection_id}/refs") +def list_collection_refs(collection_id: int, request: Request): + return _list("flowdeck.project", collection_id, request) + + +@router.post("/api/collections/{collection_id}/refs") +def add_collection_ref(collection_id: int, request: Request, payload: dict = Body(...)): + return _add("flowdeck.project", collection_id, request, payload) + + +@router.delete("/api/refs/{ref_id}") +def delete_ref(ref_id: int, request: Request): + user = _user(request) + ref = refs_svc.get_ref(ref_id) + if ref is None: + raise HTTPException(404, "Lien introuvable") + _check(ref["owner_type"], int(ref["owner_id"]), user, edit=True) + refs_svc.delete_ref(ref_id) + return {"deleted": ref_id} + + +@router.get("/collections/{collection_id}") +def view_collection_deep_link(collection_id: int, request: Request): + """Forme canonique Fusion (ACC-0.1) : /collections/ → /db/.""" + _user(request) + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + if not row: + # Réponse directe, comme view_page : le gestionnaire global de + # l'app convertirait une HTTPException 404 en redirection 302. + return HTMLResponse("

Collection introuvable

", status_code=404) + return RedirectResponse(f"/db/{collection_id}", status_code=303) diff --git a/app/services/external_refs.py b/app/services/external_refs.py new file mode 100644 index 0000000..d849c6a --- /dev/null +++ b/app/services/external_refs.py @@ -0,0 +1,127 @@ +"""Références croisées ``external_ref`` — Fusion ACC-1.3 (Flowdeck). + +Modèle figé par le programme Fusion dans ``dracodev-commons`` +(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez +le propriétaire de l'objet source — ici une page ou un projet +(collection) Flowdeck — et pointe vers un objet d'une autre application +par son URL profonde (``contrats/liens-profonds.md``). Une seule source +de vérité par objet : des références, jamais des copies. +""" + +from __future__ import annotations + +import re +from urllib.parse import parse_qs, unquote, urlparse + +from app.db import get_conn + +APP_COLORS = { + "obsigate": "#7a5af5", + "flowdeck": "#2f6fed", + "tiktak": "#e8762c", + "kanbante": "#0d9488", +} + + +def parse_deep_link(url: str) -> tuple[str, str, str, str] | None: + """Reconnaît une URL profonde des applications dracodev. + + Formes figées par ``dracodev-commons/contrats/liens-profonds.md``. + Retourne ``(target_app, target_type, target_id, label_par_défaut)`` + ou ``None`` si l'URL ne correspond à aucune forme du contrat. + """ + try: + u = urlparse((url or "").strip()) + except ValueError: + return None + host = (u.hostname or "").lower() + if host == "tiktak.dracodev.net": + q = parse_qs(u.query) + if q.get("task"): + return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}") + if q.get("list"): + return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}") + return None + if host == "og.dracodev.net": + frag = u.fragment or "" + if frag.startswith("file="): + cible = unquote(frag[5:]) + if ":" in cible: + nom = cible.rsplit("/", 1)[-1] + return ("obsigate", "note", cible, f"ObsiGate · {nom}") + return None + if host == "flowdeck.dracodev.net": + m = re.fullmatch(r"/pages/(\d+)", u.path or "") + if m: + return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}") + m = re.fullmatch(r"/collections/(\d+)", u.path or "") + if m: + return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}") + return None + if host == "git.dracodev.net": + m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "") + if m: + owner, repo, num = m.groups() + return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}") + return None + return None + + +def _row_to_ref(row) -> dict: + d = dict(row) + d["app_color"] = APP_COLORS.get(d["target_app"], "#888") + return d + + +def list_refs(owner_type: str, owner_id: int) -> list[dict]: + """Références posées sur un objet Flowdeck, dans l'ordre de création.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM external_refs WHERE owner_type = ? AND owner_id = ? " + "ORDER BY created_at, id", + (owner_type, owner_id), + ).fetchall() + return [_row_to_ref(r) for r in rows] + + +def add_ref( + user_id: int, + owner_type: str, + owner_id: int, + target_app: str, + target_type: str, + target_id: str, + target_url: str, + label: str, +) -> dict: + """Ajoute une référence à un objet et la retourne (avec son ``id``).""" + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO external_refs (user_id, owner_type, owner_id, target_app, " + "target_type, target_id, target_url, label) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (user_id, owner_type, owner_id, target_app, target_type, target_id, + target_url, label), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM external_refs WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return _row_to_ref(row) + + +def get_ref(ref_id: int) -> dict | None: + """Une référence par son identifiant, ou ``None``.""" + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM external_refs WHERE id = ?", (ref_id,) + ).fetchone() + return _row_to_ref(row) if row else None + + +def delete_ref(ref_id: int) -> bool: + """Retire une référence ; ``True`` si elle existait.""" + with get_conn() as conn: + cur = conn.execute("DELETE FROM external_refs WHERE id = ?", (ref_id,)) + conn.commit() + return cur.rowcount > 0 diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html new file mode 100644 index 0000000..cc7ebd4 --- /dev/null +++ b/app/templates/_draco_bar.html @@ -0,0 +1,96 @@ +{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17). + Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0 + (ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution + passe par une nouvelle version de commons. + CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js. #} +
+ +
+ +
+
+
+ Domaine + + + +
+ + + + {{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }} +
+
+ + diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 045b764..2d3f0db 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -755,6 +755,32 @@ + +
+

Liens

+
    + {% for r in page_refs %} +
  • + + {{ r.label }} + {% if page_data.can_edit %} + + {% endif %} +
  • + {% endfor %} +
+ {% if page_data.can_edit %} +
+ + + +
+ + {% endif %} +
+
Saving... Unsaved diff --git a/app/templates/base.html b/app/templates/base.html index 70e9b8f..aa1eb22 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,6 +30,7 @@ htmx lors d'une navigation partielle : la page revenait alors sans aucune feuille de style (composants blancs en thème sombre). --> +