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
+24
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
7.71.5
7.72.0
+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" %}
+33
View File
@@ -5309,3 +5309,36 @@ body.page-locked .get-started-toolbar { display: none !important; }
.fd-gtpl-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
.fd-gtpl-empty { color: var(--text-dim); font-size: 13px; padding: 20px 0; text-align: center; }
.fd-gtpl-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
/* ── Barre d'apps commune dracodev + section « Liens » des pages
(Fusion ACC-1.1/1.3, snippet figé dracodev-commons v0.1.0) ── */
body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
@media (max-width: 700px) {
body.has-draco-bar .app-layout { height: calc(100vh - 52px); }
}
.draco-liens {
margin: 28px 0 8px; padding: 16px 18px;
border: 1px solid var(--border, #e3e3e0); border-radius: 12px;
background: var(--bg-secondary, #f7f7f5);
}
.draco-liens-title { margin: 0 0 10px; font-size: 15px; }
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
.draco-ref-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 7px; flex: none;
}
.draco-liens-del {
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
color: var(--text-secondary, #888); font-size: 12px;
}
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
.draco-liens-form input {
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border, #e3e3e0); font: inherit; font-size: 13.5px;
}
.draco-liens-form button {
padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border, #e3e3e0);
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
+127
View File
@@ -0,0 +1,127 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
.draco-brand-mark { color: var(--agent); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
en barre basse propre à chaque app (patron du prototype v5+). */
.draco-apptabs, .draco-domaines-lbl { display: none; }
.draco-bar { height: 52px; }
}
+65
View File
@@ -0,0 +1,65 @@
/* Références croisées des pages — Fusion ACC-1.3 (Flowdeck).
* Câble la section « Liens » rendue côté serveur dans l'éditeur de page :
* ajout par collage d'URL profonde (POST /api/pages/<id>/refs) et retrait
* (DELETE /api/refs/<id>), jeton CSRF lu dans le cookie comme le reste de
* l'app. Après chaque mutation, la page est rechargée — l'état affiché
* reste celui du serveur, seule source de vérité.
*/
(function () {
"use strict";
function csrf() {
var m = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
function wire(section) {
var ownerType = section.getAttribute("data-owner-type");
var ownerId = section.getAttribute("data-owner-id");
var base = ownerType === "flowdeck.project"
? "/api/collections/" + ownerId + "/refs"
: "/api/pages/" + ownerId + "/refs";
var erreur = section.querySelector("[data-refs-erreur]");
function showError(msg) {
if (erreur) {
erreur.textContent = msg;
erreur.hidden = false;
}
}
section.querySelectorAll("[data-ref-del]").forEach(function (btn) {
btn.addEventListener("click", function () {
fetch("/api/refs/" + btn.getAttribute("data-ref-del"), {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": csrf() }
}).then(function (r) {
if (r.ok) { window.location.reload(); }
else { r.json().then(function (d) { showError(d.detail || "HTTP " + r.status); }); }
}).catch(function () { showError("Réseau indisponible"); });
});
});
var form = section.querySelector("[data-refs-form]");
if (form) {
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var inputs = form.querySelectorAll("input");
fetch(base, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf() },
body: JSON.stringify({ url: inputs[0].value.trim(), label: inputs[1].value.trim() || null })
}).then(function (r) {
if (r.ok) { window.location.reload(); }
else { r.json().then(function (d) { showError(d.detail || "HTTP " + r.status); }); }
}).catch(function () { showError("Réseau indisponible"); });
});
}
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("[data-refs]").forEach(wire);
});
})();
+56
View File
@@ -0,0 +1,56 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+126
View File
@@ -0,0 +1,126 @@
"""Références croisées ``external_ref`` — Fusion ACC-1.3 (Flowdeck).
Vérifie le parseur d'URL profondes (contrat figé dracodev-commons), le
cycle API sur une page et sur un projet (collection), les refus
(422/404/401) et la route profonde ``/collections/{id}`` du contrat
ACC-0.1 (redirection vers la vue native ``/db/{id}``).
"""
import pytest
from app.db import get_conn
from app.services.external_refs import parse_deep_link
# ── Parseur d'URL profondes ─────────────────────────────────────────────
@pytest.mark.parametrize("url,attendu", [
("https://tiktak.dracodev.net/?task=42", ("tiktak", "task", "42")),
("https://tiktak.dracodev.net/?list=3", ("tiktak", "list", "3")),
("https://og.dracodev.net/#file=Perso:Notes/Idée.md",
("obsigate", "note", "Perso:Notes/Idée.md")),
("https://flowdeck.dracodev.net/pages/7", ("flowdeck", "page", "7")),
("https://flowdeck.dracodev.net/collections/9", ("flowdeck", "project", "9")),
("https://git.dracodev.net/Projets/ticktick-clone/issues/213",
("kanbante", "card", "Projets/ticktick-clone#213")),
])
def test_parse_formes_reconnues(url, attendu):
parsed = parse_deep_link(url)
assert parsed is not None
assert parsed[:3] == attendu
@pytest.mark.parametrize("url", [
"https://tiktak.dracodev.net/autre?x=1",
"https://og.dracodev.net/#recherche",
"https://flowdeck.dracodev.net/tableau",
"https://example.com/pages/1",
"pas une url",
"",
])
def test_parse_formes_refusees(url):
assert parse_deep_link(url) is None
# ── Fixtures d'objets ───────────────────────────────────────────────────
def _creer_page() -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title) VALUES ('bruno/flowdeck', 'Page Fusion')"
)
conn.commit()
return cur.lastrowid
def _creer_collection() -> int:
with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name) VALUES ('Projet Fusion')")
conn.commit()
return cur.lastrowid
# ── API sur une page ────────────────────────────────────────────────────
def test_cycle_refs_page(client):
pid = _creer_page()
r = client.post(f"/api/pages/{pid}/refs",
json={"url": "https://tiktak.dracodev.net/?task=42"})
assert r.status_code == 201, r.text
ref = r.json()
assert ref["target_app"] == "tiktak" and ref["target_id"] == "42"
assert ref["label"] == "TikTak · tâche 42"
assert ref["app_color"] == "#e8762c"
r = client.post(f"/api/pages/{pid}/refs",
json={"url": "https://og.dracodev.net/#file=Perso:A.md",
"label": "Note liée"})
assert r.status_code == 201, r.text
r = client.get(f"/api/pages/{pid}/refs")
assert r.status_code == 200
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak", "obsigate"]
assert r.json()["refs"][1]["label"] == "Note liée"
r = client.delete(f"/api/refs/{ref['id']}")
assert r.status_code == 200
r = client.get(f"/api/pages/{pid}/refs")
assert [x["target_app"] for x in r.json()["refs"]] == ["obsigate"]
def test_ref_url_inconnue_422(client):
pid = _creer_page()
r = client.post(f"/api/pages/{pid}/refs", json={"url": "https://example.com/x"})
assert r.status_code == 422
def test_delete_ref_inconnue_404(client):
r = client.delete("/api/refs/999999")
assert r.status_code == 404
def test_refs_anonyme_401(client):
from tests.conftest import anon
pid = _creer_page()
r = anon(client).get(f"/api/pages/{pid}/refs")
assert r.status_code == 401
# ── API sur un projet (collection) + route profonde ─────────────────────
def test_cycle_refs_collection(client):
cid = _creer_collection()
r = client.post(f"/api/collections/{cid}/refs",
json={"url": "https://git.dracodev.net/Projets/fusion/issues/5"})
assert r.status_code == 201, r.text
assert r.json()["target_app"] == "kanbante"
r = client.get(f"/api/collections/{cid}/refs")
assert len(r.json()["refs"]) == 1
def test_route_profonde_collection(client):
cid = _creer_collection()
r = client.get(f"/collections/{cid}", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == f"/db/{cid}"
r = client.get("/collections/999999", follow_redirects=False)
assert r.status_code == 404