fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
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:
+871
-1513
File diff suppressed because it is too large
Load Diff
+167
-1
@@ -1,6 +1,172 @@
|
|||||||
# Changelog - FlowDeck
|
# 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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.69.4",
|
version="7.69.9",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
|||||||
# Images
|
# Images
|
||||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||||
# Documents
|
# Documents
|
||||||
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
|
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
|
||||||
# Code
|
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
|
||||||
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
|
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
|
||||||
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
|
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
|
||||||
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
|
".py", ".pyw", ".pyi", ".gyp", ".gypi",
|
||||||
".swift", ".kt", ".scala", ".r",
|
".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
|
# Archives
|
||||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||||
# Misc
|
|
||||||
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|||||||
@@ -1725,3 +1725,80 @@ def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
|||||||
"WHERE provider='mistral' AND model IN (?,?)",
|
"WHERE provider='mistral' AND model IN (?,?)",
|
||||||
blocked,
|
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 <> ''"
|
||||||
|
)
|
||||||
|
|||||||
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
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:
|
if not ws_cookie:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, parent_section, content_format, "
|
"SELECT id, title, parent_section, content_format, "
|
||||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
"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,),
|
(ws_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _load_children(parent_id: int) -> 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:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, parent_section, content_format, "
|
"SELECT id, title, parent_section, content_format, "
|
||||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
"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,),
|
(parent_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
children = []
|
children = []
|
||||||
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
published_rows = conn.execute(
|
published_rows = conn.execute(
|
||||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
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",
|
f"ORDER BY updated_at DESC LIMIT 20",
|
||||||
scope_params,
|
scope_params,
|
||||||
).fetchall()
|
).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
|
uid = user["id"] if user and user.get("id") else 1
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
fav_rows = conn.execute(
|
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 "
|
"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()
|
).fetchall()
|
||||||
favorites = []
|
favorites = []
|
||||||
for r in fav_rows:
|
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({
|
favorites.append({
|
||||||
"id": f"page/{r['id']}",
|
"id": f"page/{r['id']}",
|
||||||
"db_id": r["id"],
|
"db_id": r["id"],
|
||||||
"name": r["title"] or "New page",
|
"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']}",
|
"url": f"/pages/{r['id']}",
|
||||||
"active": False,
|
"active": False,
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
|
|||||||
@@ -95,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
|||||||
mode = body.get("mode", "private")
|
mode = body.get("mode", "private")
|
||||||
published = body.get("published", False)
|
published = body.get("published", False)
|
||||||
with get_conn() as conn:
|
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(
|
conn.execute(
|
||||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
|
||||||
(mode, 1 if published else 0, page_id),
|
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok", "share_mode": mode, "published": published}
|
return {"status": "ok", "share_mode": mode, "published": published}
|
||||||
|
|||||||
@@ -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:
|
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
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:
|
except Exception:
|
||||||
logger.exception("_sidebar_data")
|
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]:
|
for repo in repos[:10]:
|
||||||
full_name = repo.get("full_name", "")
|
full_name = repo.get("full_name", "")
|
||||||
recent_pages.append({
|
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:
|
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."""
|
"""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:
|
if parent_id is None:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
"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 "
|
"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 "
|
"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",
|
"ORDER BY created_at DESC",
|
||||||
(ws_id,),
|
(ws_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
"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 "
|
"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",
|
"ORDER BY created_at DESC",
|
||||||
(parent_id, ws_id),
|
(parent_id, ws_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -441,6 +490,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
|
|||||||
"author": author,
|
"author": author,
|
||||||
"tags": tags_map.get(r["id"], []),
|
"tags": tags_map.get(r["id"], []),
|
||||||
"is_shared": bool(r["is_shared"]),
|
"is_shared": bool(r["is_shared"]),
|
||||||
|
"published": bool(r["pub1"] or r["pub2"]),
|
||||||
"favorited": r["id"] in favorited_ids,
|
"favorited": r["id"] in favorited_ids,
|
||||||
})
|
})
|
||||||
return tree
|
return tree
|
||||||
@@ -671,12 +721,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
|||||||
)
|
)
|
||||||
elif t == "code":
|
elif t == "code":
|
||||||
lang = b.get("language", "")
|
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 ""
|
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||||
html_parts.append(
|
html_parts.append(
|
||||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
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'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||||
f'{lang_label}'
|
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":
|
elif t == "divider":
|
||||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
breadcrumb = _build_breadcrumb(conn, folder)
|
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 = {
|
ctx = {
|
||||||
**sidebar,
|
**sidebar,
|
||||||
"user": user,
|
"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,
|
"nav_workspace_id": ws_id or 0,
|
||||||
"breadcrumb": breadcrumb,
|
"breadcrumb": breadcrumb,
|
||||||
"breadcrumbs": breadcrumb,
|
"breadcrumbs": breadcrumb,
|
||||||
|
"breadcrumb_items": nav_crumbs,
|
||||||
}
|
}
|
||||||
template = env.get_template("local_workspace.html")
|
template = env.get_template("local_workspace.html")
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
|
|||||||
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
|
|||||||
if not row:
|
if not row:
|
||||||
return RedirectResponse("/workspaces", status_code=302)
|
return RedirectResponse("/workspaces", status_code=302)
|
||||||
page = dict(row)
|
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.
|
# v6.5.0: synced blocks resolve server-side at read time.
|
||||||
from app.services.synced_blocks import resolve_content_json
|
from app.services.synced_blocks import resolve_content_json
|
||||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
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", ""))
|
_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
|
_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)
|
_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,
|
"is_locked": _locked,
|
||||||
"locked_by": _locked_by,
|
"locked_by": _locked_by,
|
||||||
"can_edit": _can_edit,
|
"can_edit": _can_edit,
|
||||||
|
|||||||
+124
-15
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
|||||||
else:
|
else:
|
||||||
url = f"/pages/{page_id}"
|
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")
|
content_format = db_row.get("content_format", "blocks")
|
||||||
if db_row.get("is_folder"):
|
if db_row.get("is_folder"):
|
||||||
icon = "📁"
|
icon = "folder"
|
||||||
elif content_format == "file":
|
elif content_format == "file":
|
||||||
icon = "📄"
|
icon = "file"
|
||||||
fn = title.lower()
|
fn = title.lower()
|
||||||
if fn.endswith(".pdf"):
|
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||||
icon = "📕"
|
icon = "image"
|
||||||
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 = "📜"
|
|
||||||
else:
|
else:
|
||||||
icon = "📝"
|
icon = "edit"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": page_id,
|
"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 "",
|
"page_icon": db_row.get("page_icon") or "",
|
||||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||||
"source_type": source_type,
|
"source_type": source_type,
|
||||||
|
"source_icon": {"local": "file", "gitea": "link",
|
||||||
|
"github": "external-link"}.get(source_type, "file"),
|
||||||
"source_label": _source_label(source_type, workspace),
|
"source_label": _source_label(source_type, workspace),
|
||||||
"workspace": workspace,
|
"workspace": workspace,
|
||||||
"workspace_name": _source_label(source_type, 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:
|
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||||
items = [_build_item(dict(r), uid) for r in rows]
|
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:
|
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")
|
@router.get("/recents")
|
||||||
def library_recents(
|
def library_recents(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -167,7 +275,7 @@ def library_recents(
|
|||||||
source_type = "all"
|
source_type = "all"
|
||||||
|
|
||||||
uid = _get_user_id(request)
|
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 = []
|
params: list = []
|
||||||
|
|
||||||
# Hierarchical view: show only root-level pages at the top
|
# Hierarchical view: show only root-level pages at the top
|
||||||
@@ -301,7 +409,8 @@ def library_published(
|
|||||||
source_type = "all"
|
source_type = "all"
|
||||||
|
|
||||||
uid = _get_user_id(request)
|
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 = []
|
params: list = []
|
||||||
if tree:
|
if tree:
|
||||||
query += " AND p.parent_id IS NULL"
|
query += " AND p.parent_id IS NULL"
|
||||||
@@ -326,7 +435,7 @@ def library_private(
|
|||||||
source_type = "all"
|
source_type = "all"
|
||||||
|
|
||||||
uid = _get_user_id(request)
|
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 = []
|
params: list = []
|
||||||
if tree:
|
if tree:
|
||||||
query += " AND p.parent_id IS NULL"
|
query += " AND p.parent_id IS NULL"
|
||||||
@@ -392,7 +501,7 @@ def library_workspace(
|
|||||||
source_type = "all"
|
source_type = "all"
|
||||||
|
|
||||||
uid = _get_user_id(request)
|
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 = []
|
params: list = []
|
||||||
|
|
||||||
if tree:
|
if tree:
|
||||||
|
|||||||
+329
-2
@@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30):
|
|||||||
|
|
||||||
# ── meetings ───────────────────────────────────────────────────────────────
|
# ── 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")
|
@router.post("/api/v2/meetings/transcribe")
|
||||||
async def upload_and_transcribe(request: Request):
|
async def upload_and_transcribe(request: Request):
|
||||||
user = _auth_user(request, require_write=True)
|
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")
|
@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)
|
user = _auth_user(request, require_write=True)
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from None
|
raise HTTPException(400, str(exc)) from None
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(502, str(exc)) from None
|
raise HTTPException(502, str(exc)) from None
|
||||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||||
return out
|
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
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
|
|||||||
# ── Source resolution: read real textual content for ANY page type ──
|
# ── Source resolution: read real textual content for ANY page type ──
|
||||||
|
|
||||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
# 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 = {
|
_TEXTUAL_EXTS = {
|
||||||
"md", "markdown", "txt", "log", "text",
|
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
|
||||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
|
||||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
"py", "pyw", "pyi", "gyp", "gypi",
|
||||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
|
||||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
|
||||||
"properties", "gitignore", "dockerfile", "makefile",
|
"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 = {
|
_CODE_LANG = {
|
||||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
"py": "python", "pyw": "python", "pyi": "python",
|
||||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
"gyp": "python", "gypi": "python",
|
||||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
"jsx": "javascript",
|
||||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
"ts": "typescript", "mts": "typescript", "cts": "typescript",
|
||||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
"tsx": "typescript",
|
||||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
|
||||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
|
||||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
|
||||||
"astro": "html", "properties": "ini", "md": "markdown",
|
"svg": "xml", "axml": "xml",
|
||||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
"vue": "xml", "svelte": "xml", "astro": "xml",
|
||||||
"text": "plaintext",
|
"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"}
|
_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()
|
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||||
mime = (meta.get("mime_type") or "").lower()
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
full = (_data_root() / rel).resolve()
|
full = (_data_root() / rel).resolve()
|
||||||
@@ -161,8 +360,7 @@ def _page_source(page: dict):
|
|||||||
mime = (meta.get("mime_type") or "").lower()
|
mime = (meta.get("mime_type") or "").lower()
|
||||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||||
return "md", text
|
return "md", text
|
||||||
lang = _CODE_LANG.get(ext, "plaintext")
|
return "code", (text, code_lang_for_file(name))
|
||||||
return "code", (text, lang)
|
|
||||||
|
|
||||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||||
if content.strip():
|
if content.strip():
|
||||||
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
|
|||||||
out.append("---")
|
out.append("---")
|
||||||
elif t == "code":
|
elif t == "code":
|
||||||
lang = b.get("language") or ""
|
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":
|
elif t == "toggle":
|
||||||
out.append(f"### {c}")
|
out.append(f"### {c}")
|
||||||
if b.get("children"):
|
if b.get("children"):
|
||||||
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
|
|||||||
parts.append("<hr>")
|
parts.append("<hr>")
|
||||||
elif t == "code":
|
elif t == "code":
|
||||||
lang = b.get("language") or ""
|
lang = b.get("language") or ""
|
||||||
|
hljs_lang = normalize_code_lang(lang)
|
||||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
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":
|
elif t == "math":
|
||||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||||
elif t == "table_of_contents":
|
elif t == "table_of_contents":
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
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
|
from app.services.importers.base import ImportPage, ImportResult
|
||||||
|
|
||||||
_MD_EXTS = {"md", "markdown"}
|
_MD_EXTS = {"md", "markdown"}
|
||||||
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
|
|||||||
return Path(path).suffix.lower().lstrip(".")
|
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(
|
def build_repo_result(
|
||||||
files: list[tuple[str, str]],
|
files: list[tuple[str, str]],
|
||||||
*,
|
*,
|
||||||
@@ -34,7 +46,7 @@ def build_repo_result(
|
|||||||
if ext in _MD_EXTS:
|
if ext in _MD_EXTS:
|
||||||
markdown = content
|
markdown = content
|
||||||
else:
|
else:
|
||||||
lang = _CODE_LANG.get(ext, "")
|
lang = code_lang_for_file(clean)
|
||||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||||
parts = clean.split("/")
|
parts = clean.split("/")
|
||||||
result.pages.append(ImportPage(
|
result.pages.append(ImportPage(
|
||||||
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
|
|||||||
files: list[tuple[str, str]] = []
|
files: list[tuple[str, str]] = []
|
||||||
for meta in metas:
|
for meta in metas:
|
||||||
file_path = meta.get("path") or ""
|
file_path = meta.get("path") or ""
|
||||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
if not _is_textual(file_path):
|
||||||
continue
|
continue
|
||||||
if int(meta.get("size") or 0) > max_file_bytes:
|
if int(meta.get("size") or 0) > max_file_bytes:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+668
-49
@@ -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)
|
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
|
||||||
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
|
machine à états du bloc, révélation progressive des onglets, consentement
|
||||||
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
|
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
|
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
|
||||||
``transcript`` (client-side transcription). Nothing here requires new pip
|
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
|
||||||
dependencies. See ``docs/V71_Calendar_Meetings.md``.
|
append-only. Rien n'exige de dépendance pip supplémentaire.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
|
|||||||
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
|
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
|
||||||
MAX_AUDIO_BYTES = 100 * 1024 * 1024
|
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):
|
class TranscriptionUnavailable(RuntimeError):
|
||||||
"""Raised when no transcription backend is configured."""
|
"""Raised when no transcription backend is configured."""
|
||||||
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
|
|||||||
return d
|
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",
|
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:
|
with get_conn() as conn:
|
||||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||||
raise ValueError("page not found")
|
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(
|
cur = conn.execute(
|
||||||
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
|
"""INSERT INTO meeting_transcripts
|
||||||
VALUES (?,?,?,?)""",
|
(page_id, audio_path, transcript, language, status, mode,
|
||||||
(page_id, audio_path, transcript or "", language or "fr"))
|
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()
|
conn.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
|
||||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
|
|
||||||
|
|
||||||
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
|
def record_consent(transcript_id: int, *, method: str = "start_attestation",
|
||||||
(command must print or be adapted — stdout is preferred). Raises
|
message_ref: str = "", attested_by: int | None = None,
|
||||||
:class:`TranscriptionUnavailable` when unconfigured.
|
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()
|
cmd_template = os.environ.get("STT_COMMAND", "").strip()
|
||||||
if not cmd_template:
|
if not cmd_template:
|
||||||
raise TranscriptionUnavailable(
|
raise TranscriptionUnavailable(
|
||||||
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
|
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
|
||||||
"""Summarize a stored transcript + fire ``meeting.summarized``.
|
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")
|
||||||
|
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"]}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
# ── 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:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
cur = conn.execute(
|
||||||
(transcript_id,)).fetchone()
|
"""INSERT INTO meeting_distributions
|
||||||
if not row:
|
(transcript_id, channel, actor_id, recipients_json, status)
|
||||||
raise ValueError("transcript not found")
|
VALUES (?,?,?,?,?)""",
|
||||||
tr = dict(row)
|
(transcript_id, channel, actor_id, recipients or "", status),
|
||||||
if not (tr.get("transcript") or "").strip():
|
)
|
||||||
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')}")
|
|
||||||
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()
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
try:
|
return {"id": cur.lastrowid, "transcript_id": transcript_id,
|
||||||
from app.services.automations import fire_event
|
"channel": channel, "status": status}
|
||||||
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"))}
|
|
||||||
|
|||||||
+14
-6
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
|
|||||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
- `/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)
|
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||||
|
|
||||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
Canonical (comportement du front) : `is_published` + `published` +
|
||||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
|
||||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
|
||||||
donc pas un partage manuel.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
|||||||
if not page:
|
if not page:
|
||||||
raise HTTPException(404, "Page not found")
|
raise HTTPException(404, "Page not found")
|
||||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
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(
|
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()
|
conn.commit()
|
||||||
return slug, page["title"] or ""
|
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():
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||||
raise HTTPException(404, "Page not found")
|
raise HTTPException(404, "Page not found")
|
||||||
conn.execute(
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ document.addEventListener('alpine:init', function () {
|
|||||||
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
|
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() {
|
get display() {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
|
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
|
||||||
{{ page_data | tojson }}
|
{{ page_data | tojson }}
|
||||||
</script>
|
</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>
|
<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>
|
||||||
|
|||||||
+221
-25
@@ -361,13 +361,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-actions">
|
<div class="sidebar-section-actions">
|
||||||
{% if has_active_workspace %}
|
{% 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 %}
|
{% endif %}
|
||||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -662,6 +677,10 @@
|
|||||||
<div class="sb-filter-row">
|
<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 === '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 === '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>
|
||||||
|
|
||||||
<div class="sb-quick-actions">
|
<div class="sb-quick-actions">
|
||||||
@@ -671,15 +690,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="sidebar-items">
|
<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)">
|
<li class="sidebar-item" @click="meetingOpen(ev)">
|
||||||
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
|
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
|
||||||
<span class="page-name" x-text="ev.title"></span>
|
<span style="flex:1;min-width:0;">
|
||||||
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
|
<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>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
|
<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>
|
||||||
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
|
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
|
||||||
<span class="page-name text-dim">Loading…</span>
|
<span class="page-name text-dim">Loading…</span>
|
||||||
@@ -719,18 +741,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer : bouton "New" + popover "Create new" (vers le haut) -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div style="display:flex; gap:6px;">
|
<div class="fd-new-wrap" @click.outside="closeNewMenu()">
|
||||||
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
|
<div class="fd-new-popover" x-show="newMenuOpen" x-cloak
|
||||||
<span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
|
x-transition:enter="transition ease-out duration-150"
|
||||||
<span class="shortcut">Ctrl+O</span>
|
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>
|
||||||
|
<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>
|
</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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /sidebar-scroll -->
|
</div><!-- /sidebar-scroll -->
|
||||||
@@ -899,6 +938,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="context-menu-section">
|
<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')">
|
<div class="context-menu-item" @click="wsCtxAction('rename')">
|
||||||
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
|
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
|
||||||
</div>
|
</div>
|
||||||
@@ -1294,6 +1336,8 @@
|
|||||||
this.loadTeamspaces();
|
this.loadTeamspaces();
|
||||||
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
|
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
|
||||||
this.loadInbox();
|
this.loadInbox();
|
||||||
|
// v7.1.1 : notes de réunion (section Meetings, pas Workspace).
|
||||||
|
this.loadMeetingNotes();
|
||||||
this.initSidebarWidth();
|
this.initSidebarWidth();
|
||||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||||
this.startClipAutoRefresh();
|
this.startClipAutoRefresh();
|
||||||
@@ -1310,7 +1354,7 @@
|
|||||||
});
|
});
|
||||||
document.addEventListener('flowdeck:workspace-changed', () => {
|
document.addEventListener('flowdeck:workspace-changed', () => {
|
||||||
clearTimeout(self._clipRefreshTimer);
|
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)
|
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -1451,15 +1495,37 @@
|
|||||||
|
|
||||||
// ── Meeting tab ──
|
// ── Meeting tab ──
|
||||||
meetings: [],
|
meetings: [],
|
||||||
|
meetingPast: [],
|
||||||
meetingsLoading: false,
|
meetingsLoading: false,
|
||||||
meetingFilter: 'upcoming',
|
meetingFilter: 'upcoming',
|
||||||
|
meetingSearch: '',
|
||||||
|
meetingHistoryLoaded: false,
|
||||||
get meetingVisible() {
|
get meetingVisible() {
|
||||||
if (this.meetingFilter === 'today') {
|
var q = (this.meetingSearch || '').trim().toLowerCase();
|
||||||
return this.meetings.filter(function(e) { return e.today; });
|
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; }); },
|
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) {
|
loadMeetings(force) {
|
||||||
var self = this;
|
var self = this;
|
||||||
if (!force && this.meetings.length) return Promise.resolve();
|
if (!force && this.meetings.length) return Promise.resolve();
|
||||||
@@ -1472,8 +1538,38 @@
|
|||||||
})
|
})
|
||||||
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
|
.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) {
|
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);
|
if (window.fdNavigate) window.fdNavigate(target);
|
||||||
else window.location.href = target;
|
else window.location.href = target;
|
||||||
},
|
},
|
||||||
@@ -1614,6 +1710,15 @@
|
|||||||
this.wsCtx.visible = false;
|
this.wsCtx.visible = false;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
switch(action) {
|
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':
|
case 'new-file':
|
||||||
this.newPageInFolder(id);
|
this.newPageInFolder(id);
|
||||||
break;
|
break;
|
||||||
@@ -1632,6 +1737,7 @@
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
|
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
|
||||||
self.refreshSidebarTree();
|
self.refreshSidebarTree();
|
||||||
|
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
|
||||||
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
|
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
|
||||||
if (window._wsData && window._wsData._reloadAfterAction) {
|
if (window._wsData && window._wsData._reloadAfterAction) {
|
||||||
window._wsData._reloadAfterAction();
|
window._wsData._reloadAfterAction();
|
||||||
@@ -1644,6 +1750,7 @@
|
|||||||
if (!id) break;
|
if (!id) break;
|
||||||
if (!confirm('Delete "' + name + '"?')) break;
|
if (!confirm('Delete "' + name + '"?')) break;
|
||||||
this.deleteWorkspacePage(id, true);
|
this.deleteWorkspacePage(id, true);
|
||||||
|
if (typeof this.loadMeetingNotes === 'function') this.loadMeetingNotes();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1685,20 +1792,58 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Sections collapsible ──
|
// ── Sections collapsible (accordéon v7.70 : une seule ouverte à la fois) ──
|
||||||
sectionsOpen: (function() {
|
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 {
|
try {
|
||||||
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
|
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; }
|
} catch(e) { return def; }
|
||||||
})(),
|
})(),
|
||||||
toggleSection(key) {
|
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);
|
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
|
||||||
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
|
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
|
||||||
catch(e) {}
|
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 ──
|
// ── Sidebar customization ──
|
||||||
customizing: false,
|
customizing: false,
|
||||||
sidebarConfig: {},
|
sidebarConfig: {},
|
||||||
@@ -1933,6 +2078,50 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
fdCreatePage() { window.FlowDeck.createPage(); },
|
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(); },
|
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||||
sidebarSections() {
|
sidebarSections() {
|
||||||
@@ -2296,6 +2485,12 @@
|
|||||||
const manageLink = ul.querySelector('li:last-child');
|
const manageLink = ul.querySelector('li:last-child');
|
||||||
ul.innerHTML = html;
|
ul.innerHTML = html;
|
||||||
if (manageLink) ul.appendChild(manageLink);
|
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));
|
.catch(e => console.error('Sidebar refresh failed:', e));
|
||||||
},
|
},
|
||||||
@@ -2526,6 +2721,7 @@
|
|||||||
if (data.contextMenu) data.contextMenu.visible = false;
|
if (data.contextMenu) data.contextMenu.visible = false;
|
||||||
if (data.wsCtx) data.wsCtx.visible = false;
|
if (data.wsCtx) data.wsCtx.visible = false;
|
||||||
if (data.showNewPageMenu) data.showNewPageMenu = false;
|
if (data.showNewPageMenu) data.showNewPageMenu = false;
|
||||||
|
if (data.newMenuOpen) data.newMenuOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (e.ctrlKey && e.key === 's') {
|
else if (e.ctrlKey && e.key === 's') {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Prism.js syntax highlighting -->
|
<!-- highlight.js syntax highlighting (~230 file types via FDHighlight helper) -->
|
||||||
<link rel="stylesheet" href="/static/css/prism.css">
|
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||||
<script src="/static/js/prism.min.js"></script>
|
<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>
|
<style>
|
||||||
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
||||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
.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{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);}
|
.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;}
|
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -62,7 +64,10 @@
|
|||||||
<div class="skeleton" style="height:15px;width:50%;"></div>
|
<div class="skeleton" style="height:15px;width:50%;"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Syntax-highlighted code -->
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -60,20 +60,38 @@
|
|||||||
/* ── Table (div-based for hover effects) ── */
|
/* ── Table (div-based for hover effects) ── */
|
||||||
.lib-table{width:100%;}
|
.lib-table{width:100%;}
|
||||||
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
|
.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;}
|
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
|
||||||
|
|
||||||
/* Column widths — Notion-style */
|
/* Column widths — Notion-style */
|
||||||
.lib-col-name{flex:1;min-width:0;}
|
.lib-col-name{flex:1;min-width:0;}
|
||||||
.lib-col-author{width:150px;flex-shrink:0;display:none;}
|
.lib-col-author{width:150px;flex-shrink:0;}
|
||||||
.lib-col-source{width:120px;flex-shrink:0;display:none;}
|
.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-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
|
||||||
.lib-table.show-author .lib-col-author{display:flex;}
|
.lib-col-visited{width:130px;flex-shrink:0;text-align:right;justify-content:flex-end;}
|
||||||
.lib-table.show-source .lib-col-source{display:flex;}
|
/* 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 ── */
|
/* ── Row ── */
|
||||||
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
|
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
|
||||||
.lib-row:hover{background:var(--bg-hover);}
|
.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.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.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
|
||||||
.lib-row.dragging{opacity:0.4;}
|
.lib-row.dragging{opacity:0.4;}
|
||||||
@@ -189,7 +207,7 @@
|
|||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media(max-width:768px){
|
@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;}
|
.peek-overlay{width:100vw;max-width:100vw;}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -212,6 +230,9 @@
|
|||||||
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
<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
|
<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>
|
||||||
|
<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')">
|
<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
|
<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>
|
</button>
|
||||||
@@ -265,6 +286,9 @@
|
|||||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||||
<div class="dd-label">Show columns</div>
|
<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('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 class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,7 +375,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Table ── -->
|
<!-- ── 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="lib-table-header">
|
||||||
<div class="th lib-col-name">
|
<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">
|
<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
|
Page name
|
||||||
</div>
|
</div>
|
||||||
<div class="th lib-col-author">Created by</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">
|
<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>
|
<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
|
Last edited time
|
||||||
</div>
|
</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>
|
||||||
<div class="lib-table-body" id="lib-table-body">
|
<div class="lib-table-body" id="lib-table-body">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,9 +166,12 @@
|
|||||||
.bc-edit-go:hover{opacity:.85;}
|
.bc-edit-go:hover{opacity:.85;}
|
||||||
|
|
||||||
/* ── Preserved styles ── */
|
/* ── 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{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 .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: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 .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{flex:1;font-size:14px;color:var(--text);}
|
||||||
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
|
.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 .inline-rename-input{flex:0 1 auto;}
|
||||||
.ws-tree-item .name.file .btn-open{flex:0 0 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-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 .actions{display:none;gap:2px;}
|
||||||
.ws-tree-item:hover .actions{display:flex;}
|
.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;}
|
.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;}
|
.preview-leave-end{transform:translateX(100%);opacity:0;}
|
||||||
</style>
|
</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" 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>
|
<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>
|
<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="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>
|
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
|
||||||
{% if breadcrumbs %}
|
|
||||||
<div class="unified-breadcrumb">
|
<div class="unified-breadcrumb">
|
||||||
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
|
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
|
||||||
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
|
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
|
||||||
@@ -549,7 +557,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
|
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="ws-toolbar" style="margin-left:auto;">
|
<div class="ws-toolbar" style="margin-left:auto;">
|
||||||
<!-- View dropdown -->
|
<!-- View dropdown -->
|
||||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||||
@@ -729,6 +736,8 @@
|
|||||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||||
@dblclick.prevent.stop="startInlineRename(node, $event)">
|
@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.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 x-show="renamingId !== node.id" x-text="node.name"></span>
|
||||||
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
|
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
|
||||||
x-text="'(' + node.child_count + ')'"></span>
|
x-text="'(' + node.child_count + ')'"></span>
|
||||||
@@ -743,6 +752,8 @@
|
|||||||
@mouseleave="hidePreview()"
|
@mouseleave="hidePreview()"
|
||||||
title="Click to rename, double-click to open">
|
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.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 x-show="renamingId !== node.id" x-text="node.name"></span>
|
||||||
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
|
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
|
||||||
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
|
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
|
||||||
|
|||||||
@@ -173,8 +173,17 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
<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() }}">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
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) {
|
if (window.katex) {
|
||||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||||
var tex = el.getAttribute('data-katex') || '';
|
var tex = el.getAttribute('data-katex') || '';
|
||||||
|
|||||||
@@ -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) |
|
||||||
@@ -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).
|
> 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.
|
> **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)
|
## 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é.
|
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([]);
|
||||||
|
});
|
||||||
@@ -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
@@ -540,41 +540,42 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
.sidebar-item.empty-hint:hover { background: none; }
|
.sidebar-item.empty-hint:hover { background: none; }
|
||||||
.sidebar-item.empty-hint .page-name { font-size: 12px; }
|
.sidebar-item.empty-hint .page-name { font-size: 12px; }
|
||||||
|
|
||||||
/* Shared subgroups (Par moi / Avec moi) */
|
/* Shared subgroups (Par moi / Avec moi) — compact */
|
||||||
.sidebar-subgroup {
|
.sidebar-subgroup {
|
||||||
padding-top: 2px;
|
padding-top: 1px;
|
||||||
}
|
}
|
||||||
.sidebar-subgroup + .sidebar-subgroup {
|
.sidebar-subgroup + .sidebar-subgroup {
|
||||||
margin-top: 6px;
|
margin-top: 4px;
|
||||||
padding-top: 6px;
|
padding-top: 4px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.sidebar-subgroup-label {
|
.sidebar-subgroup-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections */
|
/* Sections — compact (v7.70 : une seule ouverte à la fois, paddings réduits) */
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
padding: var(--space-xs) var(--space-md);
|
padding: 1px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-header {
|
.sidebar-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 6px;
|
padding: 3px 4px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-header:hover {
|
.sidebar-section-header:hover {
|
||||||
@@ -589,8 +590,8 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
.sidebar-section-title {
|
.sidebar-section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
@@ -754,6 +755,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
/* Customize Sidebar Panel */
|
/* Customize Sidebar Panel */
|
||||||
.sidebar-customize-panel {
|
.sidebar-customize-panel {
|
||||||
padding: 8px 12px;
|
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 {
|
.scp-header {
|
||||||
@@ -853,14 +858,14 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 2px 4px;
|
padding: 1px 4px;
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
min-height: 24px;
|
min-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item:hover {
|
.sidebar-item:hover {
|
||||||
@@ -1037,6 +1042,80 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
color: var(--text-secondary);
|
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
|
CONTEXT MENU
|
||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
@@ -2654,6 +2733,35 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.block-code pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@@ -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;}
|
||||||
@@ -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; }
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -19,7 +19,8 @@ var _regGiteaWs = () => {
|
|||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
fileSha: '',
|
fileSha: '',
|
||||||
fileLoading: false,
|
fileLoading: false,
|
||||||
fileLanguage: 'text',
|
fileLanguage: 'plaintext',
|
||||||
|
fileLanguageLabel: 'Plain Text',
|
||||||
editContent: '',
|
editContent: '',
|
||||||
commitMessage: 'Update via FlowDeck',
|
commitMessage: 'Update via FlowDeck',
|
||||||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||||||
@@ -290,17 +291,27 @@ var _regGiteaWs = () => {
|
|||||||
this.fileContent = '[Error loading file]';
|
this.fileContent = '[Error loading file]';
|
||||||
}
|
}
|
||||||
this.fileLoading = false;
|
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;
|
var self = this;
|
||||||
this.$nextTick(function() {
|
this.$nextTick(function() {
|
||||||
var block = self.$refs.codeBlock;
|
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);
|
Prism.highlightElement(block);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getLanguage(path) {
|
getLanguage(path) {
|
||||||
|
if (window.FDHighlight) {
|
||||||
|
try { return FDHighlight.langForFile(path); } catch (e) { /* fall through */ }
|
||||||
|
}
|
||||||
var ext = (path || '').split('.').pop().toLowerCase();
|
var ext = (path || '').split('.').pop().toLowerCase();
|
||||||
var map = {
|
var map = {
|
||||||
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
||||||
|
|||||||
Vendored
+776
File diff suppressed because one or more lines are too long
Vendored
+1213
File diff suppressed because one or more lines are too long
+67
-9
@@ -35,6 +35,9 @@ function libraryPage() {
|
|||||||
shareDir: 'all',
|
shareDir: 'all',
|
||||||
showAuthor: false,
|
showAuthor: false,
|
||||||
showSource: false,
|
showSource: false,
|
||||||
|
showEditedBy: true,
|
||||||
|
showEditedTime: true,
|
||||||
|
showVisited: true,
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
workspaceTags: [],
|
workspaceTags: [],
|
||||||
@@ -90,6 +93,12 @@ function libraryPage() {
|
|||||||
for (var i = 0; i < this.flatItems.length; i++) {
|
for (var i = 0; i < this.flatItems.length; i++) {
|
||||||
var item = this.flatItems[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 ──
|
// ── Synthetic "+ Add new" placeholder row for an expanded parent ──
|
||||||
if (item._isAddNew) {
|
if (item._isAddNew) {
|
||||||
var pad = 30 + (item.depth || 0) * 20;
|
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="row-cell lib-col-author"><div class="row-author">';
|
||||||
html += '<div class="author-avatar">' + (item.author_initial || '?') + '</div>';
|
html += '<div class="author-avatar">' + (item.author_initial || '?') + '</div>';
|
||||||
html += '<span>' + (item.author || '\u2014') + '</span></div></div>';
|
html += '<span>' + (item.author || '\u2014') + '</span></div></div>';
|
||||||
// Source
|
// Last edited by
|
||||||
html += '<div class="row-cell lib-col-source"><span class="row-source">' + (item.source_label || 'Private') + '</span></div>';
|
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
|
// Edited
|
||||||
html += '<div class="row-cell lib-col-edited"><span class="row-time">' + self.formatTime(item.updated_at) + '</span></div>';
|
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>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
@@ -200,8 +218,9 @@ bindHtmlIcon(el) {
|
|||||||
bindHtmlItem(el, item) {
|
bindHtmlItem(el, item) {
|
||||||
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
|
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
|
||||||
},
|
},
|
||||||
_renderIcon(item) {
|
_renderIcon(item) {
|
||||||
if (!item) return getSvgIcon('file', 14);
|
if (!item) return getSvgIcon('file', 14);
|
||||||
|
if (item.is_meeting) return getSvgIcon('calendar', 14);
|
||||||
// Custom emoji (image URL), icon name, or unicode emoji
|
// 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.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);
|
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; });
|
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)
|
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
|
||||||
this.flatItems.splice(0, this.flatItems.length, ...result);
|
this.flatItems.splice(0, this.flatItems.length, ...result);
|
||||||
this.selectedCount = Object.keys(this.selected).length;
|
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();
|
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) ──
|
// ── Global dispatch (bypasses Alpine x-for reactivity issues) ──
|
||||||
toggleItemById(id) {
|
toggleItemById(id) {
|
||||||
var item = this._findItem(id);
|
var item = this._findItem(id);
|
||||||
@@ -399,7 +452,7 @@ _renderIcon(item) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_canAddChild() {
|
_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() {
|
_getCsrf() {
|
||||||
@@ -443,13 +496,14 @@ _renderIcon(item) {
|
|||||||
emptyText: '',
|
emptyText: '',
|
||||||
|
|
||||||
_recomputeEmpty() {
|
_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';
|
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',
|
published:'No published pages',private:'No private pages',workspace:'No workspace pages',
|
||||||
repository:'Empty repository'};
|
repository:'Empty repository'};
|
||||||
this.emptyTitle = titles[this.tab] || 'No items';
|
this.emptyTitle = titles[this.tab] || 'No items';
|
||||||
var texts = {recents:'Pages you\'ve recently viewed or edited will appear here.',
|
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".',
|
favorites:'Right-click a page in the sidebar and select "Add to Favorites".',
|
||||||
shared:'Shared pages from your team will appear here.',
|
shared:'Shared pages from your team will appear here.',
|
||||||
published:'Pages published to the web 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);
|
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var t = params.get('tab');
|
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.tab = t;
|
||||||
}
|
}
|
||||||
this._recomputeEmpty();
|
this._recomputeEmpty();
|
||||||
@@ -524,7 +578,7 @@ _renderIcon(item) {
|
|||||||
if (this.tab === 'repository' && this.isGiteaActive) {
|
if (this.tab === 'repository' && this.isGiteaActive) {
|
||||||
url += sep + 'gitea_owner=' + encodeURIComponent(this.giteaOwner) + '&gitea_repo=' + encodeURIComponent(this.giteaRepo);
|
url += sep + 'gitea_owner=' + encodeURIComponent(this.giteaOwner) + '&gitea_repo=' + encodeURIComponent(this.giteaRepo);
|
||||||
sep = '&';
|
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
|
// Hierarchical tree view: show only root-level pages at the top
|
||||||
url += sep + 'tree=1';
|
url += sep + 'tree=1';
|
||||||
sep = '&';
|
sep = '&';
|
||||||
@@ -573,6 +627,9 @@ _renderIcon(item) {
|
|||||||
toggleColumn(col) {
|
toggleColumn(col) {
|
||||||
if (col === 'author') this.showAuthor = !this.showAuthor;
|
if (col === 'author') this.showAuthor = !this.showAuthor;
|
||||||
else if (col === 'source') this.showSource = !this.showSource;
|
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() {
|
doSort() {
|
||||||
@@ -650,6 +707,7 @@ _renderIcon(item) {
|
|||||||
} else {
|
} else {
|
||||||
var s = {};
|
var s = {};
|
||||||
for (var i = 0; i < this.flatItems.length; i++) {
|
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;
|
s[this.flatItems[i].id] = true;
|
||||||
}
|
}
|
||||||
this.selected = s;
|
this.selected = s;
|
||||||
|
|||||||
+111
-12
@@ -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) {
|
if (!window.__fdLocalWorkspaceScriptsLoaded) {
|
||||||
window.__fdLocalWorkspaceScriptsLoaded = true;
|
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');
|
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||||
const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||||
@@ -150,6 +156,19 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
try {
|
try {
|
||||||
// Expose globally for event handlers (BEFORE await)
|
// Expose globally for event handlers (BEFORE await)
|
||||||
window._wsData = this;
|
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';
|
var url = '/api/local-workspace/tree';
|
||||||
if (this.currentFolder && this.currentFolder > 0) {
|
if (this.currentFolder && this.currentFolder > 0) {
|
||||||
url += '?folder=' + this.currentFolder;
|
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; }
|
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
|
||||||
this._recompute();
|
this._recompute();
|
||||||
this._initResizeHandle();
|
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() {
|
_initResizeHandle() {
|
||||||
@@ -486,6 +531,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
if (b.type === 'code' || b.type === 'code_block') {
|
if (b.type === 'code' || b.type === 'code_block') {
|
||||||
tag = 'pre';
|
tag = 'pre';
|
||||||
style += 'background:var(--bg-tertiary);padding:10px 14px;border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-wrap;';
|
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 + '>';
|
return '<' + tag + ' style="' + style + '">' + (txt ? this._escHtml(txt) : '<br>') + '</' + tag + '>';
|
||||||
}, this).join('');
|
}, 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>';
|
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">Empty document</div>';
|
||||||
}
|
}
|
||||||
} catch {
|
} 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') {
|
} 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>';
|
preview = '<div style="white-space:pre-wrap;line-height:1.6;font-size:13px;">' + this._escHtml(String(content).substring(0, 5000)) + '</div>';
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Unknown format — show as text
|
// Unknown format — show as (highlighted) text
|
||||||
var display = String(content || '').trim();
|
var display = String(content || '').trim();
|
||||||
if (display) {
|
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 {
|
} else {
|
||||||
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">No content</div>';
|
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>';
|
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 {
|
} catch {
|
||||||
body.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-tertiary)">Preview not available</div>';
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── 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 ──
|
// ── Content snippet for content view ──
|
||||||
contentSnippetMap: {},
|
contentSnippetMap: {},
|
||||||
|
|
||||||
@@ -765,7 +834,10 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
html += '<span class="icon">'+icon+'</span>';
|
html += '<span class="icon">'+icon+'</span>';
|
||||||
if (c.is_folder) {
|
if (c.is_folder) {
|
||||||
var countBadge = (c.child_count > 0) ? ' <span class="child-count">('+c.child_count+')</span>' : '';
|
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 {
|
} else {
|
||||||
// Click to rename, double-click to open
|
// Click to rename, double-click to open
|
||||||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||||||
@@ -773,8 +845,10 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
+ ' title="Click to rename, double-click to open"'
|
+ ' title="Click to rename, double-click to open"'
|
||||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
+ ' 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()">'
|
+ ' 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.is_shared ? '<span class="ws-shared-badge" title="Partag\u00e9" onclick="event.stopPropagation()">👥</span>' : '')
|
||||||
+ '<span id="rc-name-'+escId+'">'+c.name+'</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"'
|
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none"'
|
||||||
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
|
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
|
||||||
+ ' onblur="window._wsData.commitRenderChildRename('+escId+')"'
|
+ ' onblur="window._wsData.commitRenderChildRename('+escId+')"'
|
||||||
@@ -956,6 +1030,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
this._saveExpandedState();
|
this._saveExpandedState();
|
||||||
this.previewItem = null;
|
this.previewItem = null;
|
||||||
this._recompute();
|
this._recompute();
|
||||||
|
this._syncHeaderPath();
|
||||||
var self = this;
|
var self = this;
|
||||||
this.$nextTick(function() { self._syncCheckboxes(); });
|
this.$nextTick(function() { self._syncCheckboxes(); });
|
||||||
} catch(e) { console.error('Load folder error:', e); }
|
} catch(e) { console.error('Load folder error:', e); }
|
||||||
@@ -1230,8 +1305,9 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
} else {
|
} else {
|
||||||
this.previewContent = 'Loading...';
|
this.previewContent = 'Loading...';
|
||||||
this._fetchRetry(url).then(function(t) {
|
this._fetchRetry(url).then(function(t) {
|
||||||
var content = t.substring(0, 500);
|
var html = self._codeHtml(t, node.name, 500);
|
||||||
self.previewContent = content; self.previewCache[cacheKey] = content;
|
// Tooltip body is already monospace/pre-wrap: keep it lightweight.
|
||||||
|
self.previewContent = html; self.previewCache[cacheKey] = html;
|
||||||
}).catch(function() { self.previewContent = '(preview not available)'; });
|
}).catch(function() { self.previewContent = '(preview not available)'; });
|
||||||
}
|
}
|
||||||
} else {
|
} 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 ;
|
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
|
||||||
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
|
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
|
||||||
var _regWs = function () {
|
var _regWs = function () {
|
||||||
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
// Objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
||||||
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
|
// non snapshoté par ji). L'état reste unique, MAIS la factory Alpine
|
||||||
Alpine.data('wsInitData', function () { return _wsInitData; });
|
// 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
|
// Bloc preview : le parseur le place HORS de la div racine (structure
|
||||||
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
|
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
|
||||||
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
|
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
|||||||
]},{name:'AI',items:[
|
]},{name:'AI',items:[
|
||||||
{id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'},
|
{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:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'},
|
||||||
|
{id:'meeting',name:'AI Meeting Notes',icon:'🎙️'},
|
||||||
]},
|
]},
|
||||||
{name:'MEDIA',items:[
|
{name:'MEDIA',items:[
|
||||||
{id:'image',name:'Image',icon:'🖼️'},{id:'embed',name:'Embed',icon:'🔗'},
|
{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) ──
|
// ── Block type shortcuts (shown on the right of each slash item) ──
|
||||||
// Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---)
|
// 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'};
|
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) ──
|
// ── Hover preview card (matches Notion's visual hint) ──
|
||||||
function _previewHtml(blockId){
|
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 '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 '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 '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>');
|
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;
|
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){
|
function renderBlock(b,idx){
|
||||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||||
if(b.type==='table')return renderTableBlock(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='';
|
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>`;
|
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==='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 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>`;
|
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';
|
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'){
|
if(mime==='application/pdf'||ext==='pdf'){
|
||||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'pdf'})];this.render();return;
|
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'};
|
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)){
|
if(mime.startsWith('text/')||codeExts.includes(ext)){
|
||||||
try{const r=await fetch(url);const text=await r.text();
|
try{const r=await fetch(url);const text=await r.text();
|
||||||
if(ext==='md'||ext==='markdown'){this.blocks=this.md2b(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();
|
this.render();
|
||||||
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
|
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
|
||||||
return;
|
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)||'';});
|
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;
|
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
|
// Render math blocks with KaTeX
|
||||||
var self=this;
|
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:'');});
|
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 ──
|
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
|
||||||
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
|
_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='';}});
|
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);
|
if(type==='synced')setTimeout(()=>this._pickSyncedBlock(idx),80);
|
||||||
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
|
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
|
||||||
@@ -2701,7 +2731,24 @@ applyAIBlocks(text){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||||
if(id==='duplicate'){this.duplicateBlock(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==='delete'){this.removeBlock(idx);return;}
|
||||||
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);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;}
|
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
@@ -20,7 +20,7 @@ const PRECACHE_URLS = [
|
|||||||
'/static/css/design-tokens.css?v=5.2.0',
|
'/static/css/design-tokens.css?v=5.2.0',
|
||||||
'/static/css/components.css?v=5.2.0',
|
'/static/css/components.css?v=5.2.0',
|
||||||
'/static/css/katex.min.css?v=0.16.11',
|
'/static/css/katex.min.css?v=0.16.11',
|
||||||
'/static/css/prism.css',
|
'/static/css/highlight.css',
|
||||||
// JS
|
// JS
|
||||||
'/static/js/htmx.min.js',
|
'/static/js/htmx.min.js',
|
||||||
'/static/js/alpine.csp.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/app.js?v=2.4.8',
|
||||||
'/static/js/offline.js?v=6.0.0',
|
'/static/js/offline.js?v=6.0.0',
|
||||||
'/static/js/katex.min.js?v=0.16.11',
|
'/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
|
// Icônes & métadonnées
|
||||||
'/static/favicon.ico',
|
'/static/favicon.ico',
|
||||||
'/static/manifest.json',
|
'/static/manifest.json',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user