feat: Fusion bloc 1 — barre d'apps commune (ACC-1.1, #17) et références croisées external_ref (ACC-1.3, #18)
FlowDeck CI / lint (push) Failing after 2m33s
FlowDeck CI / test (push) Failing after 30m3s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
Spark
2026-10-20 23:17:18 -04:00
parent 12beab01c9
commit d8548eede9
16 changed files with 845 additions and 4 deletions
+3 -1
View File
@@ -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.
+34
View File
@@ -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)"
)
+4 -1
View File
@@ -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)),
+118
View File
@@ -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/<id> → /db/<id>."""
_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("<h2>Collection introuvable</h2>", status_code=404)
return RedirectResponse(f"/db/{collection_id}", status_code=303)
+127
View File
@@ -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
+96
View File
@@ -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. #}
<header class="draco-bar" data-current="flowdeck">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" aria-label="dracodev">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script nonce="{{ csp_nonce() }}">
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le hamburger natif, les alertes ouvrent la cloche
native de la page ; la recherche fédérée et Spark arrivent avec
les phases suivantes du programme Fusion. */
window.DRACO_BAR_CONFIG = {
current: "flowdeck",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
if (act === "alertes") {
var b = document.querySelector(".fd-notif-bell button");
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
return;
}
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
</script>
+26
View File
@@ -755,6 +755,32 @@
</button>
</div>
<!-- ═══ Liens — références croisées (Fusion ACC-1.3) ═══
Liste rendue côté serveur ; ajout/retrait par
static/js/draco-refs.js (API /api/pages/<id>/refs). -->
<section class="draco-liens" data-refs data-owner-type="flowdeck.page" data-owner-id="{{ page.id }}">
<h3 class="draco-liens-title">Liens</h3>
<ul class="draco-liens-list">
{% for r in page_refs %}
<li class="draco-liens-item">
<span class="draco-ref-dot" style="background:{{ r.app_color }}"></span>
<a href="{{ r.target_url }}" target="_blank" rel="noopener">{{ r.label }}</a>
{% if page_data.can_edit %}
<button type="button" class="draco-liens-del" data-ref-del="{{ r.id }}" title="Retirer le lien">✕</button>
{% endif %}
</li>
{% endfor %}
</ul>
{% if page_data.can_edit %}
<form class="draco-liens-form" data-refs-form>
<input type="url" required placeholder="Coller une URL profonde (TikTak, ObsiGate, Gitea, Flowdeck)…">
<input type="text" placeholder="Libellé (optionnel)">
<button type="submit">Lier</button>
</form>
<p class="draco-liens-erreur" data-refs-erreur hidden></p>
{% endif %}
</section>
<div class="editor-statusbar">
<span x-show="saving">Saving...</span>
<span x-show="!saving&&dirty">Unsaved</span>
+4 -1
View File
@@ -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). -->
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -146,7 +147,8 @@
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
<div class="app-layout" x-data="appState()" x-cloak>
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
@@ -2867,6 +2869,7 @@
});
</script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
+1
View File
@@ -9,6 +9,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% include "_page_editor_content.html" %}
{% endblock %} {% block scripts %}
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
<script src="/static/js/draco-refs.js?v={{ asset_version }}" defer></script>
{% include "_page_editor_scripts.html" %}
{% include "_page_editor_realtime.html" %}
{% include "_database_table_scripts.html" %}