fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped

Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
2026-10-09 16:58:04 -04:00
parent 7468e497c6
commit 0218d8f5e5
44 changed files with 12060 additions and 1729 deletions
+887 -1529
View File
File diff suppressed because it is too large Load Diff
+167 -1
View File
@@ -1,6 +1,172 @@
# Changelog - FlowDeck
## v7.69.4 (2026-10-08) — Le close de Settings ferme vraiment
## v7.69.9 (2026-10-09) — /local-workspace : chemin du header + navigation dans les dossiers réparée
### Fixed
- **/local-workspace : le chemin du header suit la navigation** — le header
restait bloqué sur « Home / <workspace> » quel que soit le dossier
affiché. La route rend désormais `breadcrumb_items`
(`_render_local_workspace`) au format des pages
(`_nav_breadcrumb`) : **Home / <workspace> / <dossier> / <sous-dossier> /
…**, chaque niveau cliquable (`/local-workspace?folder=<id>`) et le
dernier niveau courant ; au-delà de 4 niveaux, collapse « … » identique
à l'éditeur de page. La navigation **sans rechargement** (clic sur un
dossier dans la vue, breadcrumb interne, retour/avant) recalcule le
chemin via le nouvel event `flowdeck:breadcrumb-changed`, écouté par
`fdBreadcrumb` (`_header.html`) — les renommages continuent de suivre
via `flowdeck:page-renamed`.
- **/local-workspace : clic sur un dossier du sidebar = tous les composants
affichés en même temps** — après une navigation partielle
(`/local-workspace` → `/local-workspace?folder=<id>`), Alpine échouait
à remonter la page : `Alpine.data('wsInitData')` retournait **le même
objet singleton** et Alpine redéfinit ses magics (`$nextTick`…) avec
`Object.defineProperty` non-configurable →
« Cannot redefine property: $nextTick », `initTree` abandonnait et
**tout le contenu restait au state brut** (tree/list/détails/cartes,
dropdowns, toast Undo, barre d'upload… visibles simultanément).
La factory retourne désormais une enveloppe fraîche à chaque montage,
qui délègue lecture/écriture au proxy réactif partagé (état et
`wsPreview` inchangés).
- **/local-workspace : le dossier de l'URL était perdu en navigation
partielle** — `LW` (config `#lw-config`) n'était lu qu'à la première
exécution du script (verrouillé par `__fdLocalWorkspaceScriptsLoaded`) :
le 2e montage gardait le `folder_id`/`workspace_id` du premier
chargement. La config est relue à chaque exécution et l'état transitoire
(recherche, filtre, panneaux) est remis à zéro au montage.
- **/local-workspace : breadcrumb interne toujours rendu** — il n'apparaît
que si la page était chargée avec `?folder=` ; il est désormais présent
dès la racine et suit la pile de dossiers.
### Tests
- `e2e/regression_local_workspace_breadcrumb.spec.js` : parcours complet
(sidebar → dossiers/sous-dossiers, clic dans la vue, breadcrumb
interne, crumb workspace du header) avec vérification du chemin header
et absence de composants peints à l'état brut.
## v7.69.8 (2026-10-09) — Library : ordre des colonnes visited/source
### Fixed
- **Combinaison « Last visited time » + Source illisible** — les cellules
de données rendaient Source avant Edited/Visited alors que l'en-tête
l'attend en dernier : les valeurs tombaient sous les mauvais intitulés.
L'ordre des cellules suit désormais l'en-tête (Name, Created by, Last
edited by, Last edited time, Last visited time, Source), et les intitulés
d'en-tête tronquent en ellipsis au lieu de déborder sur la colonne
suivante (reproduit et mesuré au navigateur : alignement header/lignes
vérifié colonne par colonne).
### Fixed
- **Sidebar Favorites : icônes Workspace** — la liste utilisait des emoji
bruts (voire rien) via `item.icon` ; `_build_item` renvoie désormais les
noms SVG (`folder`/`file`/`edit`/`image`), idem pour les favoris board.
- **/local-workspace : indicateurs de statut** — ★ favori (ambre), 👥
partagé (existant), 🌐 publié ; exposés par `_build_tree_children`
(`favorited`, `published`, `is_shared`) et rendus dans l'arbre Alpine
comme dans le rendu JS des enfants.
- **Library « Created by » vide** — aucune source auteur n'était
sélectionnée. Nouvelle provenance groupée : created by (première
version, sinon propriétaire du workspace), last edited by (dernière
version), last visited (table `recents`, tracée à chaque lecture de
page). Nouvelles colonnes **Last edited by**, **Last edited time**,
**Last visited time** ; le menu **Show columns** pilote chaque colonne,
seul Page name est verrouillé.
- **Colonne Source avec icône** — `file` (local), `link` (Gitea),
`external-link` (GitHub), mêmes SVG que le reste de Flowdeck.
- **Show columns réellement fonctionnel** — les règles de visibilité
(spécificité 0,1,0) perdaient contre `.lib-table-header .th` et
`.lib-row .row-cell` (0,2,0) : seule Last edited time s'affichait et
masquait, les autres options semblaient mortes (reproduit et corrigé
avec vérification en direct : les 5 toggles on/off + colonne Source
avec icône + libellé, 0 erreur console).
### Tests
- `tests/test_v696_sidebar_library_publish.py` (+3) : provenance et visite
tracée, icônes favoris, flags de l'arbre local-workspace.
## v7.69.6 (2026-10-09) — Library AI Meeting Notes, Recents réels, Publish unifié
### Added
- **Onglet Library « AI Meeting Notes »** (`GET /api/library/meetings`) :
notes de réunion de l'utilisateur regroupées par date — Today,
Yesterday, This week, Last week, This month, Older — avec icône
calendrier, statut (Summary ready / Ready to record) en colonne Source,
recherche et sélection réutilisées. Les notes sont exclues des onglets
Recents / Workspace / Private (ainsi que de l'arbre Workspace et du
navigateur local).
### Fixed
- **Sidebar Recents vide** — elle ne listait que les dépôts Gitea. Elle
liste désormais les fichiers réellement récents (`updated_at`, 8
derniers, dépôts conservés ensuite), avec les mêmes icônes SVG que le
Workspace et les notes de réunion exclues (→ Meetings).
- **Customize sidebar inutilisable** — l'overlay plein écran (z-index 998)
recouvrait aussi le panneau d'options : chaque clic refermait le mode.
Le panneau passe au-dessus (z-index 999).
- **Publish qui ne « tenait » pas** — le share dialog écrivait `published`,
les routes `/publish` écrivaient `is_published`, et chaque lecteur ne
regardait qu'une colonne. Toutes les écritures maintiennent désormais
les deux drapeaux (+ slug garanti), tous les lecteurs acceptent l'un ou
l'autre : la page publiée reste publiée dans le dialogue, apparaît dans
la section Published de la sidebar et dans l'onglet Published de la
Library.
### Tests
- `tests/test_v696_sidebar_library_publish.py` (6) : onglet meetings et
exclusions, Recents sidebar, publish/unpublish des deux chemins,
visibilité sidebar+library+dialogue, z-index du panneau customize.
## v7.69.5 (2026-10-09) — Meeting Notes : setup visible, retry qui ne perd plus rien
### Fixed
- **Pas de « Setup check » visible avant l'enregistrement** — il était caché
dans le menu ▾ du bouton Start, que personne n'ouvre. Le panneau idle
affiche désormais une bande de vérification : état de la permission micro
(via Permissions API), transcription live dispo ou repli manuel, mode
onglet supporté ou non, et bouton **Test microphone** (4 s de niveau réel,
qui fait aussi accorder l'autorisation avant le Start).
- **Erreur 400 persistante au Stop/Retry après un collage de transcript** —
`retry()`/`regenerate()` appelaient le serveur sans y verser d'abord le
contenu local (segments + manuel collé) : le serveur restait vide et
répondait 400 en boucle. Ils drainent désormais le tampon local (attente
bornée) avant tout appel, et `retry()` détecte une session encore en vie
côté serveur pour terminer par `/stop` plutôt que `/processing:retry`.
- **Stop appelé alors que l'upload des segments échoue** — condamnait à un
400 (« transcript is empty ») malgré des segments en local. Le client
n'appelle plus `/stop` dans ce cas : état `failed` avec message « envoi
impossible, Retry », session serveur conservée.
- **`/summarize` accepte `allow_empty`** (diagnostic `fragmentary` honnête
au lieu de 400), comme `/stop` et `/processing:retry`.
- **Cache-busting** : le bump 7.69.4 → 7.69.5 change `?v=` de tous les
assets — les navigateurs ayant gardé l'ancien `meeting_block.js` (sans
garde-fou ni diagnostic) reçoivent le nouveau code. C'était la cause la
plus probable du « toujours l'erreur 400 ».
- **Menu contextuel des sections Meetings et Workspace** : les notes de la
section Meetings ont désormais le même clic droit que le Workspace
(Open / Rename / Delete, via `showWsContext`) ; l'action **Open** est
ajoutée au menu des deux sections (page → `/pages/{id}`, dossier →
navigation dossier) ; rename/delete rafraîchissent la section Meetings.
- **Sidebar sur /local-workspace** : la règle CSS globale `.ws-tree-item`
de la page écrasait les items de la sidebar (espacements différents) —
scopée à `.ws-tree` (arbre principal) ; et `refreshSidebarTree()`
remonte désormais le fragment via `Alpine.initTree`, sans quoi l'arbre
perdait menu contextuel, toggles et drag&drop après chaque refresh
(d'où le menu « mort » sur cette page qui rafraîchit en continu).
### Tests
- `tests/test_v711_meeting_notion.py` (+5) : `summarize` vide → 400 sans
flag / diagnostic avec flag, retry intelligent quand la session serveur
est encore live, `meeting_notes` + propriété, exclusion de l'arbre
Workspace.
### Fixed
+1 -1
View File
@@ -1 +1 @@
7.69.4
7.69.9
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.69.4",
version="7.69.9",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+46 -8
View File
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
# Documents
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
# Code
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
".swift", ".kt", ".scala", ".r",
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
".py", ".pyw", ".pyi", ".gyp", ".gypi",
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
".rss", ".atom", ".plist", ".wsf", ".axml",
".vue", ".svelte", ".astro",
".css", ".scss", ".sass", ".less",
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
".har", ".avsc",
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
".properties", ".prop", ".inf", ".reg", ".url",
".gitconfig", ".gitmodules", ".editorconfig",
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
".rb", ".gemspec", ".rake", ".rbw",
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
".scala", ".sc", ".groovy", ".gvy", ".gradle",
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
".cs", ".csx", ".cake", ".ino", ".pde",
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
".pl", ".pm", ".pod",
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
".erl", ".hrl", ".escript",
".clj", ".cljs", ".cljc", ".edn",
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
".nim", ".nims", ".d", ".di",
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
".asm", ".s", ".nasm", ".inc",
".ada", ".ads", ".adb",
".f", ".for", ".f90", ".f95", ".f03", ".f08",
".cob", ".cbl", ".cpy",
".dockerfile", ".containerfile",
".tf", ".tfvars", ".hcl",
".cmake", ".makefile", ".mk", ".mak", ".ninja",
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
".vim", ".vimrc", ".gvimrc", ".awk",
".gcode", ".gco", ".nc", ".ngc",
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
".diff", ".patch", ".rej",
".mod", ".sum", ".lock", ".text",
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
# Archives
".zip", ".tar", ".gz", ".rar", ".7z",
# Misc
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
})
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
+77
View File
@@ -1725,3 +1725,80 @@ def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
@register(38, "v7.1.1: meeting block Notion — états, segments, consentement, partage")
def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
"""v7.1.1 — refonte Meetings façon Notion (§3A/3B du doc d'architecture).
``meeting_transcripts`` gagne la machine à états du bloc
(idle/recording/paused/processing/done/failed), les segments groupés par
source, la photo d'instructions, le journal de consentement résumé, les
étapes Thinking persistées, le résumé structuré + titre généré + classe de
complétude. Deux tables append-only : consentement et diffusions.
Règle d'unicité : une occurrence calendrier = au plus une note.
"""
cols = columns(conn, "meeting_transcripts")
for ddl in (
"ALTER TABLE meeting_transcripts ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'",
"ALTER TABLE meeting_transcripts ADD COLUMN mode TEXT NOT NULL DEFAULT 'mic_only'",
"ALTER TABLE meeting_transcripts ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_id TEXT NOT NULL DEFAULT 'auto'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_snapshot TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN consent_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN processing_steps_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN summary_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN generated_title TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN title_source TEXT NOT NULL DEFAULT 'user'",
"ALTER TABLE meeting_transcripts ADD COLUMN completeness_class TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN quality_score REAL",
"ALTER TABLE meeting_transcripts ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN paused_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN started_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN stopped_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN event_occurrence_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN notes_text TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN banner_dismissed INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN share_bar_dismissed INTEGER NOT NULL DEFAULT 0",
):
name = ddl.split("ADD COLUMN")[1].strip().split()[0]
if name not in cols:
conn.execute(ddl)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_consent_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
method TEXT NOT NULL DEFAULT 'start_attestation',
message_ref TEXT NOT NULL DEFAULT '',
attested_by INTEGER REFERENCES users(id),
participants_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_distributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'copy_link',
actor_id INTEGER REFERENCES users(id),
recipients_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_consent_tr ON meeting_consent_log(transcript_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_distr_tr ON meeting_distributions(transcript_id)"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_meeting_one_per_occurrence "
"ON meeting_transcripts(event_occurrence_id) "
"WHERE event_occurrence_id <> ''"
)
+19 -8
View File
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
"""Load top-level pages with children for the active workspace.
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
réunion — elles vivent dans la section Meetings de la sidebar
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
"""
if not ws_cookie:
return []
try:
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
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",
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
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",
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
).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"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
@@ -521,17 +528,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
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 "
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
"p.content_format FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
).fetchall()
favorites = []
for r in fav_rows:
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
is_folder = r["parent_section"] == "Workspace"
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
+15 -2
View File
@@ -95,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
# un slug garanti quand on publie depuis le share dialog.
from app.services.publish import _unique_slug
row = conn.execute(
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
if published and not slug:
title_row = conn.execute(
"SELECT title FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = _unique_slug(
conn, page_id, title_row["title"] if title_row else "")
conn.execute(
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
(mode, 1 if published else 0, page_id),
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
)
conn.commit()
return {"status": "ok", "share_mode": mode, "published": published}
+60 -5
View File
@@ -90,6 +90,50 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
"""Fichiers réellement récents (dernière édition) pour la sidebar.
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
d'emoji). Notes de réunion exclues → section Meetings.
"""
if not uid:
return []
try:
from app.routers.board import _file_icon
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.parent_section, p.content_format
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, limit),
).fetchall()
out = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
out.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return out
except Exception:
logger.exception("_recent_sidebar_pages")
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
@@ -108,7 +152,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
@@ -359,21 +403,26 @@ def _require_page_view(request: Request, page_id: int) -> None:
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
f"{no_meet}"
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
@@ -441,6 +490,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"published": bool(r["pub1"] or r["pub2"]),
"favorited": r["id"] in favorited_ids,
})
return tree
@@ -671,12 +721,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
)
elif t == "code":
lang = b.get("language", "")
try:
from app.services.export import normalize_code_lang
hljs_lang = normalize_code_lang(lang)
except Exception: # noqa: BLE001 — never break public rendering
hljs_lang = "plaintext"
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
+23
View File
@@ -100,6 +100,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
# En-tête Notion-style (même mécanique que les pages) :
# Home / <workspace> / <dossier> / <sous-dossier> / …
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
# ça le header restait bloqué sur « Home / <workspace> » même quand le
# contenu affiché était celui d'un sous-dossier.
nav_crumbs: list = [{
"id": None,
"label": ws_name,
"url": "/local-workspace",
"icon": "folder",
"menu": False,
}]
for i, bcrumb in enumerate(breadcrumb):
is_last = i == len(breadcrumb) - 1
nav_crumbs.append({
"id": bcrumb["id"],
"label": bcrumb["name"],
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
"icon": "folder",
"menu": True,
})
ctx = {
**sidebar,
"user": user,
@@ -111,6 +133,7 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
+19 -1
View File
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
try:
from datetime import UTC, datetime
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if _sess and _sess.get("id"):
with get_conn() as _conn:
_conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
accessed_at=excluded.accessed_at""",
(_sess["id"], page_id, page.get("workspace", "") or "",
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
_conn.commit()
except Exception:
pass
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
@@ -119,7 +137,7 @@ def view_page_root(request: Request, page_id: int):
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _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)),
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("is_published", 0) or page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
+124 -15
View File
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
else:
url = f"/pages/{page_id}"
# Icon
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
# des favoris de la sidebar consomme item.icon via fdIconHtml.
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "📁"
icon = "folder"
elif content_format == "file":
icon = "📄"
icon = "file"
fn = title.lower()
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "image"
else:
icon = "📝"
icon = "edit"
return {
"id": page_id,
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_icon": {"local": "file", "gitea": "link",
"github": "external-link"}.get(source_type, "file"),
"source_label": _source_label(source_type, workspace),
"workspace": workspace,
"workspace_name": _source_label(source_type, workspace),
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
items = _attach_tags(items)
return _attach_provenance(items, uid)
def _attach_provenance(items: list, uid: int = 1) -> list:
"""Created by / Last edited by / Last visited (colonnes Library).
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
- edited_by : dernier auteur des versions, sinon created_by ;
- visited_at : dernier accès tracé de l'utilisateur (table recents).
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
"""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
try:
with get_conn() as conn:
ver = conn.execute(
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
).fetchall()
first: dict = {}
last: dict = {}
for r in ver:
first.setdefault(r["page_id"], r["name"] or "")
last[r["page_id"]] = r["name"] or ""
own = conn.execute(
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
f"LEFT JOIN users u ON u.id = w.owner_id "
f"WHERE p.id IN ({placeholders})", ids,
).fetchall()
owners = {r["id"]: r["owner"] or "" for r in own}
try:
vis = conn.execute(
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
f"GROUP BY page_id", [uid, *ids],
).fetchall()
except Exception:
vis = []
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
except Exception:
logger.exception("_attach_provenance")
return items
for it in items:
pid = it["id"]
author = first.get(pid) or owners.get(pid, "")
it["author"] = author
it["author_initial"] = (author or "?")[0].upper()
edited_by = last.get(pid) or author
it["edited_by"] = edited_by
it["edited_by_initial"] = (edited_by or "?")[0].upper()
it["visited_at"] = visited.get(pid, "")
return items
def _attach_tags(items: list) -> list:
@@ -156,6 +211,59 @@ BASE_SELECT = (
)
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
NO_MEETING_BLOCK = (
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/meetings")
def library_meetings(
request: Request,
source_type: str = Query(default="all"),
):
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
exclues des onglets de navigation courante (recents/workspace/private).
"""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"{BASE_SELECT}, "
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 100"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it, r in zip(items, rows, strict=True):
d = dict(r)
st = d.get("meeting_status") or "idle"
it["is_meeting"] = True
it["meeting_status"] = st
it["source_label"] = (
"Summary ready" if (d.get("meeting_summary") or "").strip()
else ("Ready to record" if st == "idle"
else st.replace("_", " ").capitalize())
)
_enrich_children(items)
return {"items": items}
@router.get("/recents")
def library_recents(
request: Request,
@@ -167,7 +275,7 @@ def library_recents(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
@@ -301,7 +409,8 @@ def library_published(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -326,7 +435,7 @@ def library_private(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -392,7 +501,7 @@ def library_workspace(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
+329 -2
View File
@@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30):
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcripts")
def create_transcript(request: Request, body: dict = Body(default={})):
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
Idempotent par occurrence calendrier : renvoie la note existante.
"""
user = _auth_user(request, require_write=True)
try:
page_id = int(body.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
occ = (body.get("event_occurrence_id") or "").strip()[:200]
with get_conn() as conn:
if occ:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
(occ,)).fetchone()
if row:
return {**row_to_dict(row), "already_existed": True}
try:
tid = meet.save_transcript(
page_id, "", (body.get("language") or "fr")[:10],
mode=(body.get("mode") or "mic_only")
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
instruction_id=(body.get("instruction_id") or "auto")[:40],
event_occurrence_id=occ)
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201,
content={**row_to_dict(row), "already_existed": False})
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
@@ -254,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
async def summarize(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
out = await meet.summarize_transcript(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
@router.get("/api/v2/meetings/instructions")
def meeting_instructions(request: Request):
_auth_user(request)
return {"instructions": [
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
]}
# Une page est une « note de réunion » dès qu'elle contient un bloc
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
MEETING_BLOCK_PREDICATE = (
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/api/v2/meetings/notes")
def meeting_notes(request: Request, limit: int = 50):
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
Alimente la section Meetings de la sidebar (ces pages sont exclues de
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
``status = idle``.
"""
user = _auth_user(request)
uid = user.get("id")
lim = max(1, min(int(limit or 50), 200))
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
with get_conn() as conn:
rows = conn.execute(
f"""SELECT p.id, p.title, p.updated_at,
(SELECT mt.status FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
(SELECT mt.summary FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND {pred}
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, lim),
).fetchall()
notes = []
for r in rows:
d = row_to_dict(r)
notes.append({
"id": d["id"],
"title": d.get("title") or "Untitled",
"updated_at": d.get("updated_at") or "",
"status": d.get("status") or "idle",
"has_summary": bool((d.get("summary") or "").strip()),
})
return {"notes": notes, "count": len(notes)}
@router.get("/api/v2/meetings/history")
def meetings_history(request: Request, query: str = "", limit: int = 50):
"""Historique des notes de réunion (passé + recherche plein texte)."""
user = _auth_user(request)
_ = user
like = f"%{query.strip()}%" if query.strip() else "%"
lim = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
JOIN pages p ON p.id = mt.page_id
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
ORDER BY mt.id DESC LIMIT ?""",
(like, like, like, like, lim),
).fetchall()
out = []
for r in rows:
d = row_to_dict(r)
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
out.append(d)
return {"meetings": out, "count": len(out)}
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
def get_transcript_row(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
entry = meet.record_consent(
transcript_id, method=(body.get("method") or "start_attestation"),
message_ref=(body.get("message_ref") or "")[:200],
attested_by=user.get("id"),
participants=body.get("participants") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.consent", "transcript", transcript_id,
entry["method"], request)
return entry
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.start_session(
transcript_id, mode=(body.get("mode") or "mic_only"),
channels=body.get("channels"), language=(body.get("language") or "fr"),
instruction_id=(body.get("instruction_id") or "auto"),
user_id=user.get("id"))
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.session.start", "transcript", transcript_id,
out.get("mode", ""), request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.pause_session(transcript_id,
paused_ms_delta=int(body.get("paused_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
def resume_session(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = meet.resume_session(transcript_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
async def stop_session(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
stopped = meet.stop_session(transcript_id,
duration_ms=int((body or {}).get("duration_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
# d'échec LLM le transcript reste consultable (état failed, retry).
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
# jamais une ligne coincée en « processing ».
try:
out = await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
return {"session": {"id": stopped["id"], "status": "done"}, **out}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
def append_segments(transcript_id: int, request: Request,
body: dict = Body(default={})):
_auth_user(request, require_write=True)
try:
return meet.append_segments(transcript_id, body.get("segments") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
def grouped_transcript(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.grouped_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
def patch_speaker(transcript_id: int, seq: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
return meet.set_segment_speaker(transcript_id, seq,
body.get("speaker") or "",
user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
def processing_steps(transcript_id: int, request: Request):
_auth_user(request)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
import json as _json
try:
steps = _json.loads(tr.get("processing_steps_json") or "[]")
except (TypeError, ValueError):
steps = []
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
"steps": steps}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
async def retry_processing(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
if tr.get("status") not in ("failed", "processing", "done"):
raise HTTPException(400, "nothing to retry — no failed processing")
try:
return await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
title = (body.get("title") or "").strip()[:200]
if not title:
raise HTTPException(400, "title required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
" title_source='user' WHERE id=?", (title, transcript_id))
conn.commit()
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
return {"transcript_id": transcript_id, "generated_title": title}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
def post_distribution(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.record_distribution(
transcript_id, channel=(body.get("channel") or "copy_link"),
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
status=body.get("status") or "created")
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.share", "transcript", transcript_id,
out["channel"], request)
return out
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
_auth_user(request, require_write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
((body.get("notes") or "")[:50000], transcript_id))
conn.commit()
return {"transcript_id": transcript_id, "saved": True}
+224 -23
View File
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
# ── Source resolution: read real textual content for ANY page type ──
# Extensions whose content is plain text / code / markdown (textual exportable).
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
# the viewer lives client-side; this set gates server-side text handling:
# file viewer, copy-to-clipboard, exports, imports).
_TEXTUAL_EXTS = {
"md", "markdown", "txt", "log", "text",
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
"properties", "gitignore", "dockerfile", "makefile",
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
"py", "pyw", "pyi", "gyp", "gypi",
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
"rss", "atom", "plist", "wsf", "svg", "axml",
"vue", "svelte", "astro",
"css", "scss", "sass", "less",
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
"inf", "reg", "url", "prop",
"gitconfig", "gitmodules", "editorconfig",
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
"ps1", "psm1", "psd1", "bat", "cmd",
"rb", "gemspec", "rake", "rbw",
"go", "rs", "java", "jsp", "kt", "kts",
"scala", "sc", "groovy", "gvy", "gradle",
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
"cs", "csx", "cake", "ino", "pde",
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
"r", "jl", "lua", "dart",
"pl", "pm", "pod",
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
"erl", "hrl", "escript",
"clj", "cljs", "cljc", "edn",
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
"nim", "nims", "d", "di",
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
"vhd", "vhdl", "v", "vh", "sv", "svh",
"asm", "s", "nasm", "inc",
"ada", "ads", "adb",
"f", "for", "f90", "f95", "f03", "f08",
"cob", "cbl", "cpy",
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
"cmake", "makefile", "mk", "mak", "ninja",
"gql", "graphql", "graphqls", "proto", "thrift",
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
"http", "rest", "nginx", "apache", "htaccess", "wat",
"diff", "patch", "rej",
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
"lock", "mod", "sum",
}
# Well-known extensionless / dotfile basenames that are textual code.
_TEXTUAL_FILENAMES = {
"dockerfile", "containerfile", "makefile", "gnumakefile",
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
"go.mod", "go.sum",
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
".vimrc", ".gvimrc", ".viminfo",
".gitconfig", ".editorconfig", ".htaccess",
}
# Exact basenames → highlight.js id (checked before the extension).
_FILENAME_LANG = {
"dockerfile": "dockerfile", "containerfile": "dockerfile",
"makefile": "makefile", "gnumakefile": "makefile",
"cmakelists.txt": "cmake",
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
"fastfile": "ruby", "podfile": "ruby",
"go.mod": "go",
"nginx.conf": "nginx",
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
".vimrc": "vim", ".gvimrc": "vim",
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
}
_CODE_LANG = {
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
"astro": "html", "properties": "ini", "md": "markdown",
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
"text": "plaintext",
"py": "python", "pyw": "python", "pyi": "python",
"gyp": "python", "gypi": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
"jsx": "javascript",
"ts": "typescript", "mts": "typescript", "cts": "typescript",
"tsx": "typescript",
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
"svg": "xml", "axml": "xml",
"vue": "xml", "svelte": "xml", "astro": "xml",
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
"json": "json", "json5": "json", "jsonc": "json",
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
"yaml": "yaml", "yml": "yaml",
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
"env": "ini", "properties": "ini", "prop": "ini",
"inf": "ini", "reg": "ini", "url": "ini",
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
"ninja": "ini",
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
"qmd": "markdown",
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
"dtx": "latex",
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
"mssql": "sql",
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
"mksh": "bash", "yash": "bash",
"fish": "shell",
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
"bat": "dos", "cmd": "dos",
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
"go": "go", "rs": "rust",
"java": "java", "jsp": "java",
"kt": "kotlin", "kts": "kotlin",
"scala": "scala", "sc": "scala",
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
"c": "c", "h": "c",
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
"ino": "cpp", "pde": "cpp",
"cs": "csharp", "csx": "csharp", "cake": "csharp",
"php": "php", "phtml": "php-template",
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
"hs": "haskell", "lhs": "haskell",
"ex": "elixir", "exs": "elixir", "eex": "elixir",
"heex": "elixir", "leex": "elixir",
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
"ml": "ocaml", "mli": "ocaml",
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
"nim": "nim", "nims": "nim",
"d": "d", "di": "d",
"m": "objectivec", "mm": "objectivec",
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
"vhd": "vhdl", "vhdl": "vhdl",
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
"ada": "ada", "ads": "ada", "adb": "ada",
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
"f03": "fortran", "f08": "fortran",
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
"dockerfile": "dockerfile",
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
"cmake": "cmake",
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
"proto": "protobuf", "thrift": "thrift",
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
"awk": "awk",
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
"http": "http", "rest": "http",
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
"wat": "wasm",
"diff": "diff", "patch": "diff", "rej": "diff",
"bib": "plaintext", "bbl": "plaintext",
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
"nfo": "plaintext", "asc": "plaintext",
"lock": "plaintext",
"gitignore": "plaintext", "gitattributes": "plaintext",
"gitkeep": "plaintext", "dockerignore": "plaintext",
"npmignore": "plaintext",
}
# Aliases users may type in markdown fences / old block labels.
_CODE_ALIASES = {
"py": "python", "js": "javascript", "jsx": "javascript",
"ts": "typescript", "tsx": "typescript",
"html": "xml", "htm": "xml", "markup": "xml",
"yml": "yaml", "md": "markdown",
"sh": "bash", "zsh": "bash", "console": "shell",
"ps1": "powershell", "ps": "powershell",
"bat": "dos", "cmd": "dos", "batch": "dos",
"c++": "cpp", "c#": "csharp", "cs": "csharp",
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"tex": "latex", "docker": "dockerfile", "make": "makefile",
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
"none": "plaintext", "": "plaintext",
}
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
_HLJS_LANGS = frozenset({
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
})
def normalize_code_lang(lang: str | None) -> str:
"""Normalize any code language label to a bundled highlight.js id.
Accepts highlight.js ids, file extensions, markdown fence names and the
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
"""
lang = (lang or "").strip().lower()
if not lang or lang in ("plain text", "none"):
return "plaintext"
if lang in _CODE_ALIASES:
lang = _CODE_ALIASES[lang]
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
return lang
return "plaintext"
def code_lang_for_file(filename: str | None) -> str:
"""Best highlight.js id for an uploaded / repo file name."""
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
if not name:
return "plaintext"
if name in _FILENAME_LANG:
return _FILENAME_LANG[name]
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return _CODE_LANG.get(ext, "plaintext")
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
name = (rel.rsplit("/", 1)[-1] or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
or mime.startswith("text/")):
return None
try:
full = (_data_root() / rel).resolve()
@@ -161,8 +360,7 @@ def _page_source(page: dict):
mime = (meta.get("mime_type") or "").lower()
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
return "md", text
lang = _CODE_LANG.get(ext, "plaintext")
return "code", (text, lang)
return "code", (text, code_lang_for_file(name))
# Unknown format (e.g. legacy) -> try to dump as raw text
if content.strip():
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
out.append("---")
elif t == "code":
lang = b.get("language") or ""
out.append(f"```{lang}\n{c}\n```")
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
elif t == "toggle":
out.append(f"### {c}")
if b.get("children"):
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
hljs_lang = normalize_code_lang(lang)
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(f"<pre>{label}<code>{c}</code></pre>")
parts.append(
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
)
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
elif t == "table_of_contents":
+15 -3
View File
@@ -7,7 +7,11 @@ from __future__ import annotations
from pathlib import Path
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
from app.services.export import (
_TEXTUAL_EXTS,
_TEXTUAL_FILENAMES,
code_lang_for_file,
)
from app.services.importers.base import ImportPage, ImportResult
_MD_EXTS = {"md", "markdown"}
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def _basename(path: str) -> str:
return path.replace("\\", "/").rsplit("/", 1)[-1].lower()
def _is_textual(path: str) -> bool:
return _ext(path) in _TEXTUAL_EXTS or _basename(path) in _TEXTUAL_FILENAMES
def build_repo_result(
files: list[tuple[str, str]],
*,
@@ -34,7 +46,7 @@ def build_repo_result(
if ext in _MD_EXTS:
markdown = content
else:
lang = _CODE_LANG.get(ext, "")
lang = code_lang_for_file(clean)
markdown = f"```{lang}\n{content.rstrip()}\n```"
parts = clean.split("/")
result.pages.append(ImportPage(
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
files: list[tuple[str, str]] = []
for meta in metas:
file_path = meta.get("path") or ""
if _ext(file_path) not in _TEXTUAL_EXTS:
if not _is_textual(file_path):
continue
if int(meta.get("size") or 0) > max_file_bytes:
continue
+669 -50
View File
@@ -1,19 +1,25 @@
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
"""FlowDeck — AI Meeting Notes façon Notion (v7.1.1).
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
machine à états du bloc, révélation progressive des onglets, consentement
bloquant journalisé, segments groupés par source, étapes Thinking persistées,
résumé structuré avec citations obligatoires, classification de complétude,
titre généré (diagnostic honnête si bref/incomplet), barre de partage tracée.
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
``transcript`` (client-side transcription). Nothing here requires new pip
dependencies. See ``docs/V71_Calendar_Meetings.md``.
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
append-only. Rien n'exige de dépendance pip supplémentaire.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from app.config import settings
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
MAX_AUDIO_BYTES = 100 * 1024 * 1024
# États du bloc (§6.5) — transitions toujours via le serveur.
BLOCK_STATES = ("idle", "recording", "paused", "processing", "done", "failed")
CAPTURE_MODES = ("mic_only", "tab_plus_mic", "import")
# Instructions de résumé (§12.4) : auto + presets par type + custom libre.
INSTRUCTIONS = {
"auto": {"label": "Auto", "prompt": "Résumé automatique adapté au contenu détecté."},
"standup": {"label": "Standup", "prompt": "Format standup : hier / aujourd'hui / blocages, par personne."},
"team": {"label": "Team meeting", "prompt": "Format équipe : sujets, décisions, actions avec responsables."},
"sales": {"label": "Sales call", "prompt": "Format commercial : besoin, objections, next steps, montant."},
"one_to_one": {"label": "1:1", "prompt": "Format 1:1 : points du collaborateur, feedback, engagements."},
"interview": {"label": "Interview", "prompt": "Format entretien : parcours, forces, doutes, décision."},
}
# Étapes Thinking exposées (§12.7), dans l'ordre — libellés FR/EN gérés côté UI.
PROCESSING_STEPS = (
("reading_transcript", "Reading transcript…"),
("analyzing_transcript", "Analyzing the transcript"),
("resolving_references", "Thinking about how to refer to people"),
("understanding_content", "Understanding the content"),
("classifying_completeness", "Checking completeness"),
("generating_summary", "Writing the summary"),
("generating_title", "Choosing a title"),
("validating_citations", "Checking citations"),
)
CONSENT_METHODS = (
"start_attestation", "verbal", "chat_text", "audio_message",
"meet_addon", "workspace_enforced",
)
class TranscriptionUnavailable(RuntimeError):
"""Raised when no transcription backend is configured."""
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
return d
def _now() -> str:
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
def _loads(raw: str, default):
try:
v = json.loads(raw or "")
return v if v is not None else default
except (TypeError, ValueError):
return default
def get_transcript(transcript_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)
).fetchone()
if not row:
raise ValueError("transcript not found")
return dict(row)
def _update(transcript_id: int, **fields) -> None:
if not fields:
return
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
conn.execute(
f"UPDATE meeting_transcripts SET {cols} WHERE id=?",
(*fields.values(), transcript_id),
)
conn.commit()
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
audio_path: str = "", **kw) -> int:
"""Crée une note de réunion (état ``idle``). Rétro-compatible v7.1.0."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
occ = (kw.get("event_occurrence_id") or "").strip()
if occ and conn.execute(
"SELECT id FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)
).fetchone():
raise ValueError("a meeting note already exists for this calendar occurrence")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
"""INSERT INTO meeting_transcripts
(page_id, audio_path, transcript, language, status, mode,
instruction_id, event_occurrence_id, notes_text)
VALUES (?,?,?,?,?,?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr",
"idle", kw.get("mode") or "mic_only",
(kw.get("instruction_id") or "auto")[:40], occ,
kw.get("notes_text") or ""),
)
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
def record_consent(transcript_id: int, *, method: str = "start_attestation",
message_ref: str = "", attested_by: int | None = None,
participants: list | None = None) -> dict:
if method not in CONSENT_METHODS:
raise ValueError(f"unknown consent method: {method}")
get_transcript(transcript_id) # 404 si inconnu
entry = {
"method": method,
"message_ref": message_ref or "",
"attested_by": attested_by,
"participants": participants or [],
"attested_at": _now(),
}
with get_conn() as conn:
conn.execute(
"""INSERT INTO meeting_consent_log
(transcript_id, method, message_ref, attested_by, participants_json)
VALUES (?,?,?,?,?)""",
(transcript_id, method, message_ref or "",
attested_by, json.dumps(participants or [], ensure_ascii=False)),
)
conn.execute(
"UPDATE meeting_transcripts SET consent_json=? WHERE id=?",
(json.dumps(entry, ensure_ascii=False), transcript_id),
)
conn.commit()
return entry
def require_consent(tr: dict) -> dict:
consent = _loads(tr.get("consent_json") or "", None)
if consent:
return consent
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_consent_log WHERE transcript_id=? "
"ORDER BY id DESC LIMIT 1", (tr["id"],)
).fetchone()
if not row:
raise ValueError("consent required — record consent before capturing")
return {
"method": row["method"], "message_ref": row["message_ref"],
"attested_by": row["attested_by"],
"participants": _loads(row["participants_json"], []),
}
# ── Sessions de capture : start / pause / resume / stop ─────────────────────
def start_session(transcript_id: int, *, mode: str = "mic_only",
channels: list | None = None, language: str = "fr",
instruction_id: str = "auto", user_id: int | None = None) -> dict:
if mode not in CAPTURE_MODES:
raise ValueError(f"unknown capture mode: {mode}")
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "processing"):
raise ValueError(f"meeting is {tr['status']} — stop it first")
consent = require_consent(tr)
instr = (instruction_id or "auto").strip()[:40]
snapshot = {
"instruction_id": instr if instr in INSTRUCTIONS else "custom",
"instruction_raw": instr,
"definition": INSTRUCTIONS.get(instr, {"label": instr, "prompt": instr}),
"frozen_at": _now(),
}
chans = channels or (
[{"kind": "self", "label": "Microphone"}] if mode == "mic_only"
else [{"kind": "self", "label": "My microphone"},
{"kind": "remote", "label": "Tab audio"}] if mode == "tab_plus_mic"
else []
)
_update(transcript_id, status="recording", mode=mode,
channels_json=json.dumps(chans, ensure_ascii=False),
language=(language or tr.get("language") or "fr")[:10],
instruction_id=instr,
instruction_snapshot=json.dumps(snapshot, ensure_ascii=False),
segments_json="[]", processing_steps_json="[]", summary="",
summary_json="", generated_title="", completeness_class="",
duration_ms=0, paused_ms=0, started_at=_now(), stopped_at=None)
return get_transcript(transcript_id) | {"_consent": consent}
def pause_session(transcript_id: int, *, paused_ms_delta: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "recording":
raise ValueError("only a recording session can be paused")
_update(transcript_id, status="paused",
paused_ms=int(tr.get("paused_ms") or 0) + max(0, int(paused_ms_delta or 0)))
return get_transcript(transcript_id)
def resume_session(transcript_id: int) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "paused":
raise ValueError("only a paused session can be resumed")
require_consent(tr)
_update(transcript_id, status="recording")
return get_transcript(transcript_id)
def stop_session(transcript_id: int, *, duration_ms: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") not in ("recording", "paused"):
raise ValueError("nothing to stop — no live session")
require_consent(tr)
_update(transcript_id, status="processing", stopped_at=_now(),
duration_ms=max(int(duration_ms or 0), int(tr.get("duration_ms") or 0)))
return get_transcript(transcript_id)
# ── Segments : append (temps réel), correction du locuteur ───────────────────
def _next_seq(segments: list) -> int:
return (max((s.get("seq", 0) for s in segments), default=0) + 1) if segments else 1
def append_segments(transcript_id: int, segments: list) -> dict:
"""Persiste les segments finaux du client (STT navigateur / import).
Chaque item : {channel, source_label, speaker, text, start_ms, end_ms,
starts_mid, ends_mid}. Le transcript texte est reconstruit pour compat.
"""
tr = get_transcript(transcript_id)
# Retry/regen après un échec : le client reverse le manuel collé
# entre-temps — l'ajout reste possible hors session live.
if tr.get("status") not in ("recording", "paused", "processing",
"failed", "done"):
raise ValueError("session is not live — start recording first")
require_consent(tr)
if not isinstance(segments, list) or not segments:
raise ValueError("segments must be a non-empty list")
if len(segments) > 200:
raise ValueError("too many segments per batch (max 200)")
current = _loads(tr.get("segments_json") or "[]", [])
seq = _next_seq(current)
for item in segments:
if not isinstance(item, dict) or not (item.get("text") or "").strip():
raise ValueError("each segment needs a non-empty text")
text = item["text"].strip()[:4000]
current.append({
"seq": seq,
"channel": (item.get("channel") or "self")[:12],
"source_label": (item.get("source_label") or "")[:120],
"speaker": (item.get("speaker") or "")[:120],
"start_ms": max(0, int(item.get("start_ms") or 0)),
"end_ms": max(0, int(item.get("end_ms") or 0)),
"text": ("--" if item.get("starts_mid") else "") + text,
"is_final": True,
"starts_mid_utterance": bool(item.get("starts_mid")),
"ends_mid_utterance": bool(item.get("ends_mid")),
})
seq += 1
flat = "\n".join(
f"[{s['start_ms'] // 60000}:{(s['start_ms'] // 1000) % 60:02d}] "
f"{s['speaker'] or s['channel']} : {s['text']}" for s in current
)
if tr.get("transcript"):
flat = (tr["transcript"] + "\n" + flat) if flat else tr["transcript"]
_update(transcript_id, segments_json=json.dumps(current, ensure_ascii=False),
transcript=flat[:200000])
return {"transcript_id": transcript_id, "segments": len(current)}
def set_segment_speaker(transcript_id: int, seq: int, speaker: str,
user_id: int | None = None) -> dict:
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
found = False
for s in segments:
if int(s.get("seq") or 0) == int(seq):
s["speaker"] = (speaker or "").strip()[:120]
s["speaker_corrected_by"] = user_id
found = True
if not found:
raise ValueError("segment not found")
_update(transcript_id, segments_json=json.dumps(segments, ensure_ascii=False))
return {"transcript_id": transcript_id, "seq": int(seq), "speaker": speaker}
def grouped_transcript(transcript_id: int) -> dict:
"""Transcript regroupé par piste source (§3A.7) + rail temporel."""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
groups: dict[str, list] = {}
order: list[str] = []
for s in sorted(segments, key=lambda x: (x.get("seq") or 0)):
key = s.get("source_label") or s.get("channel") or "self"
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(s)
# Repli : transcript texte brut découpé en un groupe unique.
if not groups and (tr.get("transcript") or "").strip():
groups = {"audio": [{"seq": 1, "channel": "self", "source_label": "",
"speaker": "", "start_ms": 0, "end_ms": 0,
"text": tr["transcript"][:4000]}]}
order = ["audio"]
return {"transcript_id": transcript_id,
"groups": [{"source": k, "segments": groups[k]} for k in order]}
# ── Classification de complétude + titre (§12.8) ─────────────────────────────
def classify_completeness(*, text: str, segments: list, notes: str,
duration_ms: int = 0) -> str:
words = len(re.findall(r"\S+", text or ""))
n_seg = len(segments or [])
truncated = any(s.get("starts_mid_utterance") or s.get("ends_mid_utterance")
for s in (segments or []))
has_agenda = bool((notes or "").strip())
if n_seg == 0 or words < 8:
return "fragmentary"
if words < 60 or (duration_ms and duration_ms < 45_000):
return "brief" if not truncated else "incomplete"
if truncated or not has_agenda and words < 150:
return "incomplete"
if words < 150:
return "brief"
return "complete"
def _first_topic(text: str) -> str:
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text or "").strip())
for s in sentences:
s = s.strip(" -–—\"'«»")
if len(s) > 25:
return s[:90].rstrip(" ,;:")
return ""
def generate_title(*, text: str, completeness: str, notes: str = "") -> str:
if completeness in ("brief", "incomplete", "fragmentary"):
topic = _first_topic(text)
if not topic or len(re.findall(r"\S+", text or "")) < 25:
return "Brief or Incomplete Meeting Recording"
return topic[:90]
topic = _first_topic(notes + " " + text)
return topic[:90] or "Meeting notes"
# ── Résumé structuré (§12.4) : JSON contraint + citations ────────────────────
_SUMMARY_SCHEMA_HINT = (
"Réponds STRICTEMENT par un objet JSON : "
'{"overview": ["..."], "decisions": ["..."], "action_items": '
'[{"title": "...", "owner": null, "due_hint": null}], '
'"open_questions": ["..."], "limits": ["..."]}. '
"Chaque puce overview/decisions/action_items DOIT citer le transcript "
"(suffixe entre parenthèses avec un extrait repris mot pour mot). "
"N'invente ni responsable ni échéance : owner/due_hint null si absents. "
"Si le contenu est insuffisant, overview décrit les manques et "
"action_items vaut []."
)
def _cite(text: str, segments: list) -> str:
"""Trouve un segment source pour une affirmation (citation vérifiable)."""
words = [w.lower() for w in re.findall(r"[A-Za-zÀ-ÿa-zà-ÿ']{4,}", text or "")]
best, best_score = None, 0
for s in segments or []:
hay = (s.get("text") or "").lower()
score = sum(1 for w in words[:12] if w in hay)
if score > best_score:
best, best_score = s, score
if best and best_score > 0:
return f" ⁠¹ [seg {best.get('seq')}]"
if segments:
return f" ⁠¹ [seg {segments[0].get('seq')}]"
return " ⁠¹"
def _offline_structured(*, text: str, notes: str, segments: list,
completeness: str) -> dict:
sentences = [s.strip() for s in
re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if s.strip()]
overview: list[str] = []
if completeness == "fragmentary":
overview = [
"The recording captured only a short, fragmentary exchange with no "
f"discernible meeting topic, agenda, or context{_cite(text, segments)}",
"The content appears to be mid-conversation and lacks the beginning "
f"of the discussion{_cite(text, segments)}",
]
if not (notes or "").strip():
overview.append("The user's notes were empty, providing no additional "
"context about the meeting's purpose")
else:
for s in sentences[:4]:
overview.append(f"{s[:220]}{_cite(s, segments)}")
if not overview:
overview = [f"No meaningful content could be extracted{_cite(text, segments)}"]
if completeness in ("brief", "incomplete"):
overview.append(
"This was a brief or incomplete recording — treat this summary "
f"as partial{_cite(text, segments)}")
decisions = []
for s in sentences:
if re.search(r"\b(decid|décid|agreed|approuv|retain|retenu|chosen|choisi)\b", s, re.I):
decisions.append(f"{s[:220]}{_cite(s, segments)}")
actions = []
for s in sentences:
m = re.search(r"\b([A-ZÀ-Þ][a-zà-ÿ'\-]+)\s+(will|va|doit|should|must|s'occupe)\b", s)
if re.search(r"\b(action|todo|to-?do|next step|prochaine étape|faire|envoyer|préparer)\b", s, re.I) or m:
owner = m.group(1) if m and len(m.group(1)) > 2 else None
actions.append({"title": s[:180], "owner": owner,
"due_hint": None, "evidence": _cite(s, segments)})
return {"overview": overview[:6], "decisions": decisions[:8],
"action_items": actions[:10], "open_questions": [],
"limits": [] if completeness == "complete" else overview[-2:]}
def _render_markdown(struct: dict, completeness: str) -> str:
L = ["## Overview", ""]
for b in struct.get("overview") or []:
L.append(f"• {b}")
L += ["", "## Decisions", ""]
if struct.get("decisions"):
for b in struct["decisions"]:
L.append(f"• {b}")
else:
L.append("• No decisions could be identified from the available transcript")
L += ["", "## Action Items", ""]
if struct.get("action_items"):
for a in struct["action_items"]:
if isinstance(a, dict):
owner = f" — owner: {a['owner']}" if a.get("owner") else ""
L.append(f"• {a.get('title', '')}{owner}{a.get('evidence', ' ¹')}")
else:
L.append(f"• {a}")
else:
L.append("• No action items could be identified from the available transcript")
if struct.get("open_questions"):
L += ["", "## Open questions", ""]
for b in struct["open_questions"]:
L.append(f"• {b}")
if completeness in ("brief", "incomplete", "fragmentary"):
L += ["", f"_Summary class: {completeness} — partial recording, verify against the transcript._"]
return "\n".join(L).strip()
def _write_steps(transcript_id: int, states: dict) -> None:
steps = [{"key": k, "label": label,
"state": states.get(k, "pending"),
"at": _now() if states.get(k) in ("running", "done", "failed") else ""}
for k, label in PROCESSING_STEPS]
_update(transcript_id, processing_steps_json=json.dumps(steps, ensure_ascii=False))
async def _llm_structured(*, text: str, notes: str, instruction: str,
user_id: int | None) -> tuple[dict | None, bool, str]:
from app.services.ai_writing import AIWritingService
svc = AIWritingService(user_id=user_id)
if svc._offline_hint():
return None, True, ""
prompt = (
"Rédige le résumé structuré de cette réunion (format AI Meeting Note). "
f"Instructions de l'utilisateur : {instruction or 'Auto'}. "
"Notes humaines (contexte prioritaire, signale tout conflit) : "
f"{(notes or '(vides)')[:2000]}. " + _SUMMARY_SCHEMA_HINT
)
try:
res = await svc.run("summarize", context=text[:18000], prompt=prompt)
except Exception as exc: # noqa: BLE001
logger.warning("meeting LLM summary failed: %s", exc)
return None, False, str(exc)
if not res.get("ok") or not (res.get("text") or "").strip():
return None, bool(res.get("offline")), res.get("error") or "empty"
parsed = AIWritingService._parse_json_object(res["text"])
if not parsed or not isinstance(parsed.get("overview"), list):
# Le modèle a répondu en texte libre : on le conserve en overview sourcé.
return {"overview": [res["text"][:1500]], "decisions": [],
"action_items": [], "open_questions": [], "limits": []}, False, ""
return parsed, False, ""
async def run_processing(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Pipeline §12 : étapes Thinking → classification → résumé → titre.
Jamais de refus sur contenu insuffisant : résumé dégradé + classe +
titre-diagnostic (§12.8). Avec ``allow_empty`` (arrêt sans rien de
capté), un diagnostic honnête est produit au lieu d'une erreur —
l'utilisateur ne perd ni l'enregistrement ni le transcript.
Sans ``allow_empty``, un transcript vide reste une 400 (l'appelant
affiche alors un guide de reprise, pas un dump HTTP).
"""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
notes = tr.get("notes_text") or ""
text = (tr.get("transcript") or "").strip()
snapshot = _loads(tr.get("instruction_snapshot") or "", {})
instruction = (snapshot.get("instruction_raw")
or tr.get("instruction_id") or "auto")
order = [k for k, _ in PROCESSING_STEPS]
states: dict[str, str] = {}
try:
for key in ("reading_transcript", "analyzing_transcript",
"resolving_references", "understanding_content"):
states[key] = "running"
_write_steps(transcript_id, states)
time.sleep(0) # point de cession : étapes réellement exécutées
states[key] = "done"
_write_steps(transcript_id, states)
states["classifying_completeness"] = "running"
_write_steps(transcript_id, states)
completeness = classify_completeness(
text=text, segments=segments, notes=notes,
duration_ms=int(tr.get("duration_ms") or 0))
states["classifying_completeness"] = "done"
_write_steps(transcript_id, states)
if not text:
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
# Arrêt sans rien de capté (micro coupé, aucun mot reconnu) :
# diagnostic honnête au lieu d'une erreur — rien n'est perdu,
# l'utilisateur peut coller un transcript et régénérer.
for key in ("generating_summary", "generating_title",
"validating_citations"):
states[key] = "running"
_write_steps(transcript_id, states)
states[key] = "done"
_write_steps(transcript_id, states)
struct = {
"overview": [
"No audio was captured during this session — no speech "
"reached the transcript. Check that the microphone is "
"allowed for this site and that the correct input is selected.",
("If the call ran in another tab, restart with “Tab + "
"microphone” and tick “Share audio” when choosing the tab."),
],
"decisions": [], "action_items": [], "open_questions": [],
"limits": ["empty session — paste a transcript, then Regenerate"],
}
md = _render_markdown(struct, "fragmentary")
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title="Brief or Incomplete Meeting Recording",
title_source="ai", completeness_class="fragmentary")
return await _finish_processing(transcript_id, offline=True)
states["generating_summary"] = "running"
_write_steps(transcript_id, states)
struct, offline, _err = await _llm_structured(
text=text, notes=notes, instruction=instruction, user_id=user_id)
if struct is None:
struct = _offline_structured(text=text, notes=notes,
segments=segments,
completeness=completeness)
offline = True
else:
# Garantir les citations même sur réponse LLM.
for section in ("overview", "decisions"):
fixed = []
for b in struct.get(section) or []:
s = b if isinstance(b, str) else str(b)
fixed.append(s if "¹" in s or "[seg" in s else f"{s}{_cite(s, segments)}")
struct[section] = fixed
acts = []
for a in struct.get("action_items") or []:
if isinstance(a, dict):
if "evidence" not in a:
a["evidence"] = _cite(a.get("title", ""), segments)
acts.append(a)
else:
acts.append({"title": str(a), "owner": None,
"due_hint": None,
"evidence": _cite(str(a), segments)})
struct["action_items"] = acts
states["generating_summary"] = "done"
_write_steps(transcript_id, states)
states["generating_title"] = "running"
_write_steps(transcript_id, states)
title = generate_title(text=text, completeness=completeness, notes=notes)
states["generating_title"] = "done"
_write_steps(transcript_id, states)
states["validating_citations"] = "running"
_write_steps(transcript_id, states)
seqs = {int(s.get("seq") or 0) for s in segments}
# Toute citation [seg N] pointe un segment réel, sinon repli seg 1.
md = _render_markdown(struct, completeness)
def _fix_seg(m: re.Match) -> str:
try:
n = int(m.group(1))
except (TypeError, ValueError):
return "[seg 1]"
return m.group(0) if n in seqs else "[seg 1]"
md = re.sub(r"\[seg (\d+)\]", _fix_seg, md)
states["validating_citations"] = "done"
_write_steps(transcript_id, states)
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title=title, title_source="ai",
completeness_class=completeness)
except Exception as exc: # noqa: BLE001 — état failed récupérable, segments conservés
for k in order:
states.setdefault(k, "pending")
# Marquer l'étape courante en échec.
for k in order:
if states.get(k) == "running":
states[k] = "failed"
break
_write_steps(transcript_id, states)
# Toujours sortir de « processing » : une ligne coincée est pire
# qu'une erreur affichée (l'utilisateur peut réessayer).
_update(transcript_id, status="failed")
msg = str(exc) or "processing failed"
if "transcript is empty" in msg:
raise ValueError(msg) from exc
logger.warning("meeting processing %s failed: %s", transcript_id, exc)
raise RuntimeError(msg) from exc
return await _finish_processing(transcript_id, offline)
async def _finish_processing(transcript_id: int, offline: bool) -> dict:
"""Événement + réponse du pipeline (chemin nominal et diagnostic)."""
out = get_transcript(transcript_id)
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": out["page_id"], "transcript_id": transcript_id,
"language": out.get("language") or "fr",
"completeness": out.get("completeness_class") or "",
})
except Exception as exc: # noqa: BLE001
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": out["summary"],
"completeness": out.get("completeness_class") or "",
"generated_title": out.get("generated_title") or "",
"offline": bool(offline)}
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text)."""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
Returns {transcript_id, summary, offline}. Emits the automation event so
custom agents (update tracker, post recap, file tickets) trigger.
"""
from app.services.ai_writing import AIWritingService
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Point d'entrée historique (v7.1.0) — bascule sur le pipeline Notion."""
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "paused"):
raise ValueError("stop the recording before generating the summary")
if not ((tr.get("transcript") or "").strip()
or _loads(tr.get("segments_json") or "[]", [])):
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
svc = AIWritingService(user_id=user_id)
res = await svc.run("summarize", context=tr["transcript"])
summary = (res.get("text") or "").strip() if res.get("ok") else ""
if not summary:
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
if tr.get("status") not in ("processing", "done"):
_update(transcript_id, status="processing")
out = await run_processing(transcript_id, user_id=user_id,
allow_empty=allow_empty)
return {"transcript_id": transcript_id, "summary": out["summary"],
"offline": out["offline"]}
# ── Partage tracé (§3A.5 : Copy link / Email / Slack) ─────────────────────────
def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"):
raise ValueError("channel must be copy_link|email|slack")
get_transcript(transcript_id)
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
(summary, transcript_id))
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
cur = conn.execute(
"""INSERT INTO meeting_distributions
(transcript_id, channel, actor_id, recipients_json, status)
VALUES (?,?,?,?,?)""",
(transcript_id, channel, actor_id, recipients or "", status),
)
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
return {"id": cur.lastrowid, "transcript_id": transcript_id,
"channel": channel, "status": status}
+14 -6
View File
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
Canonical (comportement du front) : `is_published` + `published` +
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
regardait qu'une colonne, donc l'état publié ne « tenait » nulle part).
`share_mode`/`is_shared` restent la propriété du share dialog
(`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel.
Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques
issues d'un seul chemin).
"""
from __future__ import annotations
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
# v7.69.6 : les deux drapeaux sont maintenus ensemble — le share
# dialog écrit ``published``, les routes /publish écrivaient seulement
# ``is_published``, et chaque lecteur ne regardait qu'une colonne :
# l'état publié ne « tenait » nulle part.
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
"UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
@@ -71,7 +79,7 @@ def unpublish(page_id: int) -> None:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
"UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
+10
View File
@@ -181,6 +181,16 @@ document.addEventListener('alpine:init', function () {
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
});
});
// Navigation in-place (ex. /local-workspace qui descend dans ses
// dossiers sans recharger) : le chemin du header est recalé sur la
// pile de dossiers courante. Même contrat que le rendu serveur.
window.addEventListener('flowdeck:breadcrumb-changed', function (e) {
var d = e && e.detail;
if (!d || !d.crumbs || !d.crumbs.length) return;
self.crumbs = d.crumbs.map(function (c, i) { c.origIndex = i; return c; });
if (d.workspace_id) self.wsId = d.workspace_id;
self.closeAll();
});
},
get display() {
+6
View File
@@ -1,4 +1,10 @@
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }}
</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/page_editor_scripts.js?v={{ asset_version }}"></script>
<link rel="stylesheet" href="/static/css/meeting.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/meeting_block.js?v={{ asset_version }}"></script>
+220 -24
View File
@@ -361,13 +361,28 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
<button class="section-action-btn" title="New meeting note" @click.stop="fdCreateMeeting()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span><span class="page-name text-dim">No meetings</span></li></ul>
<ul class="sidebar-items" id="sidebar-meetings-items">
<template x-for="n in meetingNotes" :key="'mn-' + n.id">
<li class="sidebar-item" @click="meetingNoteOpen(n)"
@contextmenu.prevent="showWsContext($event, n.id, n.title, false)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="n.title"></span>
<span class="sb-item-sub" x-text="n.has_summary ? 'Summary ready' : (n.status && n.status !== 'idle' ? n.status : 'Ready to record')"></span>
</span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingNotes.length">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span>
<span class="page-name text-dim">No meetings</span>
</li>
</ul>
</div>
</div>
@@ -662,6 +677,10 @@
<div class="sb-filter-row">
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'past' }" @click="meetingFilter = 'past'; loadMeetingHistory()">Past</button>
</div>
<div class="sb-search-row">
<input type="search" class="sb-search-input" placeholder="Search meetings…" x-model="meetingSearch" @input="if (meetingFilter === 'past') loadMeetingHistory()" aria-label="Search meetings">
</div>
<div class="sb-quick-actions">
@@ -671,15 +690,18 @@
</div>
<ul class="sidebar-items">
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
<template x-for="ev in meetingVisible" :key="(ev.collection_id || 'h') + '-' + ev.id">
<li class="sidebar-item" @click="meetingOpen(ev)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span class="page-name" x-text="ev.title"></span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="ev.title"></span>
<span class="sb-item-sub" x-text="meetingCta(ev)"></span>
</span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : (ev.date || '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
<span class="page-name text-dim" x-text="meetingEmptyHint()"></span>
</li>
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
<span class="page-name text-dim">Loading…</span>
@@ -719,18 +741,35 @@
</ul>
</div>
<!-- Footer -->
<!-- Footer : bouton "New" + popover "Create new" (vers le haut) -->
<div class="sidebar-footer">
<div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span>
<div class="fd-new-wrap" @click.outside="closeNewMenu()">
<div class="fd-new-popover" x-show="newMenuOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
role="menu" aria-label="Create new">
<div class="fd-new-pop-header">Create new</div>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
{% if has_active_workspace %}
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" onclick="window.FlowDeck.createPage()" title="New page">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('meeting')">
<span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg></span><span>AI Meeting Notes</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('database')">
<span>{{ fd_icon("grid",16) }}</span><span>Database</span>
</button>
</div>
<button type="button" class="fd-new-btn" :class="{ open: newMenuOpen }"
@click="toggleNewMenu()" :aria-expanded="newMenuOpen" title="Create new">
<span>{{ fd_icon("edit",16) }}</span><span>New</span>
</button>
{% endif %}
</div>
</div>
</div><!-- /sidebar-scroll -->
@@ -899,6 +938,9 @@
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('open')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open
</div>
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
@@ -1294,6 +1336,8 @@
this.loadTeamspaces();
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
this.loadInbox();
// v7.1.1 : notes de réunion (section Meetings, pas Workspace).
this.loadMeetingNotes();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1310,7 +1354,7 @@
});
document.addEventListener('flowdeck:workspace-changed', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
self._clipRefreshTimer = setTimeout(() => { self.refreshSidebarTree(); self.loadMeetingNotes(); }, 400);
});
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
document.addEventListener('visibilitychange', () => {
@@ -1451,15 +1495,37 @@
// ── Meeting tab ──
meetings: [],
meetingPast: [],
meetingsLoading: false,
meetingFilter: 'upcoming',
meetingSearch: '',
meetingHistoryLoaded: false,
get meetingVisible() {
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today; });
var q = (this.meetingSearch || '').trim().toLowerCase();
function matches(e) {
if (!q) return true;
return ((e.title || '') + ' ' + (e.date || '')).toLowerCase().indexOf(q) >= 0;
}
return this.meetings.filter(function(e) { return !e.today; });
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today && matches(e); });
}
if (this.meetingFilter === 'past') {
return this.meetingPast.filter(matches);
}
return this.meetings.filter(function(e) { return !e.today && matches(e); });
},
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
meetingEmptyHint() {
if (this.meetingFilter === 'today') return 'Nothing scheduled today';
if (this.meetingFilter === 'past') return 'No past meeting notes yet';
return 'No upcoming meetings';
},
meetingCta(ev) {
if (ev.status === 'done' || ev.summary) return 'View summary';
if (ev.today) return 'Join & transcribe';
if (ev.page_id) return 'Open note';
return 'Prepare';
},
loadMeetings(force) {
var self = this;
if (!force && this.meetings.length) return Promise.resolve();
@@ -1472,8 +1538,38 @@
})
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
},
loadMeetingHistory() {
var self = this;
this.meetingsLoading = true;
var q = encodeURIComponent((this.meetingSearch || '').trim());
return fetch('/api/v2/meetings/history?query=' + q + '&limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { meetings: [] }; })
.then(function(d) {
self.meetingPast = (d && d.meetings) || [];
self.meetingHistoryLoaded = true;
self.meetingsLoading = false;
})
.catch(function() { self.meetingPast = []; self.meetingsLoading = false; });
},
meetingOpen(ev) {
var target = ev.url || ('/db/' + ev.collection_id);
var target = ev.url || (ev.page_id ? ('/pages/' + ev.page_id) : ('/db/' + ev.collection_id));
if (ev.today) {
try { this.toast('Opening today\u2019s meeting — press Start transcribing in the note.'); } catch (e) {}
}
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
// ── v7.1.1 : notes avec bloc AI Meeting Notes → section Meetings ──
meetingNotes: [],
loadMeetingNotes() {
var self = this;
return fetch('/api/v2/meetings/notes?limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { notes: [] }; })
.then(function(d) { self.meetingNotes = (d && d.notes) || []; })
.catch(function() { self.meetingNotes = []; });
},
meetingNoteOpen(n) {
var target = '/pages/' + n.id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
@@ -1614,6 +1710,15 @@
this.wsCtx.visible = false;
if (!id) return;
switch(action) {
case 'open':
if (this.wsCtx.isFolder) {
if (typeof this.navigateToFolder === 'function') this.navigateToFolder(id);
} else {
var target = '/pages/' + id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
}
break;
case 'new-file':
this.newPageInFolder(id);
break;
@@ -1632,6 +1737,7 @@
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
@@ -1644,6 +1750,7 @@
if (!id) break;
if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id, true);
if (typeof this.loadMeetingNotes === 'function') this.loadMeetingNotes();
break;
}
},
@@ -1685,20 +1792,58 @@
});
},
// ── Sections collapsible ──
// ── Sections collapsible (accordéon v7.70 : une seule ouverte à la fois) ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
var def = { recents: false, private: false, meetings: false, favorites: false, agents: false, teamspaces: false, shared: false, published: false, apps: false, workspace: true, gitea: false };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
var merged = Object.assign({}, def, saved);
// Normalise : si plusieurs sections ouvertes (ancien état), ne garder que la première.
var first = null;
for (var i = 0; i < order.length; i++) {
if (merged[order[i]]) { first = order[i]; break; }
}
if (first) {
for (var j = 0; j < order.length; j++) merged[order[j]] = (order[j] === first);
} else {
merged.workspace = true;
}
return merged;
} catch(e) { return def; }
})(),
toggleSection(key) {
this.sectionsOpen[key] = !this.sectionsOpen[key];
var isOpen = !!this.sectionsOpen[key];
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
if (isOpen) {
this.sectionsOpen[key] = false;
} else {
for (var i = 0; i < order.length; i++) this.sectionsOpen[order[i]] = false;
this.sectionsOpen[key] = true;
}
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Menu "New" (popover "Create new" vers le haut) ──
newMenuOpen: false,
toggleNewMenu() { this.newMenuOpen = !this.newMenuOpen; },
closeNewMenu() { this.newMenuOpen = false; },
newMenuAction(kind) {
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
} else if (kind === 'meeting') {
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (typeof this.createDatabase === 'function') this.createDatabase();
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
@@ -1933,6 +2078,50 @@
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateMeeting() {
// v7.1.1 : crée une note de réunion vide avec son bloc
// AI Meeting Notes déjà inséré (même action que +, New, /meet).
var self = this;
var iso = new Date().toISOString().slice(0, 10);
var title = 'Meeting @' + iso;
fetch('/api/local-workspace/items', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: title, type: 'page' })
})
.then(function(r) { if (!r.ok) throw new Error('page creation (' + r.status + ')'); return r.json(); })
.then(function(page) {
var block = {
id: 'b1_' + Date.now().toString(36), type: 'meeting', content: '',
meeting: {
v: 2, title: title, date: iso, state: 'idle', mode: 'mic_only',
instruction: 'auto', lang: 'fr', consent: null, consentAudio: false,
tab: 'notes', channels: [], segments: [], segSeq: 0, notes: '',
manual: '', interim: '', startedAt: 0, elapsedBase: 0, pausedMs: 0,
steps: [], summaryMd: '', completeness: '', genTitle: '',
titleEdited: false, shareHidden: false, bannerHidden: false,
transcriptId: null, error: '', remoteSilentWarned: false,
menuOpen: false, settingsOpen: false
}
};
// Amorçage du bloc : en cas d'échec la page s'ouvre quand même
// (l'utilisateur ajoute le bloc via /meet).
return fetch('/board/api/pages/' + page.id + '/blocks', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify({ title: title, blocks: [block] })
}).then(function() { return page; }).catch(function() { return page; });
})
.then(function(page) {
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
if (typeof self.refreshSidebarTree === 'function') self.refreshSidebarTree();
if (window.fdNavigate) window.fdNavigate('/pages/' + page.id);
else window.location.href = '/pages/' + page.id;
})
.catch(function(err) { self.toast('Meeting note creation failed: ' + err.message, 'error'); });
},
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
@@ -2296,6 +2485,12 @@
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
// Le HTML injecté porte des directives Alpine (@contextmenu,
// x-show des dossiers, drag&drop) : les monter explicitement,
// sinon l'arbre perd menu contextuel et toggles après refresh.
try {
if (window.Alpine && window.Alpine.initTree) window.Alpine.initTree(ul);
} catch (e) { console.error('Sidebar Alpine remount failed:', e); }
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
@@ -2526,6 +2721,7 @@
if (data.contextMenu) data.contextMenu.visible = false;
if (data.wsCtx) data.wsCtx.visible = false;
if (data.showNewPageMenu) data.showNewPageMenu = false;
if (data.newMenuOpen) data.newMenuOpen = false;
}
}
else if (e.ctrlKey && e.key === 's') {
+10 -5
View File
@@ -11,9 +11,11 @@
{% endblock %}
{% block content %}
<!-- Prism.js syntax highlighting -->
<link rel="stylesheet" href="/static/css/prism.css">
<script src="/static/js/prism.min.js"></script>
<!-- highlight.js syntax highlighting (~230 file types via FDHighlight helper) -->
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<style>
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
.gw-content{padding:24px;overflow-y:auto;flex:1;}
@@ -31,7 +33,7 @@
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.gw-commit-bar input:focus{border-color:var(--accent);}
/* Prism theme tweaks for dark UI */
/* highlight.js theme tweaks for dark UI */
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
@@ -62,7 +64,10 @@
<div class="skeleton" style="height:15px;width:50%;"></div>
</div>
<!-- Syntax-highlighted code -->
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
</div>
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
+37 -8
View File
@@ -60,20 +60,38 @@
/* ── Table (div-based for hover effects) ── */
.lib-table{width:100%;}
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths — Notion-style */
.lib-col-name{flex:1;min-width:0;}
.lib-col-author{width:150px;flex-shrink:0;display:none;}
.lib-col-source{width:120px;flex-shrink:0;display:none;}
.lib-col-author{width:150px;flex-shrink:0;}
.lib-col-editedby{width:150px;flex-shrink:0;}
.lib-col-source{width:120px;flex-shrink:0;}
.lib-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
.lib-table.show-author .lib-col-author{display:flex;}
.lib-table.show-source .lib-col-source{display:flex;}
.lib-col-visited{width:130px;flex-shrink:0;text-align:right;justify-content:flex-end;}
/* Visibilité des colonnes pilotée par Show columns. Ces règles doivent
battre .lib-table-header .th et .lib-row .row-cell (spécificité 0,2,0) :
avant, seul :not(.show-editedtime) (0,3,0) gagnait, donc seule Last
edited time s'affichait/masquait — les autres options semblaient mortes. */
.lib-table div.lib-col-author,
.lib-table div.lib-col-editedby,
.lib-table div.lib-col-source,
.lib-table div.lib-col-visited{display:none;}
.lib-table.show-author div.lib-col-author,
.lib-table.show-editedby div.lib-col-editedby,
.lib-table.show-source div.lib-col-source,
.lib-table.show-visited div.lib-col-visited{display:flex;}
.lib-table:not(.show-editedtime) div.lib-col-edited{display:none;}
.row-source{display:inline-flex;align-items:center;gap:5px;}
.row-source svg{width:13px;height:13px;flex-shrink:0;color:var(--text-dim);}
/* ── Row ── */
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
.lib-row:hover{background:var(--bg-hover);}
/* ── Date section headers (AI Meeting Notes tab) ── */
.lib-section-header{display:flex;align-items:center;font-size:12px;font-weight:600;color:var(--text-secondary);padding:10px 8px 4px;user-select:none;}
.lib-section-header:first-child{padding-top:2px;}
.lib-row.selected{background:rgba(35,131,226,0.08);}
.lib-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.lib-row.dragging{opacity:0.4;}
@@ -189,7 +207,7 @@
/* ── Responsive ── */
@media(max-width:768px){
.lib-col-author,.lib-col-source{display:none!important;}
.lib-col-author,.lib-col-editedby,.lib-col-source,.lib-col-visited{display:none!important;}
.peek-overlay{width:100vw;max-width:100vw;}
}
</style>
@@ -212,6 +230,9 @@
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg> Recents
</button>
<button class="lib-tab" :class="{ active: tab === 'meetings' }" @click="switchTab('meetings')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> AI Meeting Notes
</button>
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Favorites
</button>
@@ -265,6 +286,9 @@
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
<div class="dd-item" @click="toggleColumn('author')"><span class="check" x-text="showAuthor ? '✓' : ''"></span> Created by</div>
<div class="dd-item" @click="toggleColumn('editedby')"><span class="check" x-text="showEditedBy ? '✓' : ''"></span> Last edited by</div>
<div class="dd-item" @click="toggleColumn('editedtime')"><span class="check" x-text="showEditedTime ? '✓' : ''"></span> Last edited time</div>
<div class="dd-item" @click="toggleColumn('visited')"><span class="check" x-text="showVisited ? '✓' : ''"></span> Last visited time</div>
<div class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
</div>
</div>
@@ -351,7 +375,7 @@
</div>
<!-- ── Table ── -->
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-editedby': showEditedBy, 'show-editedtime': showEditedTime, 'show-visited': showVisited, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
@@ -359,11 +383,16 @@
Page name
</div>
<div class="th lib-col-author">Created by</div>
<div class="th lib-col-source">Source</div>
<div class="th lib-col-editedby">Last edited by</div>
<div class="th lib-col-edited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg>
Last edited time
</div>
<div class="th lib-col-visited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Last visited time
</div>
<div class="th lib-col-source">Source</div>
</div>
<div class="lib-table-body" id="lib-table-body">
</div>
+16 -5
View File
@@ -166,9 +166,12 @@
.bc-edit-go:hover{opacity:.85;}
/* ── Preserved styles ── */
/* NOTE : scopées à .ws-tree (arbre principal) — la variante globale
écrasait aussi les items .ws-tree-item de la sidebar (macro partagée),
d'où un espacement différent sur /local-workspace. */
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
@@ -179,6 +182,8 @@
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
.ws-tree-item .name.file .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
.ws-fav-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;color:#f5a623;}
.ws-pub-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;}
.ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;}
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
@@ -480,7 +485,11 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id, "workspace_name": workspace_name} | tojson }}</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
@@ -528,7 +537,6 @@
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
{% if breadcrumbs %}
<div class="unified-breadcrumb">
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
@@ -549,7 +557,6 @@
</div>
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
</div>
{% endif %}
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
@@ -729,6 +736,8 @@
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span>
@@ -743,6 +752,8 @@
@mouseleave="hidePreview()"
title="Click to rename, double-click to open">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
+9
View File
@@ -173,8 +173,17 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
// hljs — syntax-highlight code blocks (~230 file types via FDHighlight).
try {
if (window.FDHighlight) FDHighlight.highlightAllIn(document.querySelector('.pub-content') || document);
else if (window.hljs) document.querySelectorAll('.pub-content pre code').forEach(function (el) { try { window.hljs.highlightElement(el); } catch (e) {} });
} catch (e) {}
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';
+333
View File
@@ -0,0 +1,333 @@
# FlowDeck — Modèle de données (v7.69.8)
Document de référence du schéma de persistance, généré à partir du code réel :
- `app/db.py` — schéma de base (baseline, « version 1 »), créé de façon idempotente par `init_db()` (lignes 14–839) et connexion SQLite via `get_conn()` (lignes 843–858) ;
- `app/migrations.py` — migrations versionnées (1–38, table `schema_version`) ;
- `app/services/webhook_outbound.py` — table `webhook_subscriptions` (créée au boot, l. 272–286) ;
- `app/models/requests.py` / `app/models/responses.py` — couche Pydantic de l'API.
Total : **105 tables relationnelles + 1 table virtuelle FTS5** (`pages_fts`), réparties entre le baseline (`app/db.py`) et 33 migrations effectuant des créations de tables (`app/migrations.py`).
---
## 1. Moteur de base de données
### 1.1 SQLite uniquement (pas de dual-engine)
**Correction vis-à-vis de certaines notes d'architecture : FlowDeck ne supporte PAS PostgreSQL.** Le code est exclusivement SQLite :
- `app/db.py` l.4 : `import sqlite3` (stdlib) — aucune dépendance `psycopg`/`pg8000`/SQLAlchemy nulle part (`requirements.txt`, `pyproject.toml` vérifiés) ;
- le module entier est typé `sqlite3.Connection` (y compris `app/migrations.py` l.16) ;
- fonctionnalités SQLite spécifiques utilisées : `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000` (`get_conn`, l.846–850), `INTEGER PRIMARY KEY AUTOINCREMENT`, `ALTER TABLE … ADD COLUMN` (sans `IF NOT EXISTS`, d'où le pattern `try/except sqlite3.OperationalError`), déclencheurs (`TRIGGER`), extension **FTS5**, `DROP TABLE`/`RENAME` pour les reconstructions (`comments`, `tags`) ;
- `app/config.py` l.58 : `database_url: str = "sqlite:////data/flowdeck.db"` — seul le préfixe `sqlite:///` est interprété par la propriété `db_path` (l.151–163, avec gestion `:memory:` et des chemins Windows type `C:\…`) ; toute autre valeur retombe sur `/data/flowdeck.db`.
La « compatibilité » mentionnée dans certains documents est donc en réalité : une **base SQLite unique par instance**, en mode WAL (un writer, multi-readers), avec `PRAGMA busy_timeout=5000` pour supporter la concurrence (tests xdist, scheduler d'automations). Les backups (v5.2.0, `backup_*` dans `app/config.py`) sont des copies du fichier SQLite.
### 1.2 Stratégie de migrations
Décrite dans l'en-tête de `app/migrations.py` (l.1–13) :
| Composant | Rôle |
|---|---|
| `app/db.py::init_db()` | Schéma **baseline** (« version 1 ») : `CREATE TABLE IF NOT EXISTS` + ALTER garde-fous (`try/except OperationalError`). Réexécuté à chaque boot, idempotent. |
| `schema_version` (`version INTEGER PK, name TEXT, applied_at`) | Registre des migrations appliquées ; version maximale lue par `current_version()` (l.66–71). Scellée à `BASELINE_VERSION = 1` au premier démarrage (l.86–101). |
| `@register(version, name)` (l.28–38) | Décorateur d'enregistrement dans `MIGRATIONS`, trié par version, doublons refusés. |
| `apply_migrations(conn)` (l.86–111) | Appelée en fin de `init_db()` (`app/db.py` l.829–831) ; applique exactement une fois chaque migration `> version courante`. |
| `_apply_one()` (l.113–130) | **Une migration = une transaction** (`BEGIN` explicite, `rollback` complet à l'échec, puis `INSERT INTO schema_version`) — DDL tout-ou-rien (audit A31). |
| `columns(conn, table)` (l.53–64) | Helper unique `PRAGMA table_info` pour tester l'existence de colonnes avant un ALTER. |
| `fts5_available()` (l.74–83) | Détection de FTS5 ; sinon repli sur recherche `LIKE`. |
Version de schéma actuelle : **38** (`v7.1.1 meeting block`, dernier `@register`). Les migrations 4 et 17 et 23–38 ont été enregistrées hors ordre chronologique de version (versions 4, 17, 23 insérées après 21) — le tri à l'enregistrement garantit l'ordre d'application.
---
## 2. Tables par domaine
Légende : **PK** = clé primaire, **FK** = clé étrangère (avec cascade quand indiquée). Sauf mention, PK = `id INTEGER PK AUTOINCREMENT`.
### 2.1 Kanban & intégration Gitea (héritage v0.x–v1.x)
Créées dans `app/db.py` (l.22–140, 430–478).
| Table | But | Clés / colonnes clés |
|---|---|---|
| `boards` | Board Kanban par dépôt Gitea | `UNIQUE(project_owner, project_name)` ; `columns_json` (liste des colonnes), `wip_limits_json` |
| `cards` | Position d'une issue Gitea dans une colonne | FK `board_id → boards` ; `gitea_issue_id`, `column_name`, `position`, `priority`, `due_date` |
| `col_mapping` | Mapping colonne ↔ label Gitea | FK `board_id → boards` ; `UNIQUE(board_id, column_name)` ; `close_issue` |
| `notes` | Notes par projet | `UNIQUE(project_owner, project_name, title)` ; `content` |
| `checklists` | Checklists par issue | `board_id`, `gitea_issue_id` (pas de FK déclarée), `title`, `position`, `UNIQUE(board_id, gitea_issue_id, title)` |
| `checklist_items` | Lignes de checklist | FK `checklist_id → checklists ON DELETE CASCADE` ; `content`, `checked`, `position` |
| `project_properties` | Propriétés par projet (ancêtre de `collection_properties`, conservé) | `UNIQUE(project_owner, project_name, name)` ; `prop_type`, `options_json` |
| `property_values` | Valeurs de ces propriétés par issue | FK `property_id → project_properties ON DELETE CASCADE` ; `UNIQUE(property_id, gitea_issue_id)` |
| `ai_keywords` | Fréquentité des mots-clés détectés par l'IA | `UNIQUE(project_owner, project_name, keyword)` ; `color`, `usage_count` |
| `gitea_private_pages` | Pages privées rattachées à un dépôt Gitea (v2.7) | FK `user_id → users` ; `gitea_owner`, `gitea_repo`, `content` |
| `user_tokens` | Token Gitea par utilisateur | `gitea_user_id INTEGER UNIQUE`, `gitea_token` |
| `projects` | Registre forge-agnostique des projets (gitea/github/builtin), synchronisé par cron (mig. 6, `migrations.py` l.260–331) | `UNIQUE(proj_type, owner, name)` ; `clone_url`, `default_branch`, `last_synced_at` |
### 2.2 Utilisateurs & authentification
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `users` | Compte local ou fédéré | `login UNIQUE`, `email`, `password_hash`, `is_admin`, `is_active`, `login_attempts`, `locked_until`, `auth_method` (local\|gitea), `avatar_url/color`, `sidebar_config` (JSON), `notification_prefs` (JSON), `timezone`, `totp_secret_enc`, `totp_backup_hashes` (JSON) | db.py l.22 ; ALTER db.py l.328–470, mig. 10/11/28 |
| `login_history` | Journal des connexions | FK `user_id → users` ; `ip_address`, `user_agent` | db.py l.40 |
| `user_oauth_tokens` | Tokens OAuth par provider (Gitea, Google, M365…) | FK `user_id → users` ; `UNIQUE(user_id, provider)` ; `access_token`, `refresh_token`, `expires_at` | db.py l.55 |
| `user_sessions` | Sessions cookie révocables (une ligne = un cookie signé) | **PK `id TEXT`** (payload du cookie) ; FK `user_id → users ON DELETE CASCADE` ; `revoked`, `last_seen_at` | mig. 6 (l.294–316) |
| `api_tokens` | Tokens Bearer publics (sha256 stocké) | FK `user_id → users ON DELETE CASCADE` ; `token_hash UNIQUE`, `token_prefix`, `scopes`, `expires_at`, `revoked` | mig. 6 + mig. 20 (l.884–900) |
| `webauthn_credentials` | Passkeys / 2FA WebAuthn | FK `user_id → users ON DELETE CASCADE` ; `credential_id UNIQUE`, `public_key`, `sign_count` | mig. 28 (l.1311–1324) |
| `sso_config` | Config SSO entreprise (SAML 2.0 ou OIDC), la table prime sur `.env` dès qu'un admin sauvegarde | FK `workspace_id → workspaces` ; secrets chiffrés au repos (`client_secret`, `sp_private_key` — `app/services/sso_provisioning.py`) ; `attribute_mapping`/`groups_mapping` (JSON), `active` | mig. 23 (l.1495–1523) |
| `sso_login_history` | Audit de chaque tentative SSO (succès + rejets) | FK `user_id → users` ; `success`, `error_message`, `sso_identifier` | mig. 23 (l.1525–1545) |
| `sso_requests` | Store mono-usage anti-replay (AuthnRequest ID, état OIDC, PKCE, relay state) | **PK `id TEXT`** ; `kind`, `code_verifier`, `next_path`, `used` | mig. 23 (l.1547–1555) |
| `scim_tokens` | Tokens Bearer de provisioning SCIM (hash unique) | `token_hash UNIQUE`, `revoked`, FK `created_by → users` | mig. 28 (l.1285–1294) |
| `domain_claims` | Domaines d'entreprise vérifiés (DNS/txt), auto-jonction + `enforce_sso` | `domain UNIQUE`, `txt_token`, FK `workspace_id → workspaces` | mig. 28 (l.1296–1307) |
| `llm_config` | Config LLM globale de l'instance — **ligne unique** `CHECK (id = 1)` | `provider`, `model`, `api_key`, `api_base`, `verified`, `verified_model`, `last_error` ; précédence : ligne DB > `settings.llm_*` | db.py l.769–784 |
| `user_llm_keys` | Clés API LLM par utilisateur et provider | FK `user_id → users ON DELETE CASCADE` ; `UNIQUE(user_id, provider)` ; `models_json` (cache des modèles), `default_model`, `verified*` | db.py l.788–812 |
### 2.3 Workspaces, collaboration & permissions
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `workspaces` | Espace de travail multi-utilisateur | FK `owner_id → users` ; `settings_json` | db.py l.222 |
| `workspace_members` | Adhésion + rôle (`owner/admin/editor/viewer/commenter`) | FK `workspace_id → workspaces CASCADE`, `user_id → users` ; `UNIQUE(workspace_id, user_id)` | db.py l.230 |
| `teamspaces` | Namespace de pages/bases dans un workspace (v7.3.0) ; `private=1` = invisibilité 404 | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 29 (l.1380–1391) |
| `teamspace_members` | Membres + rôle d'un teamspace | FK `teamspace_id → teamspaces CASCADE`, `user_id → users` ; `UNIQUE(teamspace_id, user_id)` | mig. 29 (l.1393–1403) |
| `user_groups` | Groupes réutilisables par workspace (ACL) | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 18 (l.721–730) |
| `group_members` | Adhésion à un groupe | FK `group_id → user_groups CASCADE`, `user_id → users CASCADE` ; `UNIQUE(group_id, user_id)` | mig. 18 (l.732–740) |
| `page_permissions` | Grant explicite sur une page de l'éditeur | FK `page_id → pages CASCADE` ; `user_id XOR group_id` (`CHECK`) ; `role` (viewer/commenter/editor/owner), `grant_type`, `granted_by` | mig. 18 (l.745–760) |
| `collection_permissions` | Idem pour les bases (collections) | FK `collection_id → collections CASCADE` ; mêmes colonnes | mig. 18 (l.766–780) |
| `property_permissions` | Grant par propriété de collection (view/edit) | FK `collection_id`, `property_id → collection_properties CASCADE` | mig. 18 (l.782–797) |
| `permission_audit_log` | Traînée immuable grant/revoke | `resource_type`, `resource_id`, `action`, `old_role`, `new_role`, `performed_by → users`, `ip_address` | mig. 18 (l.799–815) |
| `page_shares` | Partage de page (user, groupe ou email) | FK `page_id → pages CASCADE`, `shared_with_user_id → users`, `shared_with_group_id → user_groups` ; `permission` (view/edit/comment) | db.py l.451–478 |
| `guest_shares` | Accès sans compte via `/g/<token>` (v7.3.0) | FK `page_id → pages CASCADE` ; `token UNIQUE`, `role`, `expires_at`, `revoked` | mig. 29 (l.1440–1452) |
| `favorites` | Favoris d'un utilisateur (reconstruite en v2.2 pour pointer `pages`) | FK `user_id → users`, `page_id → pages` ; `UNIQUE(user_id, page_id)` | db.py l.260–270 |
| `recents` | Pages récemment accédées (vue « Recents ») | FK `user_id → users`, `page_id → pages` ; `source_type` (local/gitea…), `UNIQUE(user_id, page_id)` | db.py l.481–493 |
| `notifications` | Notifications in-app (mention/commentaire/page) | FK `user_id → users CASCADE`, `actor_id → users` ; `ntype`, `resource_type/resource_id`, `is_read` ; index `(user_id, is_read)` | db.py l.592–613 |
| `comments` | Commentaires (fil, résolus) ancrés sur pages ou lignes de base — **reconstruite** v4.9 : cible polymorphe | FK `user_id → users` ; `target_type` (`page` \| `collection_page`) + `target_id`, `parent_id` (réponses), `anchor_block_id/anchor_start/anchor_end` (ancre inline) | db.py l.241 (v2.0) puis l.620–649 (v4.9) |
| `comment_reactions` | Réactions emoji d'un commentaire | FK `comment_id → comments CASCADE`, `user_id → users CASCADE` ; `UNIQUE(comment_id, user_id, emoji)` | mig. 29 (l.1418–1428) |
| `text_reactions` | Réactions ancrées sur une plage de texte d'un bloc (façon Notion, v7.64) | FK `page_id → pages CASCADE`, `user_id → users CASCADE` ; `block_id TEXT`, offsets `anchor_start/anchor_end`, `UNIQUE(page_id, block_id, start, end, emoji, user_id)` | mig. 37 (l.1683–1707) |
| `page_follows` | Abonnement aux changements d'une page | PK composite `(page_id → pages CASCADE, user_id → users CASCADE)` | mig. 29 (l.1430–1437) |
| `page_verifications` | Badge « page vérifiée ✅ » avec expiration | **PK `page_id → pages CASCADE`** (1 par page) ; `verified_by → users`, `expires_at` | mig. 29 (l.1407–1416) |
| `tags` | Étiquettes par utilisateur (`user_id=0` = globales) | FK `user_id → users` ; `UNIQUE(name, user_id)` ; `color` | db.py l.282–290 |
| `page_tags` | Association page ↔ tag | PK composite `(page_id → pages CASCADE, tag_id → tags CASCADE)` | db.py l.292–299 |
| `custom_emojis` | Emojis personnalisés uploadés par workspace | FK `workspace_id` (défaut 1, **non déclaré**) ; `name`, `url` | mig. 8 (l.371–388) |
### 2.4 Pages & éditeur de blocs
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `pages` | Page de l'éditeur (wiki) — table pivot du produit | FK `workspace_id → workspaces`, `parent_id → pages` (hiérarchie), `collection_id → collections` (base plein écran, v4.6), `collection_row_id → collection_pages CASCADE` (page-ombre de contenu d'une ligne de base, v6.5, mig. 22 l.997–1020), `locked_by → users` ; colonnes notables : `content` (markdown legacy **ou JSON de blocs** selon `content_format`), `content_format`, `parent_section`, `share_mode`, `published`/`is_published`/`publish_slug`/`is_shared`, `deleted_at` (corbeille), `sort_order`, `cover_url`, `page_icon`, `is_locked`, `full_width`, `font_small`, `search_excluded`, `permission_type` (inherit/restricted/private), `teamspace_id` (ALTER, **sans FK déclarée**), `sync_version` (trigger de concurrence optimiste, mig. 17) | db.py l.154–166 + ALTER l.328–470 ; mig. 7/13/18/22/24/25/29 |
| `page_versions` | Snapshots annulables de l'éditeur de blocs (undo/restore, une ligne par save) | FK `page_id → pages CASCADE` ; `title`, `blocks_json` (liste complète des blocs), `note`, `user_id → users` | mig. 7 (l.336–365) |
| `page_history` | Historique legacy des **lignes de base** (`collection_pages`) | FK `page_id → collection_pages CASCADE` ; `change_type`, `snapshot_json` | db.py l.249 |
| `page_global_templates` | Modèles de page globaux créés par les utilisateurs | `blocks_json` (format éditeur), FK `created_by → users SET NULL` | mig. 13 (l.582–597) |
| `synced_blocks` | Bloc synchronisé : source de vérité du contenu, réutilisé sur plusieurs pages | `content` (JSON blocs), `created_by → users` (sans FK), `workspace` (TEXT), `synced` via `page_synced_blocks` | mig. 15 (l.626–662) |
| `page_synced_blocks` | Référence page ↔ bloc synchronisé (désynchronisable par page) | FK `page_id → pages CASCADE`, `synced_block_id → synced_blocks CASCADE` ; `block_index`, `UNIQUE(page_id, synced_block_id)` | mig. 15 (l.646–662) |
| `page_views` | Compteur de vues quotidien par page (même pattern que `site_views`) | FK `page_id → pages CASCADE` ; PK composite `(page_id, day)`, `views` | mig. 29 (l.1454–1461) |
| `pages_fts` | **Table virtuelle FTS5** (`title`, `body`) pour la recherche plein texte de la palette de commandes ; synchronisée par 3 triggers `AFTER INSERT/UPDATE/DELETE` sur `pages` ; backfill au boot ; repli `LIKE` si FTS5 absent | rowid = `pages.id` | mig. 3 (l.156–205) |
### 2.5 Bases de données (collections, style Notion)
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `collections` | Base de données (collection) | `schema_json` (schéma des propriétés), FK `gitea_owner/gitea_repo` (texte, verrouillage sync), `workspace_id → workspaces`, `created_by → users`, `is_locked`, `is_inline` + `parent_page_id` (base inline), `is_task` + `task_assignee_prop/task_status_prop/task_due_prop → collection_properties SET NULL` (mapping My Tasks, mig. 30 l.1558–1585), `permission_type`, `teamspace_id` (sans FK), `form_config_json`, `sync_version` | db.py l.172 + ALTER db.py l.495–520, mig. 18/24/29/30 |
| `collection_pages` | **Ligne** d'une base (row) | FK `collection_id → collections CASCADE`, `parent_id → collection_pages` (hiérarchie interne), `property_values_json` (valeurs par propriété), `gitea_issue_id/gitea_issue_number` (sync Kanban), `cover_url`, `external_event_id` (calendrier, mig. 27), `permission_type`, `sync_version` | db.py l.187 ; mig. 10/18/27 |
| `collection_views` | Vues sauvegardées par base (table/board/calendar/gallery/list…) | FK `collection_id → collections CASCADE` ; `view_type`, **`config_json`** (filtres/tri/groupement/columns visibles), `position`, `created_by` (NULL = vue partagée, sinon personnelle — mig. 10) | db.py l.210 ; mig. 10 (l.472–478) |
| `collection_properties` | Schéma des propriétés d'une base (remplace `project_properties`) | FK `collection_id → collections CASCADE` ; `prop_type`, `options_json` (select/multi-select), `number_format`, relationnels : `related_collection_id → collections SET NULL`, `relation_property_id/target_property_id → collection_properties SET NULL`, `rollup_function`, `formula_expression`, `validation_json` (mig. 4), `group_name` (mig. 10), `button_automation_id → automations SET NULL` (bouton natif, mig. 26) ; `UNIQUE(collection_id, name)` | db.py l.222–241 ; mig. 4/10/26 |
| `collection_data_sources` | Sources de données liées (base liée type Notion « Linked database ») | FK `collection_id → collections CASCADE`, `source_collection_id → collections` ; `is_linked`, `UNIQUE(collection_id, source_collection_id)` | db.py l.480–493 |
| `collection_dashboards` | Dashboard = combinaison de vues/widgets sur une page | FK `collection_id → collections CASCADE` ; **`layout_json`** (`{"columns":1,"widgets":[]}`) | db.py l.531–542 |
| `database_templates` | Modèles de bases prêts à l'emploi (seed idempotent, v5.3.0 : Meeting notes, etc.) | `name`, `icon`, `description`, `schema_json` | db.py l.272 + mig. 4 (l.390–411) ; seed via `app/services/db_templates.py::SEED_TEMPLATES` |
| `page_templates` | Modèles de **ligne** par base, éventuellement récurrents | FK `collection_id → collections CASCADE` ; `property_values_json`, `content_json` (blocs), `is_recurring` + `recurrence_rule` | db.py l.294–303 + ALTER l.512–522 |
| `page_dependencies` | Dépendances entre lignes (bloque / bloqué par) | FK `page_id`, `dependency_id → collection_pages CASCADE` ; `dependency_type`, `auto_shift` (overlap/…) ; `UNIQUE(page_id, dependency_id, dependency_type)` | db.py l.551–563 |
| `sprints` | Sprints Scrum par base de tâches | FK `collection_id → collections CASCADE` ; `start_date/end_date` (TEXT), `status` (planning/active/done), `goal`, `auto_complete` | db.py l.570–582 |
| `sprint_pages` | Lignes affectées à un sprint + vélocité | FK `sprint_id → sprints CASCADE`, `page_id → collection_pages CASCADE` ; `velocity_points`, `status_at_start`, `UNIQUE(sprint_id, page_id)` | db.py l.584–594 |
| `reminder_log` | Journal anti-doublon des rappels : 1 ligne par (ligne de base, date d'occurrence) — le rappel ne fire qu'une fois même après restart | FK `page_id → collection_pages CASCADE` ; `UNIQUE(page_id, occurrence_date)` ; alimenté par le scan `reminders_enabled` (`app/config.py` l.75–77) — **il n'existe pas de table `reminders`** ; les dates de rappel proviennent des propriétés `due` des lignes | mig. 11 (l.485–509) |
### 2.6 Automatisation, workers & webhooks
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `automations` | Règles if-this-then-that (moteur v5.1.0) : `trigger_type` event/cron/button, `event`, `cron_expression`, `collection_id` (TEXT sans FK, scope optionnel), **`condition_json`** (clauses), **`actions_json`** (descripteurs), `enabled`, `run_count`, `last_run_at`, `trigger_mode` (`any`\|`all`, fenêtre de 5 min, mig. 26) | | mig. 5 (l.208–257) + mig. 26 (l.1196–1201) |
| `automation_steps` | Étapes ordonnées d'une automatisation v2 (trigger/condition/delay/action, v7.0.0) ; l'engine retombe sur `condition_json/actions_json` si aucune step | FK `automation_id → automations CASCADE` ; `kind`, `position`, `config_json` | mig. 26 (l.1156–1172) |
| `automation_runs` | Historique d'exécution (audit + UI Settings) | FK `automation_id → automations CASCADE` ; `status` (fired/skipped/error), `trigger_source`, `collection_id`/`page_id` (TEXT/INT sans FK déclarée), `detail` | mig. 5 (l.242–257) |
| `workers` | Snippets Python sandboxés (cron ou manuel), partageables (v7.0.0) | `slug UNIQUE`, FK `workspace_id → workspaces CASCADE`, `code_py`, `schedule_cron`, `shared`, `daily_budget_s` (budget CPU quotidien), `created_by → users` (sans FK déclarée) | mig. 26 (l.1174–1192) |
| `worker_runs` | Logs d'exécution des workers | FK `worker_id → workers CASCADE` ; `status`, `logs`, `duration_ms` | mig. 26 (l.1194–1210) |
| `webhook_subscriptions` | Abonnement sortant URL + event + secret (créée au boot hors migrations, appelée par `init_db` l.834–837) | `url`, `event`, `secret`, `active` | `app/services/webhook_outbound.py` l.272–286 |
| `webhook_deliveries` | Journal de livraison (retry ledger) | FK `webhook_id → webhook_subscriptions CASCADE` ; `status` (pending/retrying/success/error/superseded), `http_code`, `attempt`, `event`, `next_retry_at` (epoch, scheduler v6.4), `payload` (JSON), `duration_ms` | mig. 20 (l.913–928) + mig. 21 (l.940–957) |
| `idempotency_keys` | Support d'`Idempotency-Key` sur les POST de l'API v2 (réponse rejouée) | **PK `key TEXT`** ; FK `user_id → users CASCADE` ; `response_json`, `status_code` | mig. 20 (l.930–938) |
### 2.7 IA / Agent FlowDeck
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `agents` | Définition d'agent (personnel ou workspace) | FK `workspace_id → workspaces`, `created_by → users` ; `agent_type`, `system_instructions`, `model`, `scope_json`, `trigger_json`, `approval_mode` (auto/gated), `is_active`, `UNIQUE(workspace_id, name)` | db.py l.660–680 |
| `agent_conversations` | Conversation d'un utilisateur avec un agent | FK `agent_id → agents CASCADE`, `user_id → users` ; `status`, `context_json`, override `provider`/`model` (ALTER db.py l.820–827), `memory_enabled` (mig. 31) | db.py l.681–691 |
| `agent_messages` | Messages (rôles user/assistant/tool), coût tokens | FK `conversation_id → agent_conversations CASCADE` ; `tool_calls_json`, `model`, `tokens_used` | db.py l.692–702 |
| `agent_actions` | Journal des tool calls exécutés (audit + undo) | FK `conversation_id → … CASCADE` ; `tool_name`, `target_type/target_id`, `payload_json`, `result_json`, `status`, `undo_snapshot_json`, `executed_by → users` | db.py l.703–716 |
| `agent_skills` | Skills réutilisables (prompt + outils autorisés) | FK `workspace_id → workspaces`, `created_by → users` ; `prompt_template`, `allowed_tools_json`, `UNIQUE(workspace_id, name)` | db.py l.717–729 |
| `agent_triggers` | Déclencheurs d'agent (manual/cron/événement) | FK `agent_id → agents CASCADE` ; `trigger_type`, `config_json`, `is_active`, `last_fired_at` | db.py l.730–740 |
| `agent_feedback` | 👍/👎 sur les réponses (rating CHECK up/down) | FK `conversation_id`/`message_id`/`user_id` en `SET NULL` ; `snippet`, `comment` | db.py l.742–756 |
| `agent_policies` | Gouvernance par workspace (1 ligne/workspace) | FK `workspace_id → workspaces CASCADE`, `UNIQUE(workspace_id)` ; `allowed_tools_json`, `max_steps`, `require_approval` | mig. 28 (l.1326–1338) |
| `agent_approvals` | File d'approbation des actions d'écriture gated | `conversation_id` (INT **sans FK déclarée**), `tool`, `args_json`, `status` (pending/…), `requester_id`/`approver_id → users` | mig. 28 (l.1340–1354) |
| `agent_memory` | Résumé mémorisé par conversation (upsert 1:1, v7.54.0) | **PK `conversation_id → agent_conversations CASCADE`** ; `kind` (summary), `content` | mig. 31 (l.1661–1681) |
| `agent_connectors` | Catalogue de connecteurs d'agent (custom/Discord/Telegram/MCP, v7.55–7.57) | `name`, `url`, `secret_encrypted` (Fernet), `kind`, `auth` (bearer/…), `tools_json` (cache d'outils MCP), `enabled`, `status`, `created_by` (sans FK) | mig. 32 (l.1640–1659) + mig. 34 (l.1613–1624) |
| `connector_tokens` | Tokens OAuth par (kind, user) — Google / M365, chiffrés Fernet | **PK composite `(kind, user_id)`** ; `tokens_enc` | mig. 33 (l.1626–1638) |
| `plugins` | Registre des modules activables/désactivables de l'instance (web-tools, web-clipper, automations) — seed idempotent | **PK `slug TEXT`** ; `enabled` | mig. 35 (l.1589–1611) |
| `semantic_embeddings` | Recherche sémantique hybride + Ask AI (v6.9) : vecteurs « hashed-TF » 256 dims **sans dépendance externe** ; chunks par ressource polymorphe | **PK composite `(resource_type, resource_id, chunk_id)`** (page \| collection…) ; `chunk_text`, `embedding BLOB`, `model` | mig. 25 (l.1096–1133) |
| `semantic_index_state` | État d'indexation incrémentale (dernier index par ressource) | **PK composite `(resource_type, resource_id)`** ; `indexed_at` | mig. 25 (l.1122–1133) |
### 2.8 Sites publics & formulaires
(mig. 24, `migrations.py` l.1021–1093)
| Table | But | Clés / colonnes clés |
|---|---|---|
| `sites` | Mini-site public multi-pages (façon Notion Sites) | `slug UNIQUE`, FK `root_page_id → pages CASCADE`, `custom_domain UNIQUE`, `theme`, `password_hash`, `expires_at`, `noindex`, `analytics_id`, `created_by → users` |
| `site_pages` | Arbre public ordonné des pages du site | FK `site_id → sites CASCADE`, `page_id → pages CASCADE` ; `position`, `UNIQUE(site_id, page_id)` |
| `site_views` | Compteur de vues jour/site (upsert, **pas d'IP brute** — RGPD) | FK `site_id → sites CASCADE` ; PK composite `(site_id, day)`, `views` |
| `form_responses` | Journal anonymisé des soumissions de formulaire public | FK `collection_id → collections CASCADE`, `row_id → collection_pages SET NULL` (ligne créée) ; `ip_hash` (hash tournant quotidien, pas d'IP) ; config du form dans `collections.form_config_json` |
### 2.9 Calendrier & réunions
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `calendar_links` | Lien par utilisateur entre une collection et un calendrier externe (Google REST ou CalDAV générique) | FK `user_id → users CASCADE`, `collection_id → collections CASCADE` ; `provider`, `tokens_enc` (Fernet), `calendar_id`, `date_property`, `sync_token`, `UNIQUE(user_id, provider, calendar_id)` | mig. 27 (l.1231–1245) |
| `meeting_transcripts` | Note de réunion (audio + transcription + résumé IA), machine à états du bloc Notion-like (idle/recording/paused/processing/done/failed) | FK `page_id → pages CASCADE` ; `audio_path`, `transcript`, `summary`, `language` ; v7.1.1 : `status`, `mode`, `channels_json`, `instruction_snapshot`, `consent_json`, `segments_json`, `processing_steps_json`, `summary_json`, `generated_title`, `completeness_class`, `quality_score`, `duration_ms/paused_ms`, **`event_occurrence_id`** (index unique partiel : une occurrence calendrier = au plus une note) | mig. 27 (l.1247–1271) + mig. 38 (l.1730–1800) |
| `meeting_consent_log` | Journal append-only du consentement d'enregistrement | FK `transcript_id → meeting_transcripts CASCADE` ; `method` (start_attestation/…), `attested_by → users`, `participants_json` | mig. 38 (l.1767–1777) |
| `meeting_distributions` | Journal append-only des diffusions de compte-rendu (copy_link/…) | FK `transcript_id → meeting_transcripts CASCADE` ; `channel`, `actor_id → users`, `recipients_json`, `status` | mig. 38 (l.1779–1788) |
### 2.10 Import/export & offline (PWA, clipper)
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `import_jobs` | Job d'import asynchrone (polling UI) — Notion/CSV/markdown | **PK `id TEXT`** (uuid) ; `source`, `filename`, `status` (queued/running/done/error), `error`, `report_json` | mig. 9 (l.434–451) |
| `import_items` | Dédup : 1 ligne par page importée, clé (workspace, source, external_id) → re-import idempotent | FK `workspace_id`/`page_id` **sans REFERENCES déclaré** ; `UNIQUE(workspace_id, source, external_id)` | mig. 9 (l.418–432) |
| `offline_sync_queue` | File serveur des mutations reçues des clients hors-ligne (`/api/v2/sync/batch`), rejouables/auditées par device | FK `user_id → users CASCADE` ; `device_id`, `type` (page_create/update/delete/move, collection_*), `payload` (JSON), `client_timestamp`, `server_version`, `status` (pending/syncing/synced/failed), `retries`, `error` ; détection de conflit édit/édit via `sync_version` (mig. 17, triggers sur `pages`, `collection_pages`, `collections`) | mig. 16 (l.668–700) |
| `extension_devices` | Devices de l'extension Web Clipper (token hashé, scopes) | FK `user_id → users CASCADE` ; `device_id`, `token_hash`, `scopes`, `revoked`, `UNIQUE(user_id, extension_name, device_id)` | mig. 19 (l.838–857) |
| `extension_clips` | Clippings capturés (page cible + workspace cible) | FK `user_id → users CASCADE`, `target_page_id → pages SET NULL`, `target_workspace_id → workspaces SET NULL` ; `clip_type`, `source_url` | mig. 19 (l.861–877) |
### 2.11 API & audit
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `api_audit_log` | Traînée immuable des mutations de l'API publique v2 | FK `user_id → users SET NULL`, `token_id → api_tokens SET NULL` ; `action`, `resource_type`, `resource_id`, `ip_address`, `detail` | mig. 20 (l.895–911) |
| `schema_version` | Registre des migrations appliquées (meta) | **PK `version INTEGER`** ; `name`, `applied_at` | `app/migrations.py` l.41–51 |
### 2.12 Divers
`favorites`, `recents`, `notes`, `ai_keywords`, `login_history` déjà couverts ci-dessus. À noter également : les tables temporaires de reconstruction vues dans le code (`comments_new`, `tags_new`) **ne sont pas** des tables permanentes (pattern recreate → copy → drop → rename).
---
## 3. Relations clés (graphe FK simplifié)
```mermaid
graph TD
USERS[users]
WS[workspaces]
PAGES[pages]
COLS[collections]
CP[collection_pages]
CPV[collection_properties]
CV[collection_views]
WS -->|owner_id| USERS
WS -->|workspace_members| USERS
WS -->|workspace_id| PAGES
WS -->|workspace_id| COLS
PAGES -->|parent_id self| PAGES
PAGES -->|collection_id| COLS
PAGES -->|collection_row_id CASCADE| CP
CP -->|collection_id CASCADE| COLS
CP -->|parent_id self| CP
CPV -->|collection_id CASCADE| COLS
CPV -->|related_collection_id| COLS
CPV -->|relation/target_property_id self| CPV
CPV -->|button_automation_id| AUTO[automations]
CV -->|collection_id CASCADE| COLS
AUTO -->|collection_id texte sans FK| COLS
AGENTS[agents] -->|workspace_id| WS
CONV[agent_conversations] -->|agent_id CASCADE| AGENTS
CONV -->|user_id| USERS
MSG[agent_messages + actions] -->|conversation_id CASCADE| CONV
GRP[user_groups] -->|workspace_id CASCADE| WS
PPERM[page/collection/property_permissions] -->|user_id XOR group_id| USERS
PPERM -->|group_id| GRP
SITES[sites/site_pages] -->|root/page CASCADE| PAGES
TRANS[meeting_transcripts] -->|page_id CASCADE| PAGES
CAL[calendar_links] -->|user_id| USERS
CAL -->|collection_id CASCADE| COLS
CLIPS[extension_clips] -->|target_page_id| PAGES
```
Points de relation à retenir :
1. **`users` est le hub d'identité** : plus de 30 FK entrantes (sessions, tokens, permissions, audit, contenu). `workspaces` est le hub d'isolation multi-tenant (`owner_id`, `workspace_members` définit le rôle).
2. **Hiérarchie `pages ↔ collection_pages`** : deux univers de « pages ». `pages` = éditeur de blocs wiki (FK self `parent_id`, soft-delete `deleted_at`, corbeille) ; `collection_pages` = lignes de base. Le pont v6.5 : chaque ligne peut avoir une page-ombre `pages.collection_row_id` (CASCADE) qui porte son contenu blocs — versions, synced blocks et temps réel opèrent sur elle.
3. **Chaîne de cascades principales** : `collections → collection_pages/collection_views/collection_properties → …` et `pages → page_versions/page_shares/page_synced_blocks/page_permissions/guest_shares/text_reactions`. Les suppressions de ressource nettoyent l'éditorial ; les grants d'audit (`*_audit_log`, `api_audit_log`, `permission_audit_log`) privilégient `SET NULL` sur l'acteur pour survivre à la suppression du compte.
4. **Permissions en 3 niveaux** (mig. 18) : `pages/collections/collection_pages.permission_type` (`inherit`|`restricted`|`private`) pilote si les tables `*_permissions` (user **XOR** group via `CHECK`) font foi ; les groupes (`user_groups`/`group_members`) sont workspace-scopés ; `page_shares` reste le chemin de partage simple, `guest_shares` le chemin sans compte.
5. **Sync sémantique Gitea** : `boards/cards/col_mapping/checklists/property_values` et `collection_pages.gitea_issue_id` + `collections.gitea_owner/repo` relient les entités FlowDeck aux issues Gitea (jointures par ID numérique, **pas de FK** — système externe).
6. **Polymorphisme non-FK** : `notifications.resource_type/resource_id`, `semantic_embeddings.resource_type/resource_id`, `permission_audit_log.resource_type/resource_id`, `import_items.page_id`, `agent_approvals.conversation_id`, `automations.collection_id`, `custom_emojis.workspace_id`, `teamspaces`-id sur `pages`/`collections` (ALTER sans FK) — cohérence maintenue applicativement, pas par le moteur.
---
## 4. Colonnes JSON structurantes
Le modèle est fortement « document-oriented » : le JSON TEXT est le format de stockage des structures riches (choix assumé du SQLite sans JSON natif, requêtage via `json_extract` côté applicatif).
| Domaine | Table.colonne | Contenu |
|---|---|---|
| Kanban | `boards.columns_json` / `wip_limits_json` | liste ordonnée des colonnes ; limites WIP par colonne |
| Kanban/legacy | `project_properties.options_json` | options de select |
| Collections | `collections.schema_json` | schéma complet des propriétés d'une base (déclaratif, dupliqué avec `collection_properties`) |
| Collections | `collections.form_config_json` | formulaire public (champs, validations) |
| Lignes | `collection_pages.property_values_json` | map `{property_name → valeur}` de la ligne |
| Vues | `collection_views.config_json` | **config de vue** : filtres, sorts, groupements, colonnes visibles/cache, sizing — cœur du système de vues multiples |
| Propriétés | `collection_properties.options_json` / `validation_json` | options select, règles de validation |
| Dashboard | `collection_dashboards.layout_json` | colonnes + widgets |
| Modèles | `database_templates.schema_json`, `page_templates.property_values_json` / `content_json` | blueprint de base / ligne |
| Éditeur | `pages.content` (avec `content_format='blocks'`) | **tableau JSON des blocs** (heading, todo, callout, synced_block, meeting, etc.) — l'unité de contenu fondamentale |
| Historique | `page_versions.blocks_json`, `page_history.snapshot_json`, `page_global_templates.blocks_json`, `synced_blocks.content` | snapshots de liste de blocs |
| Workspace | `workspaces.settings_json`, `users.sidebar_config`, `users.notification_prefs`, `users.totp_backup_hashes` | préférences |
| Agent | `agents.scope_json`/`trigger_json`, `agent_conversations.context_json`, `agent_messages.tool_calls_json`, `agent_actions.payload_json`/`result_json`/`undo_snapshot_json`, `agent_skills.allowed_tools_json`, `agent_triggers.config_json`, `agent_policies.allowed_tools_json`, `agent_approvals.args_json` | orchestration, audit et undo |
| LLM | `user_llm_keys.models_json` | cache des modèles distants |
| Automatisation | `automations.condition_json`/`actions_json`, `automation_steps.config_json` | DSL des règles (fallback steps ↔ legacy colonnes) |
| Sync offline | `offline_sync_queue.payload`, `idempotency_keys.response_json` | mutation client + réponse rejouée |
| Webhooks | `webhook_deliveries.payload` | corps livré |
| Réunions | `meeting_transcripts.channels_json`/`consent_json`/`segments_json`/`processing_steps_json`/`summary_json`, `meeting_consent_log.participants_json`, `meeting_distributions.recipients_json` | pipeline transcription |
| Imports | `import_jobs.report_json` | rapport par fichier |
| SSO | `sso_config.attribute_mapping`, `groups_mapping`, `sso_login_history` (mapping) | attributs d'assertion |
---
## 5. Couche Pydantic (`app/models/`)
Les modèles Pydantic ne couvrent **qu'une petite partie** du contrat API — la majorité des routes FastAPI valide en dur dans les handlers :
- `app/models/requests.py` (75 l.) :
- `FileSaveRequest` — sauvegarde de fichier via Gitea (`path`, `content`, `message`, `sha` optionnel pour update) ; `@model_validator(mode="after")` restreint l'extension via `ALLOWED_EXTENSIONS`/`_ext` (`app/middleware/security`) ;
- `IssueCreateRequest` / `IssueUpdateRequest` — création/mutation d'issues Gitea (title ≤ 500, `state` pattern `^(open|closed)$`, labels/milestone/assignee en ids virgule) ;
- `CardMoveRequest` — déplacement de carte Kanban (owner/repo/issue_id/column) ;
- `ColMappingRequest` — mapping colonne ↔ label ;
- `UploadValidationResult` — DTO de validation d'upload (filename/size/extension/valid/error).
- `app/models/responses.py` (38 l.) : deux enveloppes standardisées, utilisées en `response_model` OpenAPI des routes publiques :
- `ErrorResponse {error, detail?}` ;
- `SuccessResponse {status="ok", data?: dict}`.
Aucun modèle ORM (pas de SQLAlchemy) : l'accès aux données est du **SQL brut `sqlite3`** via le context-manager `get_conn()` (`app/db.py` l.843–858, `row_factory = sqlite3.Row`), et les conversions dict↔JSON des colonnes `*_json` sont faites manuellement dans les services.
---
## 6. Fiches de synthèse technique
| Aspect | Valeur réelle (vérifiée dans le code) |
|---|---|
| Moteur | SQLite unique, fichier `settings.db_path` (`database_url` = `sqlite:///…`), WAL + `busy_timeout=5000`, `foreign_keys=ON` |
| ORM | aucun — SQL brut |
| Pilote | `sqlite3` stdlib |
| Migrations | maison, versionnées : baseline v1 (`init_db` idempotent) + registry `@register` (`app/migrations.py`), table `schema_version`, transaction par migration |
| Version de schéma | 38 |
| Triggers | FTS5 (`pages_fts_ai/ad/au`), bumps `sync_version` (`*_sync_version_bu`) sur `pages`/`collection_pages`/`collections` |
| Full-text | FTS5 optionnel, repli `LIKE` |
| Recherche vectorielle | `semantic_embeddings` (hash TF 256-dim en BLOB, PK composite polymorphe, pas d'extension vec0) |
| Chiffrement au repos | Fernet sur `calendar_links.tokens_enc`, `connector_tokens.tokens_enc`, secrets SSO ; hash sha256 pour `api_tokens.token_hash`, `scim_tokens.token_hash`, `extension_devices.token_hash` ; `password_hash` (users) |
| Sauvegardes | copie planifiée du fichier SQLite (`backup_*`, `app/config.py` l.66–70) |
| Concurrence | verrou applicatif d'écriture indirect via WAL + busy_timeout ; migration async du 510 call sites synchrones encore en cours (note A21, `app/db.py` l.848–852) |
+12 -3
View File
@@ -1,8 +1,8 @@
# V7.1.0 — Calendar sync + Meeting Notes
# V7.1.0 — Calendar sync + Meeting Notes · V7.1.1 — Bloc façon Notion
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Roadmap** : `ROADMAP.md § v7.1.0`
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Refonte Notion livrée en v7.1.1** (migration 38, `static/js/meeting_block.js`, `static/css/meeting.css`, 5 tests verts `tests/test_v711_meeting_notion.py`) · **Roadmap** : `ROADMAP.md § v7.1.0`
> Notes d'implémentation : pas de lib CalDAV/Google (REST + XML bruts) ; refresh OAuth hors scope (relink explicite sur 401) ; conflit = LWW auto + notif (pas de picker « garder ma version ») ; free/busy au jour (pas de créneaux horaires).
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026).
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026). Spec détaillée : `docs/architecture-meeting-notion-flowdeck.md` (§3A parcours visuel des 6 états, §3B maquettes ASCII, §6.5 machine à états, §12.7 étapes Thinking, §12.8 complétude + titre).
> **Existant** : vues jour/semaine/mois, moteur `recurrence.py`, `reminders.py` (scan 60s, dédup), timezones, `notify_assignment()`, template Meeting Notes.
---
@@ -41,3 +41,12 @@ Upload `.mp3/.wav/.m4a` (100 MB) sur page Meeting → `meeting_transcripts` →
## 3. Tests (`tests/test_v71_calendar_meetings.py`, ~15)
OAuth mock (tokens chiffrés roundtrip), pull/push, conflit LWW + notif, freebusy, transcription mock → résumé blocs, trigger émis, quota 100 MB refusé.
## 4. V7.1.1 — Refonte du bloc façon Notion (§3A/3B de `architecture-meeting-notion-flowdeck.md`)
- **Bloc à états** (`static/js/meeting_block.js` surcharge `window.FDMeeting`, styles `static/css/meeting.css`) : `idle → recording → paused → processing → done/failed`, révélation progressive `Notes → Notes+Transcript → Summary+Notes+Transcript` (Summary en tête, actif par défaut après Stop), bouton Start à menu (source/mode, langue), onde en direct + chrono, pastille Thinking, barre `Share this summary` (Copy link / Email / Slack tracés), pied instructions + attestation de consentement puis bandeau. Le titre généré par l'IA ne touche jamais le titre de la page hôte. Anciens blocs migrés à la volée.
- **Capture Web 2 modes** : A micro (`getUserMedia`), B onglet + micro (`getDisplayMedia` + micro, canaux séparés, alerte « je n'entends que vous » après 20 s de canal distant muet) ; STT navigateur quand dispo, transcript manuel + import audio en secours, tampon IndexedDB avec reprise après rechargement (recording → paused, processing → suivi repris).
- **Backend** (migration 38) : colonnes état/mode/instructions photo/consent/segments/steps/résumé structuré/titre/complétude + tables append-only `meeting_consent_log`, `meeting_distributions` + unicité une occurrence = une note. Endpoints : `POST /transcripts` (création idempotente), `/consents`, `/sessions`, `/pause`, `/resume`, `/stop` (analyse inline), `/segments`, `/transcript` (groupé par source), `/processing-steps`, `/processing:retry`, `/generated-title` (PATCH), `/distributions`, `/notes` (PATCH), `GET /meetings/history` + `/meetings/instructions`.
- **Pipeline IA** (`services/meetings.py`) : étapes Thinking persistées (lecture → analyse → résolution des références → compréhension → complétude → résumé → titre → citations), résumé contraint Overview/Décisions/Actions/Questions avec citations `¹ [seg N]` obligatoires et états vides explicites, classification `complete/brief/incomplete/fragmentary` (jamais de refus : résumé dégradé + titre-diagnostic `Brief or Incomplete Meeting Recording`), audit via `meeting.summarized` inchangé.
- **Hub** (`base.html`) : onglets Upcoming / Today / Past, recherche plein texte, CTA contextuels (Join & transcribe / Prepare / View summary).
- **Tests** : `tests/test_v711_meeting_notion.py` (5) — porte de consentement, machine à états complète avec résumé dégradé + titre-diagnostic, résumé structuré complet, unicité par occurrence, instructions + historique.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
/**
* /local-workspace — le chemin du header suit la navigation dans les
* dossiers, et une navigation partielle vers un sous-dossier remonte la
* page Alpine (avant : « Cannot redefine property: $nextTick » → tous les
* composants restés visibles à la fois).
*
* Parcours : sidebar Docs → sidebar Sub → clic dans la vue sur Leaf →
* breadcrumb interne Docs → crumb workspace du header.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
test('local-workspace: chemin header + navigation dossiers (partielle et interne)', async ({ page }) => {
test.setTimeout(180000);
const errors = [];
page.on('console', m => { if (m.type() === 'warning' && m.text().includes('Cannot redefine')) errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// ── 1. Utilisateur + workspace + arborescence Docs/Sub/Leaf ──
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'LWBC');
await page.fill('#email', 'lwbc' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(800);
await page.keyboard.press('Escape').catch(() => {});
const ids = await page.evaluate(async () => {
const csrf = decodeURIComponent((document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '');
const H = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
const r = await fetch('/api/workspaces', {
method: 'POST', headers: H, body: JSON.stringify({ name: 'LWBC WS ' + Date.now() })
});
const d = await r.json();
await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST', headers: H });
const mk = (name, type, parent) => fetch('/api/local-workspace/items', {
method: 'POST', headers: H, body: JSON.stringify({ name, type, parent_id: parent || null })
}).then(x => x.json());
const docs = await mk('Docs', 'folder');
const sub = await mk('Sub', 'folder', docs.id);
const leaf = await mk('Leaf', 'folder', sub.id);
await mk('Page In Leaf', 'page', leaf.id);
return { wsName: '', docs: docs.id, sub: sub.id, leaf: leaf.id, ws: d.id };
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
const wsName = await page.evaluate(() => {
const el = document.querySelector('.ws-main .unified-breadcrumb .bc-root');
return el ? el.textContent.trim() : '';
});
expect(wsName).toBeTruthy();
const headerPath = () => page.evaluate(() => {
const el = document.querySelector('.header-breadcrumb');
return el ? el.innerText.replace(/\s+/g, ' ').replace(/…/g, '...').trim() : null;
});
expect(await headerPath()).toContain(wsName);
// Composants Alpine peints à l'état brut (x-show non évalué) — hors pages
// légitimement visibles (arbre, ligne « parent », barre de tags).
// checkVisibility() prend en compte les ancêtres (display:contents sur
// .bc-edit-wrap ferme l'input sans que son propre display change).
const rawPainted = () => page.evaluate(() => {
const sels = ['.ws-dropdown', '.ws-select-menu', '.upload-progress', '.undo-toast',
'.move-modal-overlay', '.ws-search-bar', '.ws-select-bar', '.more-menu', '.filter-row',
'.bc-edit-wrap'];
return sels.filter(s => [...document.querySelectorAll(s)]
.some(e => e.checkVisibility({ opacityProperty: true, visibilityProperty: true })));
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toContain(wsName);
// ── 2. Sidebar : clic sur Docs puis sur Sub (navigation partielle) ──
const clickSidebarFolder = async (id) => {
await page.evaluate((fid) => {
const el = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + fid + '"] .tree-folder-link');
if (el) el.click();
}, id);
await page.waitForURL('**folder=' + id, { timeout: 15000 });
await page.waitForTimeout(1200);
};
await clickSidebarFolder(ids.docs);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs`);
expect(await rawPainted()).toEqual([]);
await clickSidebarFolder(ids.sub);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs / Sub`);
expect(await rawPainted()).toEqual([]);
// ── 3. Clic sur un sous-dossier DANS la vue (sans rechargement) ──
await page.evaluate((fid) => {
const el = document.querySelector('.ws-main [data-folder-id="' + fid + '"] .name.folder');
if (el) el.click();
}, ids.leaf);
await expect.poll(headerPath, { timeout: 10000 }).toContain('/ Sub / Leaf');
expect(await headerPath()).toContain(wsName);
expect(await rawPainted()).toEqual([]);
// ── 4. Breadcrumb interne : clic sur Docs → les deux chemins suivent ──
const inPage = () => page.evaluate(() =>
[...document.querySelectorAll('.ws-main .unified-breadcrumb a')].map(a => a.textContent.trim()));
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs', 'Sub', 'Leaf']);
await page.click('.ws-main .unified-breadcrumb a:has-text("Docs")');
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName} / Docs`);
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs']);
// ── 5. Crumb workspace du header → retour à la racine ──
await page.click('.header-breadcrumb a:has-text("' + wsName + '")');
await page.waitForURL('**/local-workspace', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName}`);
expect(await rawPainted()).toEqual([]);
expect(errors).toEqual([]);
});
+113
View File
@@ -0,0 +1,113 @@
"""Vendor highlight.js (common bundle + extra languages + themes) into static/.
Run: python3 scripts/vendor_hljs.py
Fetches from cdnjs 11.9.0 (pinned for stability) and writes:
static/js/highlight.min.js (common bundle, ~40 languages)
static/js/highlight-extra.min.js (extra languages concatenated)
static/css/highlight.css (github light + github-dark scoped on [data-theme])
"""
import re
import urllib.request
VERSION = "11.9.0"
BASE = f"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/{VERSION}"
COMMON_JS = f"{BASE}/highlight.min.js"
LANG_URL = BASE + "/languages/{lang}.min.js"
LIGHT_CSS = BASE + "/styles/github.min.css"
DARK_CSS = BASE + "/styles/github-dark.min.css"
# Extra languages NOT in the common bundle, covering a maximum of file types.
# NOTE (verified 11.9.0): "fish", "toml" and "cobol" have NO standalone grammar
# on cdnjs for this version — code-highlight.js maps them to the closest
# bundled grammar (fish→shell, toml→ini, cobol→plaintext).
EXTRA_LANGS = [
# shells / ops
"powershell", "dos", "fish",
# build / containers / config
"dockerfile", "cmake", "toml", "graphql", "protobuf", "thrift",
# web servers / protocols (absent du bundle common cdnjs 11.9.0)
"apache", "http", "nginx",
# JVM / functional / misc popular
"scala", "groovy", "haskell", "elixir", "erlang", "clojure",
"ocaml", "fsharp", "nim", "d", "julia", "matlab",
"dart", "latex", "vim", "awk", "delphi",
# asm / hdl / legacy
"armasm", "x86asm", "vhdl", "verilog", "ada", "fortran", "cobol",
"gcode", "stata", "stan",
]
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "FlowDeck-vendor/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
def scope_css(css: str, scope: str) -> str:
"""Prefix every rule whose selector mentions .hljs with the theme scope."""
out = []
for chunk in css.split("}"):
if "{" not in chunk:
out.append(chunk)
continue
selectors, body = chunk.split("{", 1)
parts = []
for sel in selectors.split(","):
sel = sel.strip()
if not sel:
continue
if ".hljs" in sel:
parts.append(f"{scope} {sel}")
else:
parts.append(sel)
out.append(", ".join(parts) + "{" + body)
return "}".join(out)
def main() -> None:
from pathlib import Path
root = Path(__file__).resolve().parent.parent
js_dir = root / "static" / "js"
css_dir = root / "static" / "css"
common = fetch(COMMON_JS)
(js_dir / "highlight.min.js").write_text(common, encoding="utf-8")
print(f"highlight.min.js: {len(common)} bytes")
extra_parts = []
missing = []
for lang in EXTRA_LANGS:
try:
code = fetch(LANG_URL.format(lang=lang))
extra_parts.append(f"\n/* hljs lang: {lang} */\n" + code)
print(f" + {lang}: {len(code)} bytes")
except Exception as exc: # noqa: BLE001
missing.append(lang)
print(f" ! {lang} missing: {exc}")
(js_dir / "highlight-extra.min.js").write_text(
"/* FlowDeck — highlight.js extra languages (cdnjs %s) */\n" % VERSION
+ "\n".join(extra_parts),
encoding="utf-8",
)
light = fetch(LIGHT_CSS)
dark = fetch(DARK_CSS)
css = (
"/* FlowDeck — highlight.js themes (cdnjs %s): github (light) + "
"github-dark scoped on html[data-theme=\"dark\"] */\n" % VERSION
+ light
+ "\n"
+ scope_css(dark, 'html[data-theme="dark"]')
+ "\n/* FlowDeck tweaks: keep code blocks integrated with app surfaces */\n"
+ ".hljs{background:transparent;}\n"
)
(css_dir / "highlight.css").write_text(css, encoding="utf-8")
print(f"highlight.css: {len(css)} bytes")
if missing:
print("MISSING:", missing)
if __name__ == "__main__":
main()
+122 -14
View File
@@ -540,41 +540,42 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.sidebar-item.empty-hint:hover { background: none; }
.sidebar-item.empty-hint .page-name { font-size: 12px; }
/* Shared subgroups (Par moi / Avec moi) */
/* Shared subgroups (Par moi / Avec moi) — compact */
.sidebar-subgroup {
padding-top: 2px;
padding-top: 1px;
}
.sidebar-subgroup + .sidebar-subgroup {
margin-top: 6px;
padding-top: 6px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.sidebar-subgroup-label {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
/* Sections */
/* Sections — compact (v7.70 : une seule ouverte à la fois, paddings réduits) */
.sidebar-section {
padding: var(--space-xs) var(--space-md);
padding: 1px 6px;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
padding: 3px 4px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
user-select: none;
min-height: 24px;
}
.sidebar-section-header:hover {
@@ -589,8 +590,8 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.sidebar-section-title {
display: flex;
align-items: center;
gap: 3px;
font-size: 12px;
gap: 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: none;
@@ -754,6 +755,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
/* Customize Sidebar Panel */
.sidebar-customize-panel {
padding: 8px 12px;
/* v7.69.6 : au-dessus de .customize-overlay (z 998) — sinon chaque clic,
même sur le panneau, touche l'overlay qui referme le mode customize. */
position: relative;
z-index: 999;
}
.scp-header {
@@ -853,14 +858,14 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
padding: 1px 4px;
padding-left: 4px;
border-radius: var(--radius-sm);
font-size: 13px;
font-size: 12.5px;
color: var(--text-primary);
cursor: pointer;
transition: background var(--transition);
min-height: 24px;
min-height: 22px;
}
.sidebar-item:hover {
@@ -1037,6 +1042,80 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
color: var(--text-secondary);
}
/* ── Bouton "New" + popover "Create new" (v7.70, style Notion) ──
Popover ouvert vers le haut, bouton pilule en bas de la sidebar. */
.fd-new-wrap {
position: relative;
display: flex;
justify-content: center;
}
.fd-new-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
background: var(--bg-hover);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.fd-new-btn:hover,
.fd-new-btn.open {
background: var(--bg-active);
border-color: var(--border-light);
}
.fd-new-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
padding: 6px;
z-index: 500;
animation: fd-new-pop-in 150ms ease-out;
transform-origin: bottom center;
}
@keyframes fd-new-pop-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.fd-new-pop-header {
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
padding: 6px 10px 4px;
}
.fd-new-pop-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 14px;
text-align: left;
cursor: pointer;
transition: background 120ms, color 120ms;
}
.fd-new-pop-item:hover,
.fd-new-pop-item.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.fd-new-pop-item svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════
CONTEXT MENU
═══════════════════════════════════════════════════════════ */
@@ -2654,6 +2733,35 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.code-lang-bar {
display: flex;
align-items: center;
gap: 8px;
}
.code-lang-select {
background: transparent;
border: none;
outline: none;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
cursor: pointer;
padding: 0;
max-width: 220px;
}
.code-lang-select:hover {
color: var(--text-primary);
}
.code-lang-select option {
background: var(--bg-modal, #1f1f1f);
color: var(--text-primary, #eee);
font-weight: 400;
}
/* highlight.js inside editor code blocks: theme colors the tokens,
the block keeps its app surface (transparent hljs background). */
.block-code code.hljs {
background: transparent;
}
.block-code pre {
margin: 0;
padding: 12px 16px;
+23
View File
@@ -0,0 +1,23 @@
/* FlowDeck — highlight.js themes (cdnjs 11.9.0): github (light) + github-dark scoped on html[data-theme="dark"] */
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
html[data-theme="dark"] pre code.hljs{display:block;overflow-x:auto;padding:1em}html[data-theme="dark"] code.hljs{padding:3px 5px}html[data-theme="dark"] /*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}html[data-theme="dark"] .hljs-doctag, html[data-theme="dark"] .hljs-keyword, html[data-theme="dark"] .hljs-meta .hljs-keyword, html[data-theme="dark"] .hljs-template-tag, html[data-theme="dark"] .hljs-template-variable, html[data-theme="dark"] .hljs-type, html[data-theme="dark"] .hljs-variable.language_{color:#ff7b72}html[data-theme="dark"] .hljs-title, html[data-theme="dark"] .hljs-title.class_, html[data-theme="dark"] .hljs-title.class_.inherited__, html[data-theme="dark"] .hljs-title.function_{color:#d2a8ff}html[data-theme="dark"] .hljs-attr, html[data-theme="dark"] .hljs-attribute, html[data-theme="dark"] .hljs-literal, html[data-theme="dark"] .hljs-meta, html[data-theme="dark"] .hljs-number, html[data-theme="dark"] .hljs-operator, html[data-theme="dark"] .hljs-selector-attr, html[data-theme="dark"] .hljs-selector-class, html[data-theme="dark"] .hljs-selector-id, html[data-theme="dark"] .hljs-variable{color:#79c0ff}html[data-theme="dark"] .hljs-meta .hljs-string, html[data-theme="dark"] .hljs-regexp, html[data-theme="dark"] .hljs-string{color:#a5d6ff}html[data-theme="dark"] .hljs-built_in, html[data-theme="dark"] .hljs-symbol{color:#ffa657}html[data-theme="dark"] .hljs-code, html[data-theme="dark"] .hljs-comment, html[data-theme="dark"] .hljs-formula{color:#8b949e}html[data-theme="dark"] .hljs-name, html[data-theme="dark"] .hljs-quote, html[data-theme="dark"] .hljs-selector-pseudo, html[data-theme="dark"] .hljs-selector-tag{color:#7ee787}html[data-theme="dark"] .hljs-subst{color:#c9d1d9}html[data-theme="dark"] .hljs-section{color:#1f6feb;font-weight:700}html[data-theme="dark"] .hljs-bullet{color:#f2cc60}html[data-theme="dark"] .hljs-emphasis{color:#c9d1d9;font-style:italic}html[data-theme="dark"] .hljs-strong{color:#c9d1d9;font-weight:700}html[data-theme="dark"] .hljs-addition{color:#aff5b4;background-color:#033a16}html[data-theme="dark"] .hljs-deletion{color:#ffdcd7;background-color:#67060c}
/* FlowDeck tweaks: keep code blocks integrated with app surfaces */
.hljs{background:transparent;}
+180
View File
@@ -0,0 +1,180 @@
/* FlowDeck — AI Meeting Notes, panneau façon Notion (v7.1.1).
* Coquille fixe : en-tête / rangée onglets+contrôles / corps / pied (§3B.9).
* Tout le contenu du corps est aligné sur la marge gauche des onglets ;
* tout ce qui est « action globale » est aligné à droite. */
.fm-tabs { display: flex; gap: 2px; align-items: center; }
.fm-tab {
background: none; border: none; color: var(--text-dim); font-size: 12.5px;
cursor: pointer; padding: 6px 12px; border-radius: 99px; font-weight: 500;
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
.fm-tab.active { background: var(--bg-tertiary); color: var(--text-primary); }
.fm-mic {
font-size: 10px; border: 1px solid var(--border); border-radius: 4px;
padding: 0 3px; font-style: normal;
}
.fm-tab.active .fm-mic { border-color: var(--accent); color: var(--accent); }
.fm-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.fm-controls { display: flex; align-items: center; gap: 6px; margin-left: auto; position: relative; }
.fm-icon {
background: none; border: none; color: var(--text-dim); cursor: pointer;
font-size: 14px; padding: 5px 7px; border-radius: 6px; line-height: 1;
}
.fm-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
/* Bouton de démarrage à menu (split button) */
.fm-split { display: flex; position: relative; }
.fm-start { border-radius: 8px 0 0 8px !important; }
.fm-chevron { border-radius: 0 8px 8px 0 !important; border-left: 1px solid rgba(255,255,255,.35) !important; padding: 7px 9px !important; }
.fm-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
background: var(--bg-modal); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow-modal);
padding: 8px; min-width: 280px;
}
.fm-menu-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; padding: 6px 8px 2px; }
.fm-menu-item {
display: block; width: 100%; text-align: left; background: none; border: none;
color: var(--text-primary); font-size: 13px; padding: 7px 8px; border-radius: 6px; cursor: pointer;
}
.fm-menu-item:hover { background: var(--bg-hover); }
.fm-menu-item.sel { color: var(--accent); font-weight: 600; }
.fm-menu-row { display: flex; gap: 6px; }
.fm-menu-row .fm-menu-item { width: auto; }
.fm-menu-check { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--text-secondary); padding: 8px; cursor: pointer; }
.fm-setup {
display: flex; flex-direction: column; gap: 3px;
font-size: 12.5px; color: var(--text-secondary);
padding: 2px 8px 6px;
}
/* Bande de vérification pré-enregistrement (toujours visible à l'état idle) */
.fm-setupstrip {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
font-size: 12px; color: var(--text-secondary);
border: 1px dashed var(--border); border-radius: 8px; padding: 6px 10px;
}
.fm-dim { color: var(--text-dim); }
/* Enregistrement : onde + chrono + pastilles */
.fm-wave { height: 30px; width: 140px; }
.fm-timer { font-family: monospace; font-size: 12px; color: var(--text-secondary); min-width: 44px; text-align: center; }
.fm-btn-stop { color: #e5484d; }
.fm-paused-badge {
font-size: 11px; padding: 3px 10px; border-radius: 99px;
background: #f5a62333; color: #f5a623; white-space: nowrap;
}
.fm-thinking {
font-size: 11.5px; padding: 4px 12px; border-radius: 99px;
background: var(--bg-tertiary); color: var(--text-primary); white-space: nowrap;
}
/* Panneau réglages */
.fm-settings {
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 8px; background: var(--bg-tertiary);
}
.fm-set-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12.5px; color: var(--text-secondary); }
.fm-set-row select {
background: var(--bg-tertiary); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 12.5px;
}
.fm-chip {
background: var(--bg-tertiary); border: 1px solid var(--border);
color: var(--text-secondary); border-radius: 99px; padding: 4px 12px;
font-size: 12px; cursor: pointer;
}
.fm-chip.active { color: var(--text-primary); border-color: var(--accent); }
/* Corps */
.fm-body { display: flex; flex-direction: column; gap: 8px; }
.fm-promise { color: var(--text-dim); font-size: 13px; padding: 2px 0; }
.fm-hint { color: var(--text-dim); font-size: 12px; }
.fm-head { display: flex; align-items: center; gap: 6px; }
.fm-cal { color: var(--text-dim); font-size: 16px; }
.fm-chev { color: var(--text-dim); font-size: 11px; }
.fm-rule { height: 1px; background: var(--border); margin: 0 -2px; }
/* Étapes Thinking */
.fm-steps { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
.fm-step { display: flex; gap: 10px; align-items: baseline; font-size: 13px; color: var(--text-dim); }
.fm-step.done { color: var(--text-secondary); }
.fm-step.running { color: var(--text-primary); font-weight: 600; }
.fm-step.failed { color: #e5484d; }
.fm-step-ico { width: 18px; text-align: center; }
/* Barre de diffusion */
.fm-sharebar {
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;
font-size: 13px; font-weight: 600;
}
.fm-share-btns { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.fm-chipbtn { font-size: 12px; padding: 5px 10px; font-weight: 500; }
/* Résumé structuré */
.fm-summary { display: flex; flex-direction: column; }
.fm-sum-sec h4 { font-size: 13.5px; font-weight: 700; margin: 10px 0 4px; color: var(--text-primary); }
.fm-bullet { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; line-height: 1.5; }
.fm-bullet > span { flex: 1; }
.fm-cite {
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 11px; vertical-align: super; padding: 0 1px; font-weight: 700;
}
.fm-cite:hover { text-decoration: underline; }
.fm-cite-static { color: var(--accent); font-size: 11px; vertical-align: super; }
.fm-sum-note { color: var(--text-dim); font-size: 12px; font-style: italic; margin-top: 8px; }
.fm-class {
font-size: 12px; color: #f5a623; background: #f5a62314;
border: 1px solid #f5a62344; border-radius: 8px; padding: 6px 10px;
}
/* Transcript groupé par source + rail temporel */
.fm-src { color: var(--text-dim); font-size: 12.5px; margin-top: 8px; }
.fm-seg { display: flex; gap: 8px; padding: 2px 0; font-size: 13px; border-radius: 6px; }
.fm-seg .fm-ts { color: var(--text-dim); font-family: monospace; font-size: 11px; flex-shrink: 0; width: 34px; padding-top: 3px; }
.fm-rail { width: 2px; background: var(--border); border-radius: 2px; flex-shrink: 0; }
.fm-seg-body { flex: 1; min-width: 0; }
.fm-who {
background: none; border: none; color: var(--accent); font-weight: 600;
cursor: pointer; font-size: 12.5px; padding: 0 6px 0 0;
}
.fm-who:hover { text-decoration: underline; }
.fm-flash { background: #2383e233; }
/* Pied de bloc + bandeau */
.fm-foot {
display: flex; align-items: center; gap: 10px; font-size: 12px;
color: var(--text-dim); flex-wrap: wrap;
}
.fm-foot select {
background: transparent; color: var(--text-primary);
border: 1px solid transparent; border-radius: 6px; font-size: 12px; cursor: pointer;
}
.fm-foot select:hover { border-color: var(--border); }
.fm-sep { opacity: .5; }
.fm-consent { flex: 1; min-width: 180px; }
.fm-foot-icons { display: flex; gap: 2px; margin-left: auto; }
.fm-banner {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--text-secondary);
border-top: 1px solid var(--border); padding-top: 8px;
}
.fm-warn {
font-size: 12.5px; color: #f5a623; background: #f5a62314;
border: 1px solid #f5a62344; border-radius: 8px; padding: 7px 10px;
}
.fm-manual { font-size: 12.5px; color: var(--text-secondary); }
.fm-manual summary { cursor: pointer; color: var(--text-dim); padding: 4px 0; }
.fm-manual-row { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; }
/* Hub Meetings : recherche */
.sb-search-row { padding: 0 8px 6px; }
.sb-search-input {
width: 100%; box-sizing: border-box; background: var(--bg-tertiary);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 12px; padding: 5px 9px; outline: none;
}
.sb-search-input:focus { border-color: var(--accent); }
.sb-search-input::-webkit-search-cancel-button { cursor: pointer; }
+348
View File
@@ -0,0 +1,348 @@
/* ═══════════════════════════════════════════════════════════════════
FlowDeck — central syntax-highlighting helper (highlight.js, vendored).
Single source of truth mapping file extensions / well-known filenames to
highlight.js language ids. Covers ~230 file types (code, config, IaC,
docs). Every code viewer (Gitea file view, page-editor code blocks & file
viewer, workspace previews, public pages) must go through this helper so a
new language is added in ONE place.
Requires (load order): highlight.min.js, highlight-extra.min.js.
All functions are defensive: without hljs they degrade to escaped plain
text — never throw, never use eval (CSP-safe).
═══════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
/* Extension (lowercase, no dot) → highlight.js language id.
Ids must exist in static/js/highlight.min.js (common bundle) or
static/js/highlight-extra.min.js — anything else falls back to the
alias table / plaintext via normalizeLang(). */
var EXT_TO_LANG = {
// ── Python ──
py: 'python', pyw: 'python', pyi: 'python', gyp: 'python', gypi: 'python',
// ── JavaScript / TypeScript ──
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
// ── Web markup / styles ──
html: 'xml', htm: 'xml', xhtml: 'xml', xht: 'xml', xml: 'xml',
xsd: 'xml', xsl: 'xml', xslt: 'xml', rss: 'xml', atom: 'xml',
plist: 'xml', wsf: 'xml', svg: 'xml', vue: 'xml', svelte: 'xml',
astro: 'xml', axml: 'xml',
css: 'css', scss: 'scss', sass: 'scss', less: 'less',
// ── Data / config ──
json: 'json', json5: 'json', jsonc: 'json', geojson: 'json',
webmanifest: 'json', avsc: 'json', har: 'json',
yaml: 'yaml', yml: 'yaml',
toml: 'ini', ini: 'ini', cfg: 'ini', conf: 'ini', env: 'ini',
properties: 'ini', prop: 'ini', gitconfig: 'ini', gitmodules: 'ini',
editorconfig: 'ini', inf: 'ini', url: 'ini', reg: 'ini',
// ── Markdown / docs ──
md: 'markdown', markdown: 'markdown', mkd: 'markdown', mdwn: 'markdown',
mdown: 'markdown', rmd: 'markdown', qmd: 'markdown',
tex: 'latex', latex: 'latex', sty: 'latex', cls: 'latex', dtx: 'latex',
// ── SQL ──
sql: 'sql', pgsql: 'sql', psql: 'sql', mysql: 'sql', mssql: 'sql',
// ── Shells ──
// NOTE: no `.shell` extension key (unused); the `shell` grammar stays
// available via `fish`, aliases and the language picker.
sh: 'bash', bash: 'bash', zsh: 'bash', ksh: 'bash', mksh: 'bash',
fish: 'shell', yash: 'bash',
ps1: 'powershell', psm1: 'powershell', psd1: 'powershell',
bat: 'dos', cmd: 'dos',
// ── Systems ──
c: 'c', h: 'c',
cpp: 'cpp', cc: 'cpp', cxx: 'cpp', 'c++': 'cpp', hh: 'cpp',
hpp: 'cpp', hxx: 'cpp', hcc: 'cpp', ino: 'cpp', pde: 'cpp',
cs: 'csharp', csx: 'csharp', cake: 'csharp',
rs: 'rust',
go: 'go',
swift: 'swift',
kt: 'kotlin', kts: 'kotlin',
java: 'java', jsp: 'java',
scala: 'scala', sc: 'scala',
groovy: 'groovy', gvy: 'groovy', gradle: 'groovy',
dart: 'dart',
lua: 'lua',
// NOTE: no `.perl` / `.t` / `.mat` / `.app` / `.gpg` / `.wasm` keys on
// purpose (unused, ambiguous or binary extensions).
pl: 'perl', pm: 'perl', pod: 'perl',
php: 'php', phtml: 'php-template',
r: 'r', jl: 'julia',
rb: 'ruby', gemspec: 'ruby', rake: 'ruby', rbw: 'ruby',
// ── Functional / misc ──
hs: 'haskell', lhs: 'haskell',
ex: 'elixir', exs: 'elixir', eex: 'elixir', heex: 'elixir', leex: 'elixir',
erl: 'erlang', hrl: 'erlang', escript: 'erlang',
clj: 'clojure', cljs: 'clojure', cljc: 'clojure', edn: 'clojure',
ml: 'ocaml', mli: 'ocaml',
fs: 'fsharp', fsi: 'fsharp', fsx: 'fsharp', fsscript: 'fsharp',
nim: 'nim', nims: 'nim',
d: 'd', di: 'd',
m: 'objectivec', mm: 'objectivec',
vb: 'vbnet', vbs: 'vbnet', bas: 'vbnet',
pas: 'delphi', pp: 'delphi', dpr: 'delphi',
// ── HDL / asm / legacy ──
vhd: 'vhdl', vhdl: 'vhdl',
v: 'verilog', vh: 'verilog', sv: 'verilog', svh: 'verilog',
asm: 'x86asm', s: 'x86asm', nasm: 'x86asm', inc: 'x86asm',
ada: 'ada', ads: 'ada', adb: 'ada',
f: 'fortran', for: 'fortran', f90: 'fortran', f95: 'fortran',
f03: 'fortran', f08: 'fortran',
cob: 'plaintext', cbl: 'plaintext', cpy: 'plaintext',
// ── Build / IaC / containers ──
dockerfile: 'dockerfile',
tf: 'plaintext', tfvars: 'plaintext', hcl: 'plaintext',
cmake: 'cmake',
makefile: 'makefile', mk: 'makefile', mak: 'makefile',
ninja: 'ini',
gql: 'graphql', graphql: 'graphql', graphqls: 'graphql',
proto: 'protobuf',
thrift: 'thrift',
vim: 'vim', vimrc: 'vim', gvimrc: 'vim',
awk: 'awk',
gcode: 'gcode', gco: 'gcode', nc: 'gcode', ngc: 'gcode',
// ── Web servers / protocols ──
http: 'http', rest: 'http',
nginx: 'nginx',
apache: 'apache', htaccess: 'apache',
wat: 'wasm',
// ── Diffs / misc text ──
diff: 'diff', patch: 'diff', rej: 'diff',
log: 'plaintext', txt: 'plaintext', text: 'plaintext',
csv: 'plaintext', tsv: 'plaintext', tab: 'plaintext',
lock: 'plaintext', gitignore: 'plaintext', gitattributes: 'plaintext',
gitkeep: 'plaintext', dockerignore: 'plaintext', npmignore: 'plaintext',
bib: 'plaintext', bbl: 'plaintext', nfo: 'plaintext', asc: 'plaintext',
};
/* Exact basenames (lowercase) → language, checked BEFORE the extension
(Dockerfile / Makefile / go.mod … often carry no usable extension). */
var FILE_TO_LANG = {
'dockerfile': 'dockerfile', 'containerfile': 'dockerfile',
'docker-compose.yml': 'yaml', 'docker-compose.yaml': 'yaml',
'compose.yml': 'yaml', 'compose.yaml': 'yaml',
'makefile': 'makefile', 'gnumakefile': 'makefile',
'cmakelists.txt': 'cmake',
'go.mod': 'go', 'go.sum': 'plaintext', 'go.work': 'go',
'cargo.toml': 'ini', 'cargo.lock': 'plaintext',
'package.json': 'json', 'package-lock.json': 'json',
'composer.json': 'json', 'tsconfig.json': 'json',
'nginx.conf': 'nginx',
'.bashrc': 'bash', '.bash_profile': 'bash', '.bash_login': 'bash',
'.bash_logout': 'bash', '.zshrc': 'bash', '.zshenv': 'bash',
'.profile': 'bash', '.shrc': 'bash', '.kshrc': 'bash',
'.vimrc': 'vim', '.gvimrc': 'vim',
'rakefile': 'ruby', 'gemfile': 'ruby', 'vagrantfile': 'ruby',
'podfile': 'ruby', 'fastfile': 'ruby', 'dangerfile': 'ruby',
'brewfile': 'ruby', 'berksfile': 'ruby',
'build.gradle': 'groovy', 'settings.gradle': 'groovy',
'build.gradle.kts': 'kotlin', 'settings.gradle.kts': 'kotlin',
'.gitconfig': 'ini', '.gitmodules': 'ini', '.editorconfig': 'ini',
'.htaccess': 'apache', '.viminfo': 'vim',
};
/* Aliases users may type (markdown fences, old block labels, Prism ids…)
→ canonical highlight.js id. */
var ALIAS_TO_LANG = {
'py': 'python', 'python3': 'python', 'gyp': 'python',
'js': 'javascript', 'jsx': 'javascript', 'mjs': 'javascript',
'cjs': 'javascript', 'node': 'javascript',
'ts': 'typescript', 'tsx': 'typescript',
'html': 'xml', 'htm': 'xml', 'xhtml': 'xml', 'svg': 'xml',
'markup': 'xml', 'vue': 'xml', 'svelte': 'xml',
'yml': 'yaml', 'yaml': 'yaml',
'md': 'markdown', 'mkd': 'markdown',
'sh': 'bash', 'shell': 'shell', 'zsh': 'bash', 'console': 'shell',
'ps1': 'powershell', 'ps': 'powershell', 'powershell': 'powershell',
'bat': 'dos', 'cmd': 'dos', 'batch': 'dos',
'shell-session': 'shell', 'shellsession': 'shell',
'c++': 'cpp', 'cc': 'cpp', 'hpp': 'cpp', 'h': 'c',
'c#': 'csharp', 'csharp': 'csharp', 'cs': 'csharp',
'kt': 'kotlin', 'kts': 'kotlin',
'rb': 'ruby',
'rs': 'rust',
'golang': 'go',
'pl': 'perl', 'pm': 'perl',
'm': 'objectivec', 'mm': 'objectivec', 'objc': 'objectivec',
'r': 'r',
'jl': 'julia',
'toml': 'ini', 'ini': 'ini', 'cfg': 'ini', 'conf': 'ini',
'env': 'ini', 'properties': 'ini',
'tex': 'latex', 'latex': 'latex',
'docker': 'dockerfile',
'make': 'makefile',
'patch': 'diff',
'text': 'plaintext', 'plain': 'plaintext', 'plain text': 'plaintext',
'plaintext': 'plaintext', 'txt': 'plaintext', 'log': 'plaintext',
'none': 'plaintext', '': 'plaintext',
};
/* Language id → short human label for the code-block badge / selector. */
var LANG_LABELS = {
python: 'Python', javascript: 'JavaScript', typescript: 'TypeScript',
xml: 'HTML/XML', css: 'CSS', scss: 'SCSS', less: 'Less',
json: 'JSON', yaml: 'YAML', ini: 'INI/Config', markdown: 'Markdown',
latex: 'LaTeX', sql: 'SQL', bash: 'Bash', shell: 'Shell',
powershell: 'PowerShell', dos: 'Batch', c: 'C', cpp: 'C++',
csharp: 'C#', rust: 'Rust', go: 'Go', swift: 'Swift', kotlin: 'Kotlin',
java: 'Java', scala: 'Scala', groovy: 'Groovy', dart: 'Dart',
lua: 'Lua', perl: 'Perl', php: 'PHP', 'php-template': 'PHP',
r: 'R', julia: 'Julia', ruby: 'Ruby', haskell: 'Haskell',
elixir: 'Elixir', erlang: 'Erlang', clojure: 'Clojure',
ocaml: 'OCaml', fsharp: 'F#', nim: 'Nim', d: 'D',
objectivec: 'Objective-C', matlab: 'MATLAB', vbnet: 'VB.NET',
delphi: 'Delphi', vhdl: 'VHDL', verilog: 'Verilog',
x86asm: 'ASM', armasm: 'ARM ASM', ada: 'Ada', fortran: 'Fortran',
dockerfile: 'Docker', cmake: 'CMake', makefile: 'Makefile',
graphql: 'GraphQL', protobuf: 'Protobuf', thrift: 'Thrift',
vim: 'Vim', awk: 'AWK', gcode: 'G-code',
http: 'HTTP', nginx: 'Nginx', apache: 'Apache', wasm: 'WASM',
diff: 'Diff', plaintext: 'Plain Text',
};
/** Languages offered in the code-block language picker (id order). */
var PICKER_LANGS = [
'plaintext', 'python', 'javascript', 'typescript', 'xml', 'css', 'scss',
'json', 'yaml', 'ini', 'markdown', 'sql', 'bash', 'shell', 'powershell',
'dos', 'c', 'cpp', 'csharp', 'rust', 'go', 'swift', 'kotlin', 'java',
'scala', 'groovy', 'dart', 'lua', 'perl', 'php', 'r', 'julia', 'ruby',
'haskell', 'elixir', 'erlang', 'clojure', 'ocaml', 'fsharp', 'nim', 'd',
'objectivec', 'dockerfile', 'cmake', 'makefile', 'graphql', 'protobuf',
'latex', 'vim', 'http', 'nginx', 'diff',
];
function hasHljs() {
return typeof window.hljs !== 'undefined' && !!window.hljs.getLanguage;
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** Normalize any user label / extension / Prism id to a bundled hljs id. */
function normalizeLang(lang) {
var l = String(lang == null ? '' : lang).trim().toLowerCase();
if (!l || l === 'plain text' || l === 'none') return 'plaintext';
if (ALIAS_TO_LANG[l]) return ALIAS_TO_LANG[l];
if (!hasHljs()) return l || 'plaintext';
try {
if (window.hljs.getLanguage(l)) return l;
} catch (e) { /* ignore → plaintext */ }
return 'plaintext';
}
/** Best highlight.js id for a file path (basename map, then extension). */
function langForFile(path) {
var name = String(path || '').replace(/\\/g, '/').split('/').pop().toLowerCase();
if (!name) return 'plaintext';
if (FILE_TO_LANG[name]) return FILE_TO_LANG[name];
// dotfiles like ".bashrc" without another dot are covered above; a
// trailing ".xyz" is the extension otherwise.
var dot = name.lastIndexOf('.');
if (dot < 0) return 'plaintext';
var ext = name.slice(dot + 1);
if (FILE_TO_LANG['.' + name]) return FILE_TO_LANG['.' + name];
if (EXT_TO_LANG[ext]) return EXT_TO_LANG[ext];
return 'plaintext';
}
function labelFor(lang) {
var n = normalizeLang(lang);
return LANG_LABELS[n] || n;
}
/** Highlight `text` → HTML string (escaped when hljs/lang missing). */
function highlightText(text, lang) {
var src = String(text == null ? '' : text);
var n = normalizeLang(lang);
if (n === 'plaintext' || !hasHljs()) return escHtml(src);
try {
return window.hljs.highlight(src, { language: n }).value;
} catch (e) {
return escHtml(src);
}
}
/**
* Highlight a <code> element in place. The element's TEXT is preserved
* (highlight.js reads textContent), so this is safe for contenteditable
* code blocks: sync() re-reads text, spans are transparent.
* Re-highlighting is skipped when neither language nor text changed
* (cheap length+prefix fingerprint) — e.g. repeated editor renders.
*/
function highlightBlock(el, lang) {
if (!el) return;
var n = normalizeLang(lang || el.getAttribute('data-hljs-lang') || langClassOf(el));
el.setAttribute('data-hljs-lang', n);
if (n !== 'plaintext') {
el.classList.add('hljs');
el.classList.add('language-' + n);
}
if (!hasHljs() || n === 'plaintext') return;
try {
var fp = fingerprint(el);
if (el.dataset && el.dataset.hljsFp === fp) return; // unchanged
window.hljs.highlightElement(el);
if (el.dataset) el.dataset.hljsFp = fp;
} catch (e) { /* keep plain text */ }
}
function langClassOf(el) {
if (!el || !el.className) return '';
var m = /(?:^|\s)language-([\w-]+)/.exec(el.className);
return m ? m[1] : '';
}
function fingerprint(el) {
var t = el.textContent || '';
return (el.getAttribute('data-hljs-lang') || '') + '|' + t.length + ':' + t.slice(0, 64);
}
/**
* Highlight every `pre code` viewer block under `root`:
* `[data-hljs]` (explicit, e.g. editor code blocks) and
* `code[class*=language-]` (Gitea viewer, exports, public pages).
* Editable code blocks are included — their text round-trips losslessly.
*/
function highlightAllIn(root) {
var scope = root || document;
if (!hasHljs() || !scope.querySelectorAll) return 0;
var count = 0;
try {
var nodes = scope.querySelectorAll(
'pre code[data-hljs], pre code[data-hljs-lang], pre code[class*="language-"]'
);
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
var lang = el.getAttribute('data-hljs-lang') || langClassOf(el) || 'plaintext';
var n = normalizeLang(lang);
if (n === 'plaintext') continue;
el.setAttribute('data-hljs-lang', n);
el.classList.add('hljs');
var fp = n + '|' + (el.textContent || '').length + ':' + (el.textContent || '').slice(0, 64);
if (el.dataset && el.dataset.hljsFp === fp) continue;
window.hljs.highlightElement(el);
if (el.dataset) el.dataset.hljsFp = fp;
count++;
}
} catch (e) { /* never break rendering */ }
return count;
}
window.FDHighlight = {
EXT_TO_LANG: EXT_TO_LANG,
FILE_TO_LANG: FILE_TO_LANG,
PICKER_LANGS: PICKER_LANGS,
normalizeLang: normalizeLang,
langForFile: langForFile,
labelFor: labelFor,
highlightText: highlightText,
highlightBlock: highlightBlock,
highlightAllIn: highlightAllIn,
hasHljs: hasHljs,
};
})();
+14 -3
View File
@@ -19,7 +19,8 @@ var _regGiteaWs = () => {
fileSize: 0,
fileSha: '',
fileLoading: false,
fileLanguage: 'text',
fileLanguage: 'plaintext',
fileLanguageLabel: 'Plain Text',
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
@@ -290,17 +291,27 @@ var _regGiteaWs = () => {
this.fileContent = '[Error loading file]';
}
this.fileLoading = false;
// Highlight after Alpine renders
// Highlight after Alpine renders (FDHighlight covers ~230 file types;
// legacy Prism kept as a last-resort fallback).
var self = this;
this.$nextTick(function() {
var block = self.$refs.codeBlock;
if (block && block.textContent && typeof Prism !== 'undefined') {
if (!block || !block.textContent) return;
if (window.FDHighlight) {
try {
FDHighlight.highlightBlock(block, self.fileLanguage);
self.fileLanguageLabel = FDHighlight.labelFor(self.fileLanguage);
} catch (e) { /* plain text */ }
} else if (typeof Prism !== 'undefined') {
Prism.highlightElement(block);
}
});
},
getLanguage(path) {
if (window.FDHighlight) {
try { return FDHighlight.langForFile(path); } catch (e) { /* fall through */ }
}
var ext = (path || '').split('.').pop().toLowerCase();
var map = {
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
+776
View File
File diff suppressed because one or more lines are too long
+1213
View File
File diff suppressed because one or more lines are too long
+66 -8
View File
@@ -35,6 +35,9 @@ function libraryPage() {
shareDir: 'all',
showAuthor: false,
showSource: false,
showEditedBy: true,
showEditedTime: true,
showVisited: true,
// Tags
workspaceTags: [],
@@ -90,6 +93,12 @@ function libraryPage() {
for (var i = 0; i < this.flatItems.length; i++) {
var item = this.flatItems[i];
// ── En-tête de section de date (onglet AI Meeting Notes) ──
if (item._isSection) {
html += '<div class="lib-section-header">' + self._escHtml(item.title) + '</div>';
continue;
}
// ── Synthetic "+ Add new" placeholder row for an expanded parent ──
if (item._isAddNew) {
var pad = 30 + (item.depth || 0) * 20;
@@ -154,10 +163,19 @@ function libraryPage() {
html += '<div class="row-cell lib-col-author"><div class="row-author">';
html += '<div class="author-avatar">' + (item.author_initial || '?') + '</div>';
html += '<span>' + (item.author || '\u2014') + '</span></div></div>';
// Source
html += '<div class="row-cell lib-col-source"><span class="row-source">' + (item.source_label || 'Private') + '</span></div>';
// Last edited by
html += '<div class="row-cell lib-col-editedby"><div class="row-author">';
html += '<div class="author-avatar">' + (item.edited_by_initial || '?') + '</div>';
html += '<span>' + (item.edited_by || '\u2014') + '</span></div></div>';
// Edited
html += '<div class="row-cell lib-col-edited"><span class="row-time">' + self.formatTime(item.updated_at) + '</span></div>';
// Last visited
html += '<div class="row-cell lib-col-visited"><span class="row-time">' +
(item.visited_at ? self.formatTime(item.visited_at) : '\u2014') + '</span></div>';
// Source (icône de source + libellé) — en dernier, comme l'en-tête
html += '<div class="row-cell lib-col-source"><span class="row-source">' +
getSvgIcon(item.source_icon || 'file', 13) +
'<span>' + (item.source_label || 'Private') + '</span></span></div>';
html += '</div>';
}
body.innerHTML = html;
@@ -202,6 +220,7 @@ bindHtmlItem(el, item) {
},
_renderIcon(item) {
if (!item) return getSvgIcon('file', 14);
if (item.is_meeting) return getSvgIcon('calendar', 14);
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
@@ -226,13 +245,47 @@ _renderIcon(item) {
return (r.tags || []).some(function(t) { return t.name === tf; });
});
}
if (this.tab === 'meetings') result = this._groupMeetings(result);
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
this.flatItems.splice(0, this.flatItems.length, ...result);
this.selectedCount = Object.keys(this.selected).length;
this.allSelected = this.flatItems.length > 0 && this.selectedCount === this.flatItems.length;
var selectable = result.filter(function(r) { return !r._isSection; });
this.allSelected = selectable.length > 0 && this.selectedCount === selectable.length;
this._renderTable();
},
// Regroupement par date pour l'onglet AI Meeting Notes : sections
// Today / Yesterday / This week / Last week / This month / Older.
_meetingBucket(updatedAt) {
var d = new Date(String(updatedAt || '').replace(' ', 'T'));
if (isNaN(d.getTime())) return 5;
var now = new Date();
var start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var diff = Math.floor((start.getTime() - new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()) / 86400000);
if (diff <= 0) return 0;
if (diff === 1) return 1;
if (diff <= 7) return 2;
if (diff <= 14) return 3;
if (diff <= 30) return 4;
return 5;
},
_groupMeetings(items) {
var labels = ['Today', 'Yesterday', 'This week', 'Last week', 'This month', 'Older'];
var buckets = [[], [], [], [], [], []];
for (var i = 0; i < items.length; i++) {
if (items[i]._isSection) continue;
buckets[this._meetingBucket(items[i].updated_at)].push(items[i]);
}
var out = [];
for (var b = 0; b < buckets.length; b++) {
if (!buckets[b].length) continue;
out.push({ _isSection: true, id: 'sec-' + b, title: labels[b] });
for (var j = 0; j < buckets[b].length; j++) out.push(buckets[b][j]);
}
return out;
},
// ── Global dispatch (bypasses Alpine x-for reactivity issues) ──
toggleItemById(id) {
var item = this._findItem(id);
@@ -399,7 +452,7 @@ _renderIcon(item) {
},
_canAddChild() {
return this.tab !== 'favorites' && this.tab !== 'shared' && this.tab !== 'published' && this.tab !== 'repository';
return this.tab !== 'favorites' && this.tab !== 'shared' && this.tab !== 'published' && this.tab !== 'repository' && this.tab !== 'meetings';
},
_getCsrf() {
@@ -443,13 +496,14 @@ _renderIcon(item) {
emptyText: '',
_recomputeEmpty() {
var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
var icons = {recents:'clock',meetings:'calendar',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
this.emptyIcon = icons[this.tab] || 'file';
var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages',
var titles = {recents:'No recent pages',meetings:'No meeting notes',favorites:'No favorites',shared:'No shared pages',
published:'No published pages',private:'No private pages',workspace:'No workspace pages',
repository:'Empty repository'};
this.emptyTitle = titles[this.tab] || 'No items';
var texts = {recents:'Pages you\'ve recently viewed or edited will appear here.',
meetings:'Create a meeting note from the sidebar (+) — it will appear here, grouped by date.',
favorites:'Right-click a page in the sidebar and select "Add to Favorites".',
shared:'Shared pages from your team will appear here.',
published:'Pages published to the web will appear here.',
@@ -478,7 +532,7 @@ _renderIcon(item) {
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
var params = new URLSearchParams(window.location.search);
var t = params.get('tab');
if (t && ['recents','favorites','shared','published','private','workspace','repository'].indexOf(t) !== -1) {
if (t && ['recents','meetings','favorites','shared','published','private','workspace','repository'].indexOf(t) !== -1) {
this.tab = t;
}
this._recomputeEmpty();
@@ -524,7 +578,7 @@ _renderIcon(item) {
if (this.tab === 'repository' && this.isGiteaActive) {
url += sep + 'gitea_owner=' + encodeURIComponent(this.giteaOwner) + '&gitea_repo=' + encodeURIComponent(this.giteaRepo);
sep = '&';
} else if (this.tab !== 'repository' && this.tab !== 'recents' && this.tab !== 'favorites') {
} else if (this.tab !== 'repository' && this.tab !== 'recents' && this.tab !== 'favorites' && this.tab !== 'meetings') {
// Hierarchical tree view: show only root-level pages at the top
url += sep + 'tree=1';
sep = '&';
@@ -573,6 +627,9 @@ _renderIcon(item) {
toggleColumn(col) {
if (col === 'author') this.showAuthor = !this.showAuthor;
else if (col === 'source') this.showSource = !this.showSource;
else if (col === 'editedby') this.showEditedBy = !this.showEditedBy;
else if (col === 'editedtime') this.showEditedTime = !this.showEditedTime;
else if (col === 'visited') this.showVisited = !this.showVisited;
},
doSort() {
@@ -650,6 +707,7 @@ _renderIcon(item) {
} else {
var s = {};
for (var i = 0; i < this.flatItems.length; i++) {
if (this.flatItems[i]._isSection || this.flatItems[i]._isAddNew) continue;
s[this.flatItems[i].id] = true;
}
this.selected = s;
+109 -10
View File
@@ -1,6 +1,12 @@
// Config de la page courante — relue à CHAQUE exécution du script : sur une
// navigation partielle htmx le <script> est ré-exécuté (avec le nouveau
// #lw-config) alors que le corps du fichier est verrouillé par le garde
// ci-dessous. Avec un `const` capturé à la première exécution, la page
// gardait le folder/workspace du premier chargement.
var LW = (function(){try{var el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
if (!window.__fdLocalWorkspaceScriptsLoaded) {
window.__fdLocalWorkspaceScriptsLoaded = true;
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
const _wsInitData = (function() { // lexical : NON propriété globalThis →
@@ -150,6 +156,19 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
try {
// Expose globally for event handlers (BEFORE await)
window._wsData = this;
// Navigation partielle : l'objet est partagé entre les montages →
// on recale l'état sur la page courante (#lw-config fraîchement lu)
// et on remet à zéro les panneaux/menus de la vue précédente.
if (LW && LW.current_folder_id != null) this.currentFolder = LW.current_folder_id;
this.searchQuery = '';
this.filterType = '';
this.editingPath = false;
this.previewItem = null;
this.uploading = false;
this.undoVisible = false;
this.moveModalOpen = false;
this.isDragging = false;
this.clearSelection();
var url = '/api/local-workspace/tree';
if (this.currentFolder && this.currentFolder > 0) {
url += '?folder=' + this.currentFolder;
@@ -197,6 +216,32 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
this._recompute();
this._initResizeHandle();
this._syncHeaderPath();
},
/** Recalcule le chemin du header (Home / <workspace> / <dossier> / …) à
partir de la pile de dossiers courante — même mécanique que le rendu
serveur, mais sans rechargement. */
_syncHeaderPath() {
var stack = this.folderStack || [];
var crumbs = [
{ label: 'Home', url: '/workspaces', id: null, icon: 'home', menu: false, home: true },
{ label: (LW && LW.workspace_name) || 'Workspace', url: '/local-workspace',
id: null, icon: 'folder', menu: false }
];
for (var i = 0; i < stack.length; i++) {
var last = (i === stack.length - 1);
crumbs.push({
label: stack[i].name,
url: last ? null : '/local-workspace?folder=' + stack[i].id,
id: stack[i].id,
icon: 'folder',
menu: true
});
}
window.dispatchEvent(new CustomEvent('flowdeck:breadcrumb-changed', {
detail: { workspace_id: (LW && LW.workspace_id) || 0, crumbs: crumbs }
}));
},
_initResizeHandle() {
@@ -486,6 +531,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
if (b.type === 'code' || b.type === 'code_block') {
tag = 'pre';
style += 'background:var(--bg-tertiary);padding:10px 14px;border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-wrap;';
var cLang = (window.FDHighlight && b.language) ? FDHighlight.normalizeLang(b.language) : '';
var cAttr = (cLang && cLang !== 'plaintext') ? ' data-hljs data-hljs-lang="' + cLang + '" class="hljs language-' + cLang + '"' : '';
var cHtml = (window.FDHighlight && cLang && cLang !== 'plaintext')
? (function(){ try { return FDHighlight.highlightText(txt, cLang); } catch (e) { return ''; } })() || this._escHtml(txt)
: this._escHtml(txt);
return '<' + tag + ' style="' + style + '"><code' + cAttr + '>' + (txt ? cHtml : '<br>') + '</code></' + tag + '>';
}
return '<' + tag + ' style="' + style + '">' + (txt ? this._escHtml(txt) : '<br>') + '</' + tag + '>';
}, this).join('');
@@ -493,7 +544,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">Empty document</div>';
}
} catch {
preview = '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 3000)) + '</pre>';
preview = this._codeHtml(String(content).substring(0, 3000), node.name, 3000);
}
} else if (format === 'markdown') {
@@ -501,15 +552,16 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
preview = '<div style="white-space:pre-wrap;line-height:1.6;font-size:13px;">' + this._escHtml(String(content).substring(0, 5000)) + '</div>';
} else {
// Unknown format — show as text
// Unknown format — show as (highlighted) text
var display = String(content || '').trim();
if (display) {
preview = '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">' + this._escHtml(display.substring(0, 5000)) + '</pre>';
preview = this._codeHtml(display, node.name, 5000);
} else {
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">No content</div>';
}
}
body.innerHTML = preview || '<div style="color:var(--text-tertiary);text-align:center;padding:20px">(empty)</div>';
try { if (window.FDHighlight) FDHighlight.highlightAllIn(body); } catch (e) {}
} catch {
body.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-tertiary)">Preview not available</div>';
}
@@ -519,6 +571,23 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
// ── hljs: code-aware <pre> for previews (FDHighlight, ~230 file types).
// Falls back to escaped plain text when the helper is unavailable.
_codeHtml(text, name, maxLen) {
var t = String(text == null ? '' : text).substring(0, maxLen || 5000);
if (window.FDHighlight) {
try {
var lang = FDHighlight.langForFile(name || '');
var inner = FDHighlight.highlightText(t, lang);
var cls = lang !== 'plaintext' ? ' class="hljs language-' + lang + '"' : '';
return '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">'
+ '<code' + cls + '>' + inner + '</code></pre>';
} catch (e) { /* fallback below */ }
}
return '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">'
+ this._escHtml(t) + '</pre>';
},
// ── Content snippet for content view ──
contentSnippetMap: {},
@@ -765,7 +834,10 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
html += '<span class="icon">'+icon+'</span>';
if (c.is_folder) {
var countBadge = (c.child_count > 0) ? ' <span class="child-count">('+c.child_count+')</span>' : '';
html += '<span class="name folder" onclick="window._wsData.navigateToFolder('+escId+')">'+c.name+countBadge+'</span>';
var statusBadges = (c.is_shared ? '<span class="ws-shared-badge" title="Partag\u00e9" onclick="event.stopPropagation()">👥</span>' : '')
+ (c.favorited ? '<span class="ws-fav-badge" title="Favorite">★</span>' : '')
+ (c.published ? '<span class="ws-pub-badge" title="Published to web">🌐</span>' : '');
html += '<span class="name folder" onclick="window._wsData.navigateToFolder('+escId+')">'+statusBadges+c.name+countBadge+'</span>';
} else {
// Click to rename, double-click to open
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
@@ -774,6 +846,8 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
+ (c.is_shared ? '<span class="ws-shared-badge" title="Partag\u00e9" onclick="event.stopPropagation()">👥</span>' : '')
+ (c.favorited ? '<span class="ws-fav-badge" title="Favorite">★</span>' : '')
+ (c.published ? '<span class="ws-pub-badge" title="Published to web">🌐</span>' : '')
+ '<span id="rc-name-'+escId+'">'+c.name+'</span>'
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none"'
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
@@ -956,6 +1030,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
this._saveExpandedState();
this.previewItem = null;
this._recompute();
this._syncHeaderPath();
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
} catch(e) { console.error('Load folder error:', e); }
@@ -1230,8 +1305,9 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
} else {
this.previewContent = 'Loading...';
this._fetchRetry(url).then(function(t) {
var content = t.substring(0, 500);
self.previewContent = content; self.previewCache[cacheKey] = content;
var html = self._codeHtml(t, node.name, 500);
// Tooltip body is already monospace/pre-wrap: keep it lightweight.
self.previewContent = html; self.previewCache[cacheKey] = html;
}).catch(function() { self.previewContent = '(preview not available)'; });
}
} else {
@@ -2043,9 +2119,32 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsI
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
var _regWs = function () {
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
Alpine.data('wsInitData', function () { return _wsInitData; });
// Objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji). L'état reste unique, MAIS la factory Alpine
// doit retourner une enveloppe FRAÎCHE à chaque montage : Alpine pose ses
// magics ($nextTick…) avec Object.defineProperty non-configurable. En
// retournant le même objet, le 2e montage (navigation partielle
// /local-workspace → /local-workspace?folder=) levait
// « Cannot redefine property: $nextTick », initTree échouait et TOUT le
// contenu restait au state brut (tous les x-show visibles à la fois).
// L'enveloppe délègue lecture/écriture au proxy réactif partagé.
Alpine.data('wsInitData', function () {
var R = Alpine.reactive(_wsInitData);
var t = {};
Object.keys(_wsInitData).forEach(function (k) {
if (typeof _wsInitData[k] === 'function') {
t[k] = _wsInitData[k];
} else {
Object.defineProperty(t, k, {
get: function () { return R[k]; },
set: function (v) { R[k] = v; },
enumerable: true,
configurable: true,
});
}
});
return t;
});
// Bloc preview : le parseur le place HORS de la div racine (structure
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
File diff suppressed because it is too large Load Diff
+51 -4
View File
@@ -13,6 +13,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
]},{name:'AI',items:[
{id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'},
{id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'},
{id:'meeting',name:'AI Meeting Notes',icon:'🎙️'},
]},
{name:'MEDIA',items:[
{id:'image',name:'Image',icon:'🖼️'},{id:'embed',name:'Embed',icon:'🔗'},
@@ -398,7 +399,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// ── Block type shortcuts (shown on the right of each slash item) ──
// Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---)
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',meeting:'/meet',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions',SYNCED:'Synced'};
// ── Hover preview card (matches Notion's visual hint) ──
function _previewHtml(blockId){
@@ -426,6 +427,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
case 'video': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎬 Inline video player</div>');
case 'audio': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎵 Inline audio player</div>');
case 'database': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px">📊 Inline database table</div>');
case 'meeting': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px">🎙️ <b>AI Meeting Notes</b><br><span style="color:var(--text-dim)">Record, transcribe and summarize this meeting.</span></div>');
default: return p('<div style="font-size:13px;color:var(--text-dim)">Text block</div>');
}
}
@@ -751,6 +753,28 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
};
window.AIAC=AIAC;
// ── Code block renderer (hljs: highlight.js syntax highlighting) — literal
// text (esc, real newlines — NOT mdEsc, whose <br> would break
// highlight.js textContent reads) plus a language picker driving
// FDHighlight (~230 file types). Highlight spans are transparent to
// sync(): gt()/gtTok() re-read text only.
function renderCodeBlock(b){
var rawLang=b.language||'Plain Text';
var norm=(window.FDHighlight?FDHighlight.normalizeLang(rawLang):String(rawLang||'plaintext').toLowerCase().replace(/[^a-z0-9+#-]+/g,''))||'plaintext';
var ro=!!(window.E&&E.canEdit===false);
var head;
if(!ro&&window.FDHighlight){
var opts=FDHighlight.PICKER_LANGS.map(function(id){
return '<option value="'+id+'"'+(id===norm?' selected':'')+'>'+esc(FDHighlight.labelFor(id))+'</option>';
}).join('');
head='<div class="code-lang-label code-lang-bar"><select class="code-lang-select" onchange="E.setCodeLang(\''+b.id+'\',this.value)" title="Code language — syntax highlighting">'+opts+'</select></div>';
}else{
var label=(window.FDHighlight?FDHighlight.labelFor(norm):rawLang);
head='<div class="code-lang-label">'+esc(label)+'</div>';
}
return head+'<pre><code data-bid="'+b.id+'" data-hljs data-hljs-lang="'+norm+'" class="language-'+norm+'" contenteditable="'+(ro?'false':'true')+'" spellcheck="false">'+esc(b.content||'')+'</code></pre>';
}
function renderBlock(b,idx){
if(b.type==='meeting')return renderMeetingBlock(b,idx);
if(b.type==='table')return renderTableBlock(b,idx);
@@ -813,7 +837,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
let inner='';
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<div class="toggle-title-row"><button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div class="toggle-title" data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div></div><div class="toggle-children" ${(b.expanded===false)?'style="display:none"':''}>${(b.children||[]).map((ch)=>renderNestedBlock(ch)).join('')}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${mdEsc(b.content)}</code></pre>`;
else if(b.type==='code')inner=renderCodeBlock(b);
else if(b.type==='callout')inner=`<span class="callout-icon" onclick="E._toggleCalloutIcon('${b.id}')" title="Change icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div><div class="callout-icon-picker" id="callout-picker-${b.id}" style="display:none">${CALLOUT_ICONS.map(function(i){return '<button type="button" class="callout-icon-opt" onclick="E._setCalloutIcon(\''+b.id+'\',\''+i+'\')">'+i+'</button>';}).join('')}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
@@ -1656,12 +1680,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(mime==='application/pdf'||ext==='pdf'){
this.blocks=[this.mkB('embed','',{src:url,embed_type:'pdf'})];this.render();return;
}
const codeExts=['py','js','ts','jsx','tsx','html','htm','css','json','xml','yaml','yml','md','markdown','sql','sh','bash','ps1','bat','cmd','rb','go','rs','java','c','cpp','h','hpp','php','swift','kt','scala','r','toml','ini','cfg','conf','env','txt','log'];
const _fh=(typeof window!=='undefined'&&window.FDHighlight)?window.FDHighlight:null;
const codeExts=_fh?Object.keys(_fh.EXT_TO_LANG):['py','js','ts','jsx','tsx','html','htm','css','json','xml','yaml','yml','md','markdown','sql','sh','bash','ps1','bat','cmd','rb','go','rs','java','c','cpp','h','hpp','php','swift','kt','scala','r','toml','ini','cfg','conf','env','txt','log'];
const langMap={'py':'python','js':'javascript','ts':'typescript','jsx':'javascript','tsx':'typescript','html':'html','htm':'html','css':'css','json':'json','xml':'xml','yaml':'yaml','yml':'yaml','md':'markdown','markdown':'markdown','sql':'sql','sh':'bash','bash':'bash','ps1':'powershell','bat':'plaintext','cmd':'plaintext','rb':'ruby','go':'go','rs':'rust','java':'java','c':'c','cpp':'cpp','h':'c','hpp':'cpp','php':'php','swift':'swift','kt':'kotlin','scala':'scala','r':'r','toml':'toml','ini':'ini','cfg':'ini','conf':'ini','env':'ini','txt':'plaintext','log':'plaintext'};
if(mime.startsWith('text/')||codeExts.includes(ext)){
try{const r=await fetch(url);const text=await r.text();
if(ext==='md'||ext==='markdown'){this.blocks=this.md2b(text);}
else{this.blocks=[this.mkB('code',text,{language:langMap[ext]||'Plain Text'})];}
else{const lang=_fh?_fh.langForFile(name):(langMap[ext]||'Plain Text');this.blocks=[this.mkB('code',text,{language:lang})];}
this.render();
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
return;
@@ -1717,6 +1742,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// hljs: syntax-highlight code blocks (FDHighlight skips unchanged
// blocks via content fingerprint; spans are sync-transparent).
try{if(window.FDHighlight)FDHighlight.highlightAllIn(ct);}catch(e){}
// Render math blocks with KaTeX
var self=this;
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
@@ -1983,6 +2011,8 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
// hljs: code-block language picker (drives FDHighlight re-render).
setCodeLang(bid,lang){const idx=this.getIdx(bid);if(idx<0)return;const b=this.blocks[idx];if(!b||b.type!=='code')return;this.sync();this.pushHistory();b.language=(window.FDHighlight?FDHighlight.normalizeLang(lang):lang)||'Plain Text';this.dirty=true;this.autoSave();this.render();_rtSync();},
turnInto(idx,type,cols){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='columns'&&cols>=2){b.children=Array.from({length:cols},()=>({id:genId(),type:'paragraph',content:''}));}if(type==='synced'){b.synced_id=0;b._synced_content=[];b._synced_title='';}if(type==='button'){b.automation_id=0;b.automation_name='';}});
if(type==='synced')setTimeout(()=>this._pickSyncedBlock(idx),80);
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
@@ -2702,6 +2732,23 @@ applyAIBlocks(text){
}
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
if(id==='duplicate'){this.duplicateBlock(idx);return;}
if(id==='meeting'){
// v7.1.1 : bloc AI Meeting Notes (façon Notion) — même action que
// meetingNote(), mais en place dans le bloc courant (/meet).
const block=this.blocks[idx];this.sync();this.pushHistory();
const today=new Date();const iso=today.toISOString().slice(0,10);
block.type='meeting';block.content='';
block.meeting={v:2,title:'Meeting @'+iso,date:iso,state:'idle',
mode:'mic_only',instruction:'auto',lang:'fr',consent:null,
consentAudio:false,tab:'notes',channels:[],segments:[],segSeq:0,
notes:'',manual:'',interim:'',startedAt:0,elapsedBase:0,pausedMs:0,
steps:[],summaryMd:'',completeness:'',genTitle:'',titleEdited:false,
shareHidden:false,bannerHidden:false,transcriptId:null,error:'',
remoteSilentWarned:false,menuOpen:false,settingsOpen:false};
this.render();this.dirty=true;this.autoSave();_rtSync();
setTimeout(()=>{const el=document.querySelector('[data-meeting-bid="'+block.id+'"]');if(el&&el.scrollIntoView)el.scrollIntoView({behavior:'smooth',block:'center'});},60);
return;
}
if(id==='delete'){this.removeBlock(idx);return;}
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;}
+4 -2
View File
@@ -20,7 +20,7 @@ const PRECACHE_URLS = [
'/static/css/design-tokens.css?v=5.2.0',
'/static/css/components.css?v=5.2.0',
'/static/css/katex.min.css?v=0.16.11',
'/static/css/prism.css',
'/static/css/highlight.css',
// JS
'/static/js/htmx.min.js',
'/static/js/alpine.csp.min.js',
@@ -28,7 +28,9 @@ const PRECACHE_URLS = [
'/static/js/app.js?v=2.4.8',
'/static/js/offline.js?v=6.0.0',
'/static/js/katex.min.js?v=0.16.11',
'/static/js/prism.min.js',
'/static/js/highlight.min.js',
'/static/js/highlight-extra.min.js',
'/static/js/code-highlight.js',
// Icônes & métadonnées
'/static/favicon.ico',
'/static/manifest.json',
+240
View File
@@ -0,0 +1,240 @@
"""FlowDeck v7.69.6 — Library AI Meeting Notes, Recents sidebar, Publish unifié.
- ``GET /api/library/meetings`` : notes de réunion groupées par date côté
client ; exclues des onglets recents/workspace/private.
- Sidebar Recents : fichiers réellement récents (``_recent_sidebar_pages``),
icônes workspace, notes de réunion exclues.
- Publish : ``is_published`` + ``published`` maintenus ensemble — la page
publiée apparaît dans la sidebar Published, la Library et le dialogue.
"""
from __future__ import annotations
import json
import secrets
from conftest import anon_csrf # noqa: F401
from app.db import get_conn
def _login(client):
from app.auth.session import SessionManager
login = f"v696_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V696', ?, 0)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
return SessionManager.create_session({"id": uid, "login": login}), uid
def _cookies(session):
return {"flowdeck_session": session}
def _mkworkspace(uid, name="WS696"):
with get_conn() as conn:
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, uid))
conn.commit()
return cur.lastrowid
def _mkpage(ws_id, title, with_meeting=False, updated="2026-10-09 12:00:00"):
content = json.dumps([{"id": "b1",
"type": "meeting" if with_meeting else "paragraph",
"content": ""}])
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format,"
" parent_section, updated_at) VALUES ('t', ?, ?, ?, 'blocks', 'Private', ?)",
(ws_id, title, content, updated))
conn.commit()
return cur.lastrowid
# ── Library meetings ─────────────────────────────────────────────────────
def test_library_meetings_tab_lists_only_meeting_notes(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
mp = _mkpage(ws, "Sprint Review", True)
_mkpage(ws, "Plain notes", False)
r = client.get("/api/library/meetings", cookies=_cookies(session))
assert r.status_code == 200, r.text
items = r.json()["items"]
assert [i["id"] for i in items] == [mp]
assert items[0]["is_meeting"] is True
assert items[0]["url"] == f"/pages/{mp}"
def test_library_browsing_tabs_hide_meeting_notes(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
_mkpage(ws, "Sprint Review", True, updated="2026-10-09 12:00:00")
plain = _mkpage(ws, "Plain notes", False, updated="2026-10-09 13:00:00")
for tab in ("recents", "workspace", "private"):
r = client.get(f"/api/library/{tab}", cookies=_cookies(session))
assert r.status_code == 200, r.text
ids = [i["id"] for i in r.json()["items"]]
assert plain in ids
assert not any(i.get("is_meeting") for i in r.json()["items"])
assert len([i for i in ids if isinstance(i, int)]) == len(ids)
# ── Sidebar recents ──────────────────────────────────────────────────────
def test_recent_sidebar_pages_excludes_meetings_and_uses_workspace_icons(client):
from app.routers.dashboard._common import _recent_sidebar_pages
session, uid = _login(client)
ws = _mkworkspace(uid)
_mkpage(ws, "Sprint Review", True)
plain = _mkpage(ws, "Plain notes", False)
recents = _recent_sidebar_pages(uid)
ids = [e["id"] for e in recents]
assert f"page/{plain}" in ids
assert not any("Sprint" in e["name"] for e in recents)
icons = {e["id"]: e["icon"] for e in recents}
assert icons[f"page/{plain}"] == "edit" # même convention que le Workspace
assert all(e["url"].startswith("/pages/") for e in recents)
# ── Publish unifié ───────────────────────────────────────────────────────
def test_publish_sets_both_flags_and_lists(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
pid = _mkpage(ws, "Launch notes", False)
r = client.post(f"/api/pages/{pid}/publish", cookies=_cookies(session))
assert r.status_code == 200, r.text
assert r.json()["is_published"] is True
with get_conn() as conn:
row = conn.execute("SELECT is_published, published, publish_slug FROM pages"
" WHERE id=?", (pid,)).fetchone()
assert row["is_published"] == 1 and row["published"] == 1
assert row["publish_slug"]
# Library Published + sidebar Published voient la page.
r = client.get("/api/library/published", cookies=_cookies(session))
assert pid in [i["id"] for i in r.json()["items"]]
from app.routers.board._common import _load_shared_sidebar_pages
_made, _received, published, _shared = _load_shared_sidebar_pages(uid)
assert any(p["db_id"] == pid for p in published)
# Le dialogue (page_data) reflète l'état.
r = client.get(f"/pages/{pid}", cookies=_cookies(session))
assert r.status_code == 200
assert '"published": true' in r.text
# Unpublish nettoie les deux drapeaux.
r = client.delete(f"/api/pages/{pid}/publish", cookies=_cookies(session))
assert r.json()["is_published"] is False
with get_conn() as conn:
row = conn.execute("SELECT is_published, published FROM pages WHERE id=?",
(pid,)).fetchone()
assert row["is_published"] == 0 and row["published"] == 0
r = client.get("/api/library/published", cookies=_cookies(session))
assert pid not in [i["id"] for i in r.json()["items"]]
def test_share_dialog_publish_path_sets_both_flags_and_slug(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
pid = _mkpage(ws, "Dialog publish", False)
r = client.post(f"/board/api/share/{pid}",
json={"mode": "private", "published": True},
cookies=_cookies(session))
assert r.status_code == 200, r.text
with get_conn() as conn:
row = conn.execute("SELECT is_published, published, publish_slug FROM pages"
" WHERE id=?", (pid,)).fetchone()
assert row["is_published"] == 1 and row["published"] == 1
assert row["publish_slug"]
r = client.get("/api/library/published", cookies=_cookies(session))
assert pid in [i["id"] for i in r.json()["items"]]
def test_customize_panel_above_overlay():
from pathlib import Path
css = (Path(__file__).resolve().parent.parent
/ "static" / "css" / "app.css").read_text(encoding="utf-8")
panel = css.split(".sidebar-customize-panel")[1].split("}")[0]
assert "z-index: 999" in panel
def test_library_column_selectors_beat_row_rules():
"""Show columns : les règles de visibilité doivent battre
``.lib-table-header .th`` et ``.lib-row .row-cell`` (0,2,0) — avant,
seul ``:not(.show-editedtime)`` gagnait et seule Last edited time
s'affichait/masquait (vérifié en direct au navigateur)."""
from pathlib import Path
html = (Path(__file__).resolve().parent.parent
/ "app" / "templates" / "library.html").read_text(encoding="utf-8")
for cls in ("lib-col-author", "lib-col-editedby",
"lib-col-source", "lib-col-visited"):
assert f".lib-table div.{cls}" in html, cls
# ── v7.69.6b : provenance, badges, favoris ─────────────────────────────────
def _save_blocks(client, session, pid, title="T"):
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": title,
"blocks": [{"id": "b1", "type": "paragraph",
"content": "hello"}]},
cookies=_cookies(session))
assert r.status_code == 200, r.text
def test_library_provenance_columns(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
pid = _mkpage(ws, "Prov notes", False)
_save_blocks(client, session, pid, "Prov notes")
r = client.get("/api/library/recents", cookies=_cookies(session))
assert r.status_code == 200, r.text
items = [i for i in r.json()["items"] if i["id"] == pid]
assert len(items) == 1
it = items[0]
assert it["author"], "Created by must resolve (versions, sinon owner)"
assert it["edited_by"], "Last edited by must resolve"
assert it["source_icon"] in ("file", "link", "external-link")
assert it["visited_at"] == "", "not visited yet"
# Lecture → visite tracée.
assert client.get(f"/pages/{pid}", cookies=_cookies(session)).status_code == 200
r = client.get("/api/library/recents", cookies=_cookies(session))
it = [i for i in r.json()["items"] if i["id"] == pid][0]
assert it["visited_at"], "Last visited time must be tracked on view"
def test_favorites_use_workspace_icons(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
pid = _mkpage(ws, "Fav notes", False)
with get_conn() as conn:
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,0)",
(uid, pid))
conn.commit()
r = client.get("/api/library/favorites", cookies=_cookies(session))
assert r.status_code == 200, r.text
items = [i for i in r.json()["items"] if i["id"] == pid]
assert len(items) == 1
assert items[0]["icon"] in ("folder", "file", "edit", "image")
def test_local_workspace_tree_status_flags(client):
from app.routers.dashboard._common import _build_tree_children
session, uid = _login(client)
ws = _mkworkspace(uid)
pid = _mkpage(ws, "Flagged notes", False)
with get_conn() as conn:
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,0)",
(uid, pid))
conn.execute("UPDATE pages SET is_published=1, published=1, publish_slug='x',"
" share_mode='anyone' WHERE id=?", (pid,))
conn.commit()
tree = _build_tree_children(conn, None, ws, uid)
nodes = [n for n in tree if n["id"] == pid]
assert len(nodes) == 1
assert nodes[0]["favorited"] is True
assert nodes[0]["published"] is True
assert nodes[0]["is_shared"] is True
+328
View File
@@ -0,0 +1,328 @@
"""FlowDeck v7.1.1 — Meeting block façon Notion (machine à états, consentement,
segments, résumé structuré, partage)."""
from __future__ import annotations
import secrets
import pytest
from conftest import anon_csrf # noqa: F401
from app.db import get_conn
def _login(client):
from app.auth.session import SessionManager
login = f"v711_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V711', ?, 0)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
return SessionManager.create_session({"id": uid, "login": login}), uid
def _cookies(session):
return {"flowdeck_session": session}
def _mkpage(title="Meeting"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', ?, '', 'blocks')", (title,))
conn.commit()
return cur.lastrowid
def _mktr(client, session, pid=None, **kw):
pid = pid or _mkpage()
r = client.post("/api/v2/meetings/transcribe",
files={"audio": ("rec.mp3", b"ID3" + b"\x00" * 50, "audio/mpeg")},
data={"page_id": str(pid), **{k: str(v) for k, v in kw.items()}},
cookies=_cookies(session))
assert r.status_code == 201, r.text
return r.json()["id"]
def test_consent_gate_blocks_start(client):
session, _ = _login(client)
tid = _mktr(client, session)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
json={"mode": "mic_only"}, cookies=_cookies(session))
assert r.status_code == 400
assert "consent" in r.text.lower()
def test_full_state_machine_offline_degraded(client):
session, _ = _login(client)
pid = _mkpage("Meeting @Today")
tid = _mktr(client, session, pid)
# consentement par attestation au démarrage (§3A.2)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
json={"method": "start_attestation"}, cookies=_cookies(session))
assert r.status_code == 200
# start avec photo d'instructions
r = client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
json={"mode": "tab_plus_mic", "language": "fr",
"instruction_id": "standup"},
cookies=_cookies(session))
assert r.status_code == 200, r.text
assert r.json()["status"] == "recording"
# segments groupés par source, marque de troncature (§3A.7)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
json={"segments": [
{"channel": "self", "source_label": "Bruno mic",
"speaker": "Bruno", "text": "What I like first is the plan",
"start_ms": 0, "end_ms": 2000, "starts_mid": True}]},
cookies=_cookies(session))
assert r.status_code == 200, r.text
# pause / resume = états serveur (§6.5)
assert client.post(f"/api/v2/meetings/transcripts/{tid}/pause",
json={}, cookies=_cookies(session)).status_code == 200
assert client.post(f"/api/v2/meetings/transcripts/{tid}/resume",
cookies=_cookies(session)).status_code == 200
# stop → processing → résumé dégradé classé, titre-diagnostic (§12.8)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
json={"duration_ms": 8000}, cookies=_cookies(session))
assert r.status_code == 200, r.text
body = r.json()
assert body["completeness"] in ("brief", "incomplete", "fragmentary")
assert body["generated_title"] == "Brief or Incomplete Meeting Recording"
assert "No action items could be identified" in body["summary"]
assert "¹" in body["summary"]
# steps Thinking persistés (§12.7)
r = client.get(f"/api/v2/meetings/transcripts/{tid}/processing-steps",
cookies=_cookies(session))
steps = {s["key"]: s["state"] for s in r.json()["steps"]}
assert steps.get("resolving_references") == "done"
assert steps.get("classifying_completeness") == "done"
# transcript groupé par source
r = client.get(f"/api/v2/meetings/transcripts/{tid}/transcript",
cookies=_cookies(session))
assert r.json()["groups"][0]["source"] == "Bruno mic"
assert r.json()["groups"][0]["segments"][0]["starts_mid_utterance"] is True
# partage tracé (§3A.5)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/distributions",
json={"channel": "copy_link"}, cookies=_cookies(session))
assert r.status_code == 200
# le titre IA ne touche jamais le titre de page hôte (§3A.5)
with get_conn() as conn:
title = conn.execute("SELECT title FROM pages WHERE id=?", (pid,)).fetchone()[0]
assert title == "Meeting @Today"
def test_complete_meeting_structured_summary(client):
session, _ = _login(client)
tid = _mktr(client, session)
client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
json={"method": "verbal"}, cookies=_cookies(session))
client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
json={"mode": "mic_only"}, cookies=_cookies(session))
words = ("We discussed the pricing launch for september. " * 20
+ "Alice will prepare the release notes. "
+ "We decided to ship on Friday morning. "
+ "Bob should send the invoice before Monday. " * 3)
client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
json={"segments": [
{"channel": "self", "speaker": "Alice", "text": words,
"start_ms": 0, "end_ms": 120000}]},
cookies=_cookies(session))
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
json={"duration_ms": 300000}, cookies=_cookies(session))
assert r.status_code == 200, r.text
assert r.json()["completeness"] == "complete"
assert "## Action Items" in r.json()["summary"]
def test_unicity_per_occurrence(client):
session, _ = _login(client)
p1, p2 = _mkpage("A"), _mkpage("B")
with get_conn() as conn:
t1 = conn.execute(
"INSERT INTO meeting_transcripts (page_id, event_occurrence_id)"
" VALUES (?, 'google:evt1:2026-10-15')", (p1,)).lastrowid
conn.commit()
_ = t1
import sqlite3
with get_conn() as conn:
try:
conn.execute(
"INSERT INTO meeting_transcripts (page_id, event_occurrence_id)"
" VALUES (?, 'google:evt1:2026-10-15')", (p2,))
conn.commit()
pytest.fail("unicity should reject the duplicate")
except sqlite3.IntegrityError:
pass
def test_instructions_and_history(client):
session, _ = _login(client)
r = client.get("/api/v2/meetings/instructions", cookies=_cookies(session))
assert "auto" in [i["id"] for i in r.json()["instructions"]]
_mktr(client, session)
r = client.get("/api/v2/meetings/history?query=rec",
cookies=_cookies(session))
assert r.status_code == 200
# ── v7.1.1b : arrêt à vide, notes de réunion, arbre Workspace ───────────────
def _start_empty_session(client, session):
pid = _mkpage("Empty take")
tid = _mktr(client, session, pid)
client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
json={"method": "start_attestation"}, cookies=_cookies(session))
client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
json={"mode": "mic_only"}, cookies=_cookies(session))
return tid
def test_stop_empty_without_flag_is_400_and_recoverable(client):
session, _ = _login(client)
tid = _start_empty_session(client, session)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
json={}, cookies=_cookies(session))
assert r.status_code == 400
# La ligne ne reste jamais coincée en « processing ».
with get_conn() as conn:
status = conn.execute("SELECT status FROM meeting_transcripts WHERE id=?",
(tid,)).fetchone()[0]
assert status == "failed"
def test_stop_empty_with_flag_gives_diagnostic(client):
session, _ = _login(client)
tid = _start_empty_session(client, session)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
json={"duration_ms": 5000, "allow_empty": True},
cookies=_cookies(session))
assert r.status_code == 200, r.text
body = r.json()
assert body["completeness"] == "fragmentary"
assert body["generated_title"] == "Brief or Incomplete Meeting Recording"
assert "No audio was captured" in body["summary"]
def test_retry_from_stuck_processing_with_flag(client):
session, _ = _login(client)
tid = _start_empty_session(client, session)
# Ligne coincée en processing (cas du bug rapporté, transcript id 2).
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET status='processing' WHERE id=?",
(tid,))
conn.commit()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/processing:retry",
json={"allow_empty": True}, cookies=_cookies(session))
assert r.status_code == 200, r.text
assert r.json()["completeness"] == "fragmentary"
def _mkworkspace(uid, name="WS"):
with get_conn() as conn:
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, uid))
conn.commit()
return cur.lastrowid
def _mkblockpage(ws_id, title, with_meeting):
import json as _json
content = _json.dumps([{"id": "b1", "type": "meeting" if with_meeting else "paragraph",
"content": ""}])
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format,"
" parent_section) VALUES ('t', ?, ?, ?, 'blocks', 'Private')",
(ws_id, title, content))
conn.commit()
return cur.lastrowid
def test_meeting_notes_endpoint_and_ownership(client):
session, uid = _login(client)
ws = _mkworkspace(uid)
mp = _mkblockpage(ws, "Sprint Review", True)
_mkblockpage(ws, "Plain notes", False)
# Page d'un autre utilisateur : invisible.
s2, uid2 = _login(client)
ws2 = _mkworkspace(uid2)
_mkblockpage(ws2, "Someone else", True)
r = client.get("/api/v2/meetings/notes", cookies=_cookies(session))
assert r.status_code == 200, r.text
ids = [n["id"] for n in r.json()["notes"]]
assert mp in ids
assert len(ids) == 1
assert r.json()["notes"][0]["status"] == "idle"
def test_workspace_tree_excludes_meeting_notes(client):
from app.routers.board import _load_workspace_pages
session, uid = _login(client)
ws = _mkworkspace(uid)
_mkblockpage(ws, "Sprint Review", True)
_mkblockpage(ws, "Plain notes", False)
pages = _load_workspace_pages(str(ws))
names = [p["name"] for p in pages]
assert "Plain notes" in names
assert "Sprint Review" not in names
def test_sidebar_tree_endpoint_keeps_alpine_bindings(client):
"""Contrat dont dépendent refreshSidebarTree + Alpine.initTree.
Régression /local-workspace : le fragment doit porter les bindings
(@contextmenu/showWsContext) et exclure les notes de réunion.
"""
session, uid = _login(client)
ws = _mkworkspace(uid)
_mkblockpage(ws, "Sprint Review", True)
_mkblockpage(ws, "Plain notes", False)
cookies = _cookies(session)
cookies["flowdeck_workspace"] = str(ws)
r = client.get("/api/sidebar/workspace-tree", cookies=cookies)
assert r.status_code == 200, r.text
assert "showWsContext" in r.text
assert "Plain notes" in r.text
assert "Sprint Review" not in r.text
# ── v7.69.5 : fin du 400 en boucle ──────────────────────────────────────────
def test_summarize_empty_with_flag_gives_diagnostic(client):
session, _ = _login(client)
tid = _mktr(client, session)
r = client.post(f"/api/v2/meetings/transcripts/{tid}/summarize",
json={"allow_empty": True}, cookies=_cookies(session))
assert r.status_code == 200, r.text
assert "No audio was captured" in r.json()["summary"]
with get_conn() as conn:
row = conn.execute("SELECT status, completeness_class FROM meeting_transcripts"
" WHERE id=?", (tid,)).fetchone()
assert row["status"] == "done"
assert row["completeness_class"] == "fragmentary"
def test_topup_after_failure_then_retry(client):
"""Le manuel collé après un échec part au retry — plus de 400 en boucle."""
session, _ = _login(client)
tid = _start_empty_session(client, session)
# Premier stop refusé (sans flag) → failed, pas coincé.
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
json={}, cookies=_cookies(session))
assert r.status_code == 400
# L'utilisateur colle un transcript : top-up autorisé hors session live.
r = client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
json={"segments": [
{"channel": "self", "speaker": "Alice",
"text": "We decided to ship on Friday morning. Alice will prepare "
"the release notes for the team. Bob should send the invoice "
"before Monday morning without fail.",
"start_ms": 0, "end_ms": 60000}]},
cookies=_cookies(session))
assert r.status_code == 200, r.text
r = client.post(f"/api/v2/meetings/transcripts/{tid}/processing:retry",
json={}, cookies=_cookies(session))
assert r.status_code == 200, r.text
assert "Action Items" in r.json()["summary"]