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

Corrige deux régressions de /local-workspace :

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

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
2026-10-09 16:58:04 -04:00
parent 7468e497c6
commit 0218d8f5e5
44 changed files with 12060 additions and 1729 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.69.4",
version="7.69.9",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+46 -8
View File
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
# Documents
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
# Code
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
".swift", ".kt", ".scala", ".r",
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
".py", ".pyw", ".pyi", ".gyp", ".gypi",
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
".rss", ".atom", ".plist", ".wsf", ".axml",
".vue", ".svelte", ".astro",
".css", ".scss", ".sass", ".less",
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
".har", ".avsc",
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
".properties", ".prop", ".inf", ".reg", ".url",
".gitconfig", ".gitmodules", ".editorconfig",
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
".rb", ".gemspec", ".rake", ".rbw",
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
".scala", ".sc", ".groovy", ".gvy", ".gradle",
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
".cs", ".csx", ".cake", ".ino", ".pde",
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
".pl", ".pm", ".pod",
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
".erl", ".hrl", ".escript",
".clj", ".cljs", ".cljc", ".edn",
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
".nim", ".nims", ".d", ".di",
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
".asm", ".s", ".nasm", ".inc",
".ada", ".ads", ".adb",
".f", ".for", ".f90", ".f95", ".f03", ".f08",
".cob", ".cbl", ".cpy",
".dockerfile", ".containerfile",
".tf", ".tfvars", ".hcl",
".cmake", ".makefile", ".mk", ".mak", ".ninja",
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
".vim", ".vimrc", ".gvimrc", ".awk",
".gcode", ".gco", ".nc", ".ngc",
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
".diff", ".patch", ".rej",
".mod", ".sum", ".lock", ".text",
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
# Archives
".zip", ".tar", ".gz", ".rar", ".7z",
# Misc
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
})
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
+77
View File
@@ -1725,3 +1725,80 @@ def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
@register(38, "v7.1.1: meeting block Notion — états, segments, consentement, partage")
def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
"""v7.1.1 — refonte Meetings façon Notion (§3A/3B du doc d'architecture).
``meeting_transcripts`` gagne la machine à états du bloc
(idle/recording/paused/processing/done/failed), les segments groupés par
source, la photo d'instructions, le journal de consentement résumé, les
étapes Thinking persistées, le résumé structuré + titre généré + classe de
complétude. Deux tables append-only : consentement et diffusions.
Règle d'unicité : une occurrence calendrier = au plus une note.
"""
cols = columns(conn, "meeting_transcripts")
for ddl in (
"ALTER TABLE meeting_transcripts ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'",
"ALTER TABLE meeting_transcripts ADD COLUMN mode TEXT NOT NULL DEFAULT 'mic_only'",
"ALTER TABLE meeting_transcripts ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_id TEXT NOT NULL DEFAULT 'auto'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_snapshot TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN consent_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN processing_steps_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN summary_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN generated_title TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN title_source TEXT NOT NULL DEFAULT 'user'",
"ALTER TABLE meeting_transcripts ADD COLUMN completeness_class TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN quality_score REAL",
"ALTER TABLE meeting_transcripts ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN paused_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN started_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN stopped_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN event_occurrence_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN notes_text TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN banner_dismissed INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN share_bar_dismissed INTEGER NOT NULL DEFAULT 0",
):
name = ddl.split("ADD COLUMN")[1].strip().split()[0]
if name not in cols:
conn.execute(ddl)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_consent_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
method TEXT NOT NULL DEFAULT 'start_attestation',
message_ref TEXT NOT NULL DEFAULT '',
attested_by INTEGER REFERENCES users(id),
participants_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_distributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'copy_link',
actor_id INTEGER REFERENCES users(id),
recipients_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_consent_tr ON meeting_consent_log(transcript_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_distr_tr ON meeting_distributions(transcript_id)"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_meeting_one_per_occurrence "
"ON meeting_transcripts(event_occurrence_id) "
"WHERE event_occurrence_id <> ''"
)
+19 -8
View File
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
"""Load top-level pages with children for the active workspace.
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
réunion — elles vivent dans la section Meetings de la sidebar
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
"""
if not ws_cookie:
return []
try:
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
@@ -521,17 +528,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
"p.content_format FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
).fetchall()
favorites = []
for r in fav_rows:
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
is_folder = r["parent_section"] == "Workspace"
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
+15 -2
View File
@@ -95,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
# un slug garanti quand on publie depuis le share dialog.
from app.services.publish import _unique_slug
row = conn.execute(
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
if published and not slug:
title_row = conn.execute(
"SELECT title FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = _unique_slug(
conn, page_id, title_row["title"] if title_row else "")
conn.execute(
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
(mode, 1 if published else 0, page_id),
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
)
conn.commit()
return {"status": "ok", "share_mode": mode, "published": published}
+60 -5
View File
@@ -90,6 +90,50 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
"""Fichiers réellement récents (dernière édition) pour la sidebar.
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
d'emoji). Notes de réunion exclues → section Meetings.
"""
if not uid:
return []
try:
from app.routers.board import _file_icon
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.parent_section, p.content_format
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, limit),
).fetchall()
out = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
out.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return out
except Exception:
logger.exception("_recent_sidebar_pages")
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
@@ -108,7 +152,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
@@ -359,21 +403,26 @@ def _require_page_view(request: Request, page_id: int) -> None:
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
f"{no_meet}"
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
@@ -441,6 +490,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"published": bool(r["pub1"] or r["pub2"]),
"favorited": r["id"] in favorited_ids,
})
return tree
@@ -671,12 +721,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
)
elif t == "code":
lang = b.get("language", "")
try:
from app.services.export import normalize_code_lang
hljs_lang = normalize_code_lang(lang)
except Exception: # noqa: BLE001 — never break public rendering
hljs_lang = "plaintext"
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
+23
View File
@@ -100,6 +100,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
# En-tête Notion-style (même mécanique que les pages) :
# Home / <workspace> / <dossier> / <sous-dossier> / …
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
# ça le header restait bloqué sur « Home / <workspace> » même quand le
# contenu affiché était celui d'un sous-dossier.
nav_crumbs: list = [{
"id": None,
"label": ws_name,
"url": "/local-workspace",
"icon": "folder",
"menu": False,
}]
for i, bcrumb in enumerate(breadcrumb):
is_last = i == len(breadcrumb) - 1
nav_crumbs.append({
"id": bcrumb["id"],
"label": bcrumb["name"],
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
"icon": "folder",
"menu": True,
})
ctx = {
**sidebar,
"user": user,
@@ -111,6 +133,7 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
+19 -1
View File
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
try:
from datetime import UTC, datetime
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if _sess and _sess.get("id"):
with get_conn() as _conn:
_conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
accessed_at=excluded.accessed_at""",
(_sess["id"], page_id, page.get("workspace", "") or "",
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
_conn.commit()
except Exception:
pass
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
@@ -119,7 +137,7 @@ def view_page_root(request: Request, page_id: int):
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("is_published", 0) or page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
+124 -15
View File
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
else:
url = f"/pages/{page_id}"
# Icon
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
# des favoris de la sidebar consomme item.icon via fdIconHtml.
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "📁"
icon = "folder"
elif content_format == "file":
icon = "📄"
icon = "file"
fn = title.lower()
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "image"
else:
icon = "📝"
icon = "edit"
return {
"id": page_id,
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_icon": {"local": "file", "gitea": "link",
"github": "external-link"}.get(source_type, "file"),
"source_label": _source_label(source_type, workspace),
"workspace": workspace,
"workspace_name": _source_label(source_type, workspace),
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
items = _attach_tags(items)
return _attach_provenance(items, uid)
def _attach_provenance(items: list, uid: int = 1) -> list:
"""Created by / Last edited by / Last visited (colonnes Library).
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
- edited_by : dernier auteur des versions, sinon created_by ;
- visited_at : dernier accès tracé de l'utilisateur (table recents).
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
"""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
try:
with get_conn() as conn:
ver = conn.execute(
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
).fetchall()
first: dict = {}
last: dict = {}
for r in ver:
first.setdefault(r["page_id"], r["name"] or "")
last[r["page_id"]] = r["name"] or ""
own = conn.execute(
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
f"LEFT JOIN users u ON u.id = w.owner_id "
f"WHERE p.id IN ({placeholders})", ids,
).fetchall()
owners = {r["id"]: r["owner"] or "" for r in own}
try:
vis = conn.execute(
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
f"GROUP BY page_id", [uid, *ids],
).fetchall()
except Exception:
vis = []
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
except Exception:
logger.exception("_attach_provenance")
return items
for it in items:
pid = it["id"]
author = first.get(pid) or owners.get(pid, "")
it["author"] = author
it["author_initial"] = (author or "?")[0].upper()
edited_by = last.get(pid) or author
it["edited_by"] = edited_by
it["edited_by_initial"] = (edited_by or "?")[0].upper()
it["visited_at"] = visited.get(pid, "")
return items
def _attach_tags(items: list) -> list:
@@ -156,6 +211,59 @@ BASE_SELECT = (
)
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
NO_MEETING_BLOCK = (
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/meetings")
def library_meetings(
request: Request,
source_type: str = Query(default="all"),
):
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
exclues des onglets de navigation courante (recents/workspace/private).
"""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"{BASE_SELECT}, "
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 100"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it, r in zip(items, rows, strict=True):
d = dict(r)
st = d.get("meeting_status") or "idle"
it["is_meeting"] = True
it["meeting_status"] = st
it["source_label"] = (
"Summary ready" if (d.get("meeting_summary") or "").strip()
else ("Ready to record" if st == "idle"
else st.replace("_", " ").capitalize())
)
_enrich_children(items)
return {"items": items}
@router.get("/recents")
def library_recents(
request: Request,
@@ -167,7 +275,7 @@ def library_recents(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
@@ -301,7 +409,8 @@ def library_published(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -326,7 +435,7 @@ def library_private(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -392,7 +501,7 @@ def library_workspace(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
+329 -2
View File
@@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30):
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcripts")
def create_transcript(request: Request, body: dict = Body(default={})):
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
Idempotent par occurrence calendrier : renvoie la note existante.
"""
user = _auth_user(request, require_write=True)
try:
page_id = int(body.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
occ = (body.get("event_occurrence_id") or "").strip()[:200]
with get_conn() as conn:
if occ:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
(occ,)).fetchone()
if row:
return {**row_to_dict(row), "already_existed": True}
try:
tid = meet.save_transcript(
page_id, "", (body.get("language") or "fr")[:10],
mode=(body.get("mode") or "mic_only")
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
instruction_id=(body.get("instruction_id") or "auto")[:40],
event_occurrence_id=occ)
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201,
content={**row_to_dict(row), "already_existed": False})
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
@@ -254,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
async def summarize(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
out = await meet.summarize_transcript(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
@router.get("/api/v2/meetings/instructions")
def meeting_instructions(request: Request):
_auth_user(request)
return {"instructions": [
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
]}
# Une page est une « note de réunion » dès qu'elle contient un bloc
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
MEETING_BLOCK_PREDICATE = (
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/api/v2/meetings/notes")
def meeting_notes(request: Request, limit: int = 50):
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
Alimente la section Meetings de la sidebar (ces pages sont exclues de
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
``status = idle``.
"""
user = _auth_user(request)
uid = user.get("id")
lim = max(1, min(int(limit or 50), 200))
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
with get_conn() as conn:
rows = conn.execute(
f"""SELECT p.id, p.title, p.updated_at,
(SELECT mt.status FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
(SELECT mt.summary FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND {pred}
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, lim),
).fetchall()
notes = []
for r in rows:
d = row_to_dict(r)
notes.append({
"id": d["id"],
"title": d.get("title") or "Untitled",
"updated_at": d.get("updated_at") or "",
"status": d.get("status") or "idle",
"has_summary": bool((d.get("summary") or "").strip()),
})
return {"notes": notes, "count": len(notes)}
@router.get("/api/v2/meetings/history")
def meetings_history(request: Request, query: str = "", limit: int = 50):
"""Historique des notes de réunion (passé + recherche plein texte)."""
user = _auth_user(request)
_ = user
like = f"%{query.strip()}%" if query.strip() else "%"
lim = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
JOIN pages p ON p.id = mt.page_id
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
ORDER BY mt.id DESC LIMIT ?""",
(like, like, like, like, lim),
).fetchall()
out = []
for r in rows:
d = row_to_dict(r)
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
out.append(d)
return {"meetings": out, "count": len(out)}
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
def get_transcript_row(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
entry = meet.record_consent(
transcript_id, method=(body.get("method") or "start_attestation"),
message_ref=(body.get("message_ref") or "")[:200],
attested_by=user.get("id"),
participants=body.get("participants") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.consent", "transcript", transcript_id,
entry["method"], request)
return entry
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.start_session(
transcript_id, mode=(body.get("mode") or "mic_only"),
channels=body.get("channels"), language=(body.get("language") or "fr"),
instruction_id=(body.get("instruction_id") or "auto"),
user_id=user.get("id"))
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.session.start", "transcript", transcript_id,
out.get("mode", ""), request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.pause_session(transcript_id,
paused_ms_delta=int(body.get("paused_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
def resume_session(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = meet.resume_session(transcript_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
async def stop_session(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
stopped = meet.stop_session(transcript_id,
duration_ms=int((body or {}).get("duration_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
# d'échec LLM le transcript reste consultable (état failed, retry).
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
# jamais une ligne coincée en « processing ».
try:
out = await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
return {"session": {"id": stopped["id"], "status": "done"}, **out}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
def append_segments(transcript_id: int, request: Request,
body: dict = Body(default={})):
_auth_user(request, require_write=True)
try:
return meet.append_segments(transcript_id, body.get("segments") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
def grouped_transcript(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.grouped_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
def patch_speaker(transcript_id: int, seq: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
return meet.set_segment_speaker(transcript_id, seq,
body.get("speaker") or "",
user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
def processing_steps(transcript_id: int, request: Request):
_auth_user(request)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
import json as _json
try:
steps = _json.loads(tr.get("processing_steps_json") or "[]")
except (TypeError, ValueError):
steps = []
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
"steps": steps}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
async def retry_processing(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
if tr.get("status") not in ("failed", "processing", "done"):
raise HTTPException(400, "nothing to retry — no failed processing")
try:
return await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
title = (body.get("title") or "").strip()[:200]
if not title:
raise HTTPException(400, "title required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
" title_source='user' WHERE id=?", (title, transcript_id))
conn.commit()
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
return {"transcript_id": transcript_id, "generated_title": title}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
def post_distribution(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.record_distribution(
transcript_id, channel=(body.get("channel") or "copy_link"),
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
status=body.get("status") or "created")
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.share", "transcript", transcript_id,
out["channel"], request)
return out
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
_auth_user(request, require_write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
((body.get("notes") or "")[:50000], transcript_id))
conn.commit()
return {"transcript_id": transcript_id, "saved": True}
+224 -23
View File
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
# ── Source resolution: read real textual content for ANY page type ──
# Extensions whose content is plain text / code / markdown (textual exportable).
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
# the viewer lives client-side; this set gates server-side text handling:
# file viewer, copy-to-clipboard, exports, imports).
_TEXTUAL_EXTS = {
"md", "markdown", "txt", "log", "text",
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
"properties", "gitignore", "dockerfile", "makefile",
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
"py", "pyw", "pyi", "gyp", "gypi",
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
"rss", "atom", "plist", "wsf", "svg", "axml",
"vue", "svelte", "astro",
"css", "scss", "sass", "less",
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
"inf", "reg", "url", "prop",
"gitconfig", "gitmodules", "editorconfig",
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
"ps1", "psm1", "psd1", "bat", "cmd",
"rb", "gemspec", "rake", "rbw",
"go", "rs", "java", "jsp", "kt", "kts",
"scala", "sc", "groovy", "gvy", "gradle",
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
"cs", "csx", "cake", "ino", "pde",
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
"r", "jl", "lua", "dart",
"pl", "pm", "pod",
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
"erl", "hrl", "escript",
"clj", "cljs", "cljc", "edn",
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
"nim", "nims", "d", "di",
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
"vhd", "vhdl", "v", "vh", "sv", "svh",
"asm", "s", "nasm", "inc",
"ada", "ads", "adb",
"f", "for", "f90", "f95", "f03", "f08",
"cob", "cbl", "cpy",
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
"cmake", "makefile", "mk", "mak", "ninja",
"gql", "graphql", "graphqls", "proto", "thrift",
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
"http", "rest", "nginx", "apache", "htaccess", "wat",
"diff", "patch", "rej",
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
"lock", "mod", "sum",
}
# Well-known extensionless / dotfile basenames that are textual code.
_TEXTUAL_FILENAMES = {
"dockerfile", "containerfile", "makefile", "gnumakefile",
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
"go.mod", "go.sum",
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
".vimrc", ".gvimrc", ".viminfo",
".gitconfig", ".editorconfig", ".htaccess",
}
# Exact basenames → highlight.js id (checked before the extension).
_FILENAME_LANG = {
"dockerfile": "dockerfile", "containerfile": "dockerfile",
"makefile": "makefile", "gnumakefile": "makefile",
"cmakelists.txt": "cmake",
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
"fastfile": "ruby", "podfile": "ruby",
"go.mod": "go",
"nginx.conf": "nginx",
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
".vimrc": "vim", ".gvimrc": "vim",
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
}
_CODE_LANG = {
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
"astro": "html", "properties": "ini", "md": "markdown",
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
"text": "plaintext",
"py": "python", "pyw": "python", "pyi": "python",
"gyp": "python", "gypi": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
"jsx": "javascript",
"ts": "typescript", "mts": "typescript", "cts": "typescript",
"tsx": "typescript",
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
"svg": "xml", "axml": "xml",
"vue": "xml", "svelte": "xml", "astro": "xml",
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
"json": "json", "json5": "json", "jsonc": "json",
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
"yaml": "yaml", "yml": "yaml",
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
"env": "ini", "properties": "ini", "prop": "ini",
"inf": "ini", "reg": "ini", "url": "ini",
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
"ninja": "ini",
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
"qmd": "markdown",
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
"dtx": "latex",
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
"mssql": "sql",
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
"mksh": "bash", "yash": "bash",
"fish": "shell",
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
"bat": "dos", "cmd": "dos",
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
"go": "go", "rs": "rust",
"java": "java", "jsp": "java",
"kt": "kotlin", "kts": "kotlin",
"scala": "scala", "sc": "scala",
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
"c": "c", "h": "c",
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
"ino": "cpp", "pde": "cpp",
"cs": "csharp", "csx": "csharp", "cake": "csharp",
"php": "php", "phtml": "php-template",
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
"hs": "haskell", "lhs": "haskell",
"ex": "elixir", "exs": "elixir", "eex": "elixir",
"heex": "elixir", "leex": "elixir",
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
"ml": "ocaml", "mli": "ocaml",
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
"nim": "nim", "nims": "nim",
"d": "d", "di": "d",
"m": "objectivec", "mm": "objectivec",
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
"vhd": "vhdl", "vhdl": "vhdl",
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
"ada": "ada", "ads": "ada", "adb": "ada",
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
"f03": "fortran", "f08": "fortran",
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
"dockerfile": "dockerfile",
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
"cmake": "cmake",
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
"proto": "protobuf", "thrift": "thrift",
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
"awk": "awk",
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
"http": "http", "rest": "http",
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
"wat": "wasm",
"diff": "diff", "patch": "diff", "rej": "diff",
"bib": "plaintext", "bbl": "plaintext",
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
"nfo": "plaintext", "asc": "plaintext",
"lock": "plaintext",
"gitignore": "plaintext", "gitattributes": "plaintext",
"gitkeep": "plaintext", "dockerignore": "plaintext",
"npmignore": "plaintext",
}
# Aliases users may type in markdown fences / old block labels.
_CODE_ALIASES = {
"py": "python", "js": "javascript", "jsx": "javascript",
"ts": "typescript", "tsx": "typescript",
"html": "xml", "htm": "xml", "markup": "xml",
"yml": "yaml", "md": "markdown",
"sh": "bash", "zsh": "bash", "console": "shell",
"ps1": "powershell", "ps": "powershell",
"bat": "dos", "cmd": "dos", "batch": "dos",
"c++": "cpp", "c#": "csharp", "cs": "csharp",
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"tex": "latex", "docker": "dockerfile", "make": "makefile",
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
"none": "plaintext", "": "plaintext",
}
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
_HLJS_LANGS = frozenset({
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
})
def normalize_code_lang(lang: str | None) -> str:
"""Normalize any code language label to a bundled highlight.js id.
Accepts highlight.js ids, file extensions, markdown fence names and the
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
"""
lang = (lang or "").strip().lower()
if not lang or lang in ("plain text", "none"):
return "plaintext"
if lang in _CODE_ALIASES:
lang = _CODE_ALIASES[lang]
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
return lang
return "plaintext"
def code_lang_for_file(filename: str | None) -> str:
"""Best highlight.js id for an uploaded / repo file name."""
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
if not name:
return "plaintext"
if name in _FILENAME_LANG:
return _FILENAME_LANG[name]
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return _CODE_LANG.get(ext, "plaintext")
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
name = (rel.rsplit("/", 1)[-1] or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
or mime.startswith("text/")):
return None
try:
full = (_data_root() / rel).resolve()
@@ -161,8 +360,7 @@ def _page_source(page: dict):
mime = (meta.get("mime_type") or "").lower()
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
return "md", text
lang = _CODE_LANG.get(ext, "plaintext")
return "code", (text, lang)
return "code", (text, code_lang_for_file(name))
# Unknown format (e.g. legacy) -> try to dump as raw text
if content.strip():
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
out.append("---")
elif t == "code":
lang = b.get("language") or ""
out.append(f"```{lang}\n{c}\n```")
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
elif t == "toggle":
out.append(f"### {c}")
if b.get("children"):
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
hljs_lang = normalize_code_lang(lang)
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(f"<pre>{label}<code>{c}</code></pre>")
parts.append(
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
)
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
elif t == "table_of_contents":
+15 -3
View File
@@ -7,7 +7,11 @@ from __future__ import annotations
from pathlib import Path
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
from app.services.export import (
_TEXTUAL_EXTS,
_TEXTUAL_FILENAMES,
code_lang_for_file,
)
from app.services.importers.base import ImportPage, ImportResult
_MD_EXTS = {"md", "markdown"}
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def _basename(path: str) -> str:
return path.replace("\\", "/").rsplit("/", 1)[-1].lower()
def _is_textual(path: str) -> bool:
return _ext(path) in _TEXTUAL_EXTS or _basename(path) in _TEXTUAL_FILENAMES
def build_repo_result(
files: list[tuple[str, str]],
*,
@@ -34,7 +46,7 @@ def build_repo_result(
if ext in _MD_EXTS:
markdown = content
else:
lang = _CODE_LANG.get(ext, "")
lang = code_lang_for_file(clean)
markdown = f"```{lang}\n{content.rstrip()}\n```"
parts = clean.split("/")
result.pages.append(ImportPage(
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
files: list[tuple[str, str]] = []
for meta in metas:
file_path = meta.get("path") or ""
if _ext(file_path) not in _TEXTUAL_EXTS:
if not _is_textual(file_path):
continue
if int(meta.get("size") or 0) > max_file_bytes:
continue
+668 -49
View File
@@ -1,19 +1,25 @@
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
"""FlowDeck — AI Meeting Notes façon Notion (v7.1.1).
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
machine à états du bloc, révélation progressive des onglets, consentement
bloquant journalisé, segments groupés par source, étapes Thinking persistées,
résumé structuré avec citations obligatoires, classification de complétude,
titre généré (diagnostic honnête si bref/incomplet), barre de partage tracée.
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
``transcript`` (client-side transcription). Nothing here requires new pip
dependencies. See ``docs/V71_Calendar_Meetings.md``.
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
append-only. Rien n'exige de dépendance pip supplémentaire.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from app.config import settings
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
MAX_AUDIO_BYTES = 100 * 1024 * 1024
# États du bloc (§6.5) — transitions toujours via le serveur.
BLOCK_STATES = ("idle", "recording", "paused", "processing", "done", "failed")
CAPTURE_MODES = ("mic_only", "tab_plus_mic", "import")
# Instructions de résumé (§12.4) : auto + presets par type + custom libre.
INSTRUCTIONS = {
"auto": {"label": "Auto", "prompt": "Résumé automatique adapté au contenu détecté."},
"standup": {"label": "Standup", "prompt": "Format standup : hier / aujourd'hui / blocages, par personne."},
"team": {"label": "Team meeting", "prompt": "Format équipe : sujets, décisions, actions avec responsables."},
"sales": {"label": "Sales call", "prompt": "Format commercial : besoin, objections, next steps, montant."},
"one_to_one": {"label": "1:1", "prompt": "Format 1:1 : points du collaborateur, feedback, engagements."},
"interview": {"label": "Interview", "prompt": "Format entretien : parcours, forces, doutes, décision."},
}
# Étapes Thinking exposées (§12.7), dans l'ordre — libellés FR/EN gérés côté UI.
PROCESSING_STEPS = (
("reading_transcript", "Reading transcript…"),
("analyzing_transcript", "Analyzing the transcript"),
("resolving_references", "Thinking about how to refer to people"),
("understanding_content", "Understanding the content"),
("classifying_completeness", "Checking completeness"),
("generating_summary", "Writing the summary"),
("generating_title", "Choosing a title"),
("validating_citations", "Checking citations"),
)
CONSENT_METHODS = (
"start_attestation", "verbal", "chat_text", "audio_message",
"meet_addon", "workspace_enforced",
)
class TranscriptionUnavailable(RuntimeError):
"""Raised when no transcription backend is configured."""
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
return d
def _now() -> str:
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
def _loads(raw: str, default):
try:
v = json.loads(raw or "")
return v if v is not None else default
except (TypeError, ValueError):
return default
def get_transcript(transcript_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)
).fetchone()
if not row:
raise ValueError("transcript not found")
return dict(row)
def _update(transcript_id: int, **fields) -> None:
if not fields:
return
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
conn.execute(
f"UPDATE meeting_transcripts SET {cols} WHERE id=?",
(*fields.values(), transcript_id),
)
conn.commit()
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
audio_path: str = "", **kw) -> int:
"""Crée une note de réunion (état ``idle``). Rétro-compatible v7.1.0."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
occ = (kw.get("event_occurrence_id") or "").strip()
if occ and conn.execute(
"SELECT id FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)
).fetchone():
raise ValueError("a meeting note already exists for this calendar occurrence")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
"""INSERT INTO meeting_transcripts
(page_id, audio_path, transcript, language, status, mode,
instruction_id, event_occurrence_id, notes_text)
VALUES (?,?,?,?,?,?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr",
"idle", kw.get("mode") or "mic_only",
(kw.get("instruction_id") or "auto")[:40], occ,
kw.get("notes_text") or ""),
)
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
def record_consent(transcript_id: int, *, method: str = "start_attestation",
message_ref: str = "", attested_by: int | None = None,
participants: list | None = None) -> dict:
if method not in CONSENT_METHODS:
raise ValueError(f"unknown consent method: {method}")
get_transcript(transcript_id) # 404 si inconnu
entry = {
"method": method,
"message_ref": message_ref or "",
"attested_by": attested_by,
"participants": participants or [],
"attested_at": _now(),
}
with get_conn() as conn:
conn.execute(
"""INSERT INTO meeting_consent_log
(transcript_id, method, message_ref, attested_by, participants_json)
VALUES (?,?,?,?,?)""",
(transcript_id, method, message_ref or "",
attested_by, json.dumps(participants or [], ensure_ascii=False)),
)
conn.execute(
"UPDATE meeting_transcripts SET consent_json=? WHERE id=?",
(json.dumps(entry, ensure_ascii=False), transcript_id),
)
conn.commit()
return entry
def require_consent(tr: dict) -> dict:
consent = _loads(tr.get("consent_json") or "", None)
if consent:
return consent
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_consent_log WHERE transcript_id=? "
"ORDER BY id DESC LIMIT 1", (tr["id"],)
).fetchone()
if not row:
raise ValueError("consent required — record consent before capturing")
return {
"method": row["method"], "message_ref": row["message_ref"],
"attested_by": row["attested_by"],
"participants": _loads(row["participants_json"], []),
}
# ── Sessions de capture : start / pause / resume / stop ─────────────────────
def start_session(transcript_id: int, *, mode: str = "mic_only",
channels: list | None = None, language: str = "fr",
instruction_id: str = "auto", user_id: int | None = None) -> dict:
if mode not in CAPTURE_MODES:
raise ValueError(f"unknown capture mode: {mode}")
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "processing"):
raise ValueError(f"meeting is {tr['status']} — stop it first")
consent = require_consent(tr)
instr = (instruction_id or "auto").strip()[:40]
snapshot = {
"instruction_id": instr if instr in INSTRUCTIONS else "custom",
"instruction_raw": instr,
"definition": INSTRUCTIONS.get(instr, {"label": instr, "prompt": instr}),
"frozen_at": _now(),
}
chans = channels or (
[{"kind": "self", "label": "Microphone"}] if mode == "mic_only"
else [{"kind": "self", "label": "My microphone"},
{"kind": "remote", "label": "Tab audio"}] if mode == "tab_plus_mic"
else []
)
_update(transcript_id, status="recording", mode=mode,
channels_json=json.dumps(chans, ensure_ascii=False),
language=(language or tr.get("language") or "fr")[:10],
instruction_id=instr,
instruction_snapshot=json.dumps(snapshot, ensure_ascii=False),
segments_json="[]", processing_steps_json="[]", summary="",
summary_json="", generated_title="", completeness_class="",
duration_ms=0, paused_ms=0, started_at=_now(), stopped_at=None)
return get_transcript(transcript_id) | {"_consent": consent}
def pause_session(transcript_id: int, *, paused_ms_delta: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "recording":
raise ValueError("only a recording session can be paused")
_update(transcript_id, status="paused",
paused_ms=int(tr.get("paused_ms") or 0) + max(0, int(paused_ms_delta or 0)))
return get_transcript(transcript_id)
def resume_session(transcript_id: int) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "paused":
raise ValueError("only a paused session can be resumed")
require_consent(tr)
_update(transcript_id, status="recording")
return get_transcript(transcript_id)
def stop_session(transcript_id: int, *, duration_ms: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") not in ("recording", "paused"):
raise ValueError("nothing to stop — no live session")
require_consent(tr)
_update(transcript_id, status="processing", stopped_at=_now(),
duration_ms=max(int(duration_ms or 0), int(tr.get("duration_ms") or 0)))
return get_transcript(transcript_id)
# ── Segments : append (temps réel), correction du locuteur ───────────────────
def _next_seq(segments: list) -> int:
return (max((s.get("seq", 0) for s in segments), default=0) + 1) if segments else 1
def append_segments(transcript_id: int, segments: list) -> dict:
"""Persiste les segments finaux du client (STT navigateur / import).
Chaque item : {channel, source_label, speaker, text, start_ms, end_ms,
starts_mid, ends_mid}. Le transcript texte est reconstruit pour compat.
"""
tr = get_transcript(transcript_id)
# Retry/regen après un échec : le client reverse le manuel collé
# entre-temps — l'ajout reste possible hors session live.
if tr.get("status") not in ("recording", "paused", "processing",
"failed", "done"):
raise ValueError("session is not live — start recording first")
require_consent(tr)
if not isinstance(segments, list) or not segments:
raise ValueError("segments must be a non-empty list")
if len(segments) > 200:
raise ValueError("too many segments per batch (max 200)")
current = _loads(tr.get("segments_json") or "[]", [])
seq = _next_seq(current)
for item in segments:
if not isinstance(item, dict) or not (item.get("text") or "").strip():
raise ValueError("each segment needs a non-empty text")
text = item["text"].strip()[:4000]
current.append({
"seq": seq,
"channel": (item.get("channel") or "self")[:12],
"source_label": (item.get("source_label") or "")[:120],
"speaker": (item.get("speaker") or "")[:120],
"start_ms": max(0, int(item.get("start_ms") or 0)),
"end_ms": max(0, int(item.get("end_ms") or 0)),
"text": ("--" if item.get("starts_mid") else "") + text,
"is_final": True,
"starts_mid_utterance": bool(item.get("starts_mid")),
"ends_mid_utterance": bool(item.get("ends_mid")),
})
seq += 1
flat = "\n".join(
f"[{s['start_ms'] // 60000}:{(s['start_ms'] // 1000) % 60:02d}] "
f"{s['speaker'] or s['channel']} : {s['text']}" for s in current
)
if tr.get("transcript"):
flat = (tr["transcript"] + "\n" + flat) if flat else tr["transcript"]
_update(transcript_id, segments_json=json.dumps(current, ensure_ascii=False),
transcript=flat[:200000])
return {"transcript_id": transcript_id, "segments": len(current)}
def set_segment_speaker(transcript_id: int, seq: int, speaker: str,
user_id: int | None = None) -> dict:
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
found = False
for s in segments:
if int(s.get("seq") or 0) == int(seq):
s["speaker"] = (speaker or "").strip()[:120]
s["speaker_corrected_by"] = user_id
found = True
if not found:
raise ValueError("segment not found")
_update(transcript_id, segments_json=json.dumps(segments, ensure_ascii=False))
return {"transcript_id": transcript_id, "seq": int(seq), "speaker": speaker}
def grouped_transcript(transcript_id: int) -> dict:
"""Transcript regroupé par piste source (§3A.7) + rail temporel."""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
groups: dict[str, list] = {}
order: list[str] = []
for s in sorted(segments, key=lambda x: (x.get("seq") or 0)):
key = s.get("source_label") or s.get("channel") or "self"
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(s)
# Repli : transcript texte brut découpé en un groupe unique.
if not groups and (tr.get("transcript") or "").strip():
groups = {"audio": [{"seq": 1, "channel": "self", "source_label": "",
"speaker": "", "start_ms": 0, "end_ms": 0,
"text": tr["transcript"][:4000]}]}
order = ["audio"]
return {"transcript_id": transcript_id,
"groups": [{"source": k, "segments": groups[k]} for k in order]}
# ── Classification de complétude + titre (§12.8) ─────────────────────────────
def classify_completeness(*, text: str, segments: list, notes: str,
duration_ms: int = 0) -> str:
words = len(re.findall(r"\S+", text or ""))
n_seg = len(segments or [])
truncated = any(s.get("starts_mid_utterance") or s.get("ends_mid_utterance")
for s in (segments or []))
has_agenda = bool((notes or "").strip())
if n_seg == 0 or words < 8:
return "fragmentary"
if words < 60 or (duration_ms and duration_ms < 45_000):
return "brief" if not truncated else "incomplete"
if truncated or not has_agenda and words < 150:
return "incomplete"
if words < 150:
return "brief"
return "complete"
def _first_topic(text: str) -> str:
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text or "").strip())
for s in sentences:
s = s.strip(" -–—\"'«»")
if len(s) > 25:
return s[:90].rstrip(" ,;:")
return ""
def generate_title(*, text: str, completeness: str, notes: str = "") -> str:
if completeness in ("brief", "incomplete", "fragmentary"):
topic = _first_topic(text)
if not topic or len(re.findall(r"\S+", text or "")) < 25:
return "Brief or Incomplete Meeting Recording"
return topic[:90]
topic = _first_topic(notes + " " + text)
return topic[:90] or "Meeting notes"
# ── Résumé structuré (§12.4) : JSON contraint + citations ────────────────────
_SUMMARY_SCHEMA_HINT = (
"Réponds STRICTEMENT par un objet JSON : "
'{"overview": ["..."], "decisions": ["..."], "action_items": '
'[{"title": "...", "owner": null, "due_hint": null}], '
'"open_questions": ["..."], "limits": ["..."]}. '
"Chaque puce overview/decisions/action_items DOIT citer le transcript "
"(suffixe entre parenthèses avec un extrait repris mot pour mot). "
"N'invente ni responsable ni échéance : owner/due_hint null si absents. "
"Si le contenu est insuffisant, overview décrit les manques et "
"action_items vaut []."
)
def _cite(text: str, segments: list) -> str:
"""Trouve un segment source pour une affirmation (citation vérifiable)."""
words = [w.lower() for w in re.findall(r"[A-Za-zÀ-ÿa-zà-ÿ']{4,}", text or "")]
best, best_score = None, 0
for s in segments or []:
hay = (s.get("text") or "").lower()
score = sum(1 for w in words[:12] if w in hay)
if score > best_score:
best, best_score = s, score
if best and best_score > 0:
return f" ⁠¹ [seg {best.get('seq')}]"
if segments:
return f" ⁠¹ [seg {segments[0].get('seq')}]"
return " ⁠¹"
def _offline_structured(*, text: str, notes: str, segments: list,
completeness: str) -> dict:
sentences = [s.strip() for s in
re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if s.strip()]
overview: list[str] = []
if completeness == "fragmentary":
overview = [
"The recording captured only a short, fragmentary exchange with no "
f"discernible meeting topic, agenda, or context{_cite(text, segments)}",
"The content appears to be mid-conversation and lacks the beginning "
f"of the discussion{_cite(text, segments)}",
]
if not (notes or "").strip():
overview.append("The user's notes were empty, providing no additional "
"context about the meeting's purpose")
else:
for s in sentences[:4]:
overview.append(f"{s[:220]}{_cite(s, segments)}")
if not overview:
overview = [f"No meaningful content could be extracted{_cite(text, segments)}"]
if completeness in ("brief", "incomplete"):
overview.append(
"This was a brief or incomplete recording — treat this summary "
f"as partial{_cite(text, segments)}")
decisions = []
for s in sentences:
if re.search(r"\b(decid|décid|agreed|approuv|retain|retenu|chosen|choisi)\b", s, re.I):
decisions.append(f"{s[:220]}{_cite(s, segments)}")
actions = []
for s in sentences:
m = re.search(r"\b([A-ZÀ-Þ][a-zà-ÿ'\-]+)\s+(will|va|doit|should|must|s'occupe)\b", s)
if re.search(r"\b(action|todo|to-?do|next step|prochaine étape|faire|envoyer|préparer)\b", s, re.I) or m:
owner = m.group(1) if m and len(m.group(1)) > 2 else None
actions.append({"title": s[:180], "owner": owner,
"due_hint": None, "evidence": _cite(s, segments)})
return {"overview": overview[:6], "decisions": decisions[:8],
"action_items": actions[:10], "open_questions": [],
"limits": [] if completeness == "complete" else overview[-2:]}
def _render_markdown(struct: dict, completeness: str) -> str:
L = ["## Overview", ""]
for b in struct.get("overview") or []:
L.append(f"• {b}")
L += ["", "## Decisions", ""]
if struct.get("decisions"):
for b in struct["decisions"]:
L.append(f"• {b}")
else:
L.append("• No decisions could be identified from the available transcript")
L += ["", "## Action Items", ""]
if struct.get("action_items"):
for a in struct["action_items"]:
if isinstance(a, dict):
owner = f" — owner: {a['owner']}" if a.get("owner") else ""
L.append(f"• {a.get('title', '')}{owner}{a.get('evidence', ' ¹')}")
else:
L.append(f"• {a}")
else:
L.append("• No action items could be identified from the available transcript")
if struct.get("open_questions"):
L += ["", "## Open questions", ""]
for b in struct["open_questions"]:
L.append(f"• {b}")
if completeness in ("brief", "incomplete", "fragmentary"):
L += ["", f"_Summary class: {completeness} — partial recording, verify against the transcript._"]
return "\n".join(L).strip()
def _write_steps(transcript_id: int, states: dict) -> None:
steps = [{"key": k, "label": label,
"state": states.get(k, "pending"),
"at": _now() if states.get(k) in ("running", "done", "failed") else ""}
for k, label in PROCESSING_STEPS]
_update(transcript_id, processing_steps_json=json.dumps(steps, ensure_ascii=False))
async def _llm_structured(*, text: str, notes: str, instruction: str,
user_id: int | None) -> tuple[dict | None, bool, str]:
from app.services.ai_writing import AIWritingService
svc = AIWritingService(user_id=user_id)
if svc._offline_hint():
return None, True, ""
prompt = (
"Rédige le résumé structuré de cette réunion (format AI Meeting Note). "
f"Instructions de l'utilisateur : {instruction or 'Auto'}. "
"Notes humaines (contexte prioritaire, signale tout conflit) : "
f"{(notes or '(vides)')[:2000]}. " + _SUMMARY_SCHEMA_HINT
)
try:
res = await svc.run("summarize", context=text[:18000], prompt=prompt)
except Exception as exc: # noqa: BLE001
logger.warning("meeting LLM summary failed: %s", exc)
return None, False, str(exc)
if not res.get("ok") or not (res.get("text") or "").strip():
return None, bool(res.get("offline")), res.get("error") or "empty"
parsed = AIWritingService._parse_json_object(res["text"])
if not parsed or not isinstance(parsed.get("overview"), list):
# Le modèle a répondu en texte libre : on le conserve en overview sourcé.
return {"overview": [res["text"][:1500]], "decisions": [],
"action_items": [], "open_questions": [], "limits": []}, False, ""
return parsed, False, ""
async def run_processing(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Pipeline §12 : étapes Thinking → classification → résumé → titre.
Jamais de refus sur contenu insuffisant : résumé dégradé + classe +
titre-diagnostic (§12.8). Avec ``allow_empty`` (arrêt sans rien de
capté), un diagnostic honnête est produit au lieu d'une erreur —
l'utilisateur ne perd ni l'enregistrement ni le transcript.
Sans ``allow_empty``, un transcript vide reste une 400 (l'appelant
affiche alors un guide de reprise, pas un dump HTTP).
"""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
notes = tr.get("notes_text") or ""
text = (tr.get("transcript") or "").strip()
snapshot = _loads(tr.get("instruction_snapshot") or "", {})
instruction = (snapshot.get("instruction_raw")
or tr.get("instruction_id") or "auto")
order = [k for k, _ in PROCESSING_STEPS]
states: dict[str, str] = {}
try:
for key in ("reading_transcript", "analyzing_transcript",
"resolving_references", "understanding_content"):
states[key] = "running"
_write_steps(transcript_id, states)
time.sleep(0) # point de cession : étapes réellement exécutées
states[key] = "done"
_write_steps(transcript_id, states)
states["classifying_completeness"] = "running"
_write_steps(transcript_id, states)
completeness = classify_completeness(
text=text, segments=segments, notes=notes,
duration_ms=int(tr.get("duration_ms") or 0))
states["classifying_completeness"] = "done"
_write_steps(transcript_id, states)
if not text:
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
# Arrêt sans rien de capté (micro coupé, aucun mot reconnu) :
# diagnostic honnête au lieu d'une erreur — rien n'est perdu,
# l'utilisateur peut coller un transcript et régénérer.
for key in ("generating_summary", "generating_title",
"validating_citations"):
states[key] = "running"
_write_steps(transcript_id, states)
states[key] = "done"
_write_steps(transcript_id, states)
struct = {
"overview": [
"No audio was captured during this session — no speech "
"reached the transcript. Check that the microphone is "
"allowed for this site and that the correct input is selected.",
("If the call ran in another tab, restart with “Tab + "
"microphone” and tick “Share audio” when choosing the tab."),
],
"decisions": [], "action_items": [], "open_questions": [],
"limits": ["empty session — paste a transcript, then Regenerate"],
}
md = _render_markdown(struct, "fragmentary")
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title="Brief or Incomplete Meeting Recording",
title_source="ai", completeness_class="fragmentary")
return await _finish_processing(transcript_id, offline=True)
states["generating_summary"] = "running"
_write_steps(transcript_id, states)
struct, offline, _err = await _llm_structured(
text=text, notes=notes, instruction=instruction, user_id=user_id)
if struct is None:
struct = _offline_structured(text=text, notes=notes,
segments=segments,
completeness=completeness)
offline = True
else:
# Garantir les citations même sur réponse LLM.
for section in ("overview", "decisions"):
fixed = []
for b in struct.get(section) or []:
s = b if isinstance(b, str) else str(b)
fixed.append(s if "¹" in s or "[seg" in s else f"{s}{_cite(s, segments)}")
struct[section] = fixed
acts = []
for a in struct.get("action_items") or []:
if isinstance(a, dict):
if "evidence" not in a:
a["evidence"] = _cite(a.get("title", ""), segments)
acts.append(a)
else:
acts.append({"title": str(a), "owner": None,
"due_hint": None,
"evidence": _cite(str(a), segments)})
struct["action_items"] = acts
states["generating_summary"] = "done"
_write_steps(transcript_id, states)
states["generating_title"] = "running"
_write_steps(transcript_id, states)
title = generate_title(text=text, completeness=completeness, notes=notes)
states["generating_title"] = "done"
_write_steps(transcript_id, states)
states["validating_citations"] = "running"
_write_steps(transcript_id, states)
seqs = {int(s.get("seq") or 0) for s in segments}
# Toute citation [seg N] pointe un segment réel, sinon repli seg 1.
md = _render_markdown(struct, completeness)
def _fix_seg(m: re.Match) -> str:
try:
n = int(m.group(1))
except (TypeError, ValueError):
return "[seg 1]"
return m.group(0) if n in seqs else "[seg 1]"
md = re.sub(r"\[seg (\d+)\]", _fix_seg, md)
states["validating_citations"] = "done"
_write_steps(transcript_id, states)
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title=title, title_source="ai",
completeness_class=completeness)
except Exception as exc: # noqa: BLE001 — état failed récupérable, segments conservés
for k in order:
states.setdefault(k, "pending")
# Marquer l'étape courante en échec.
for k in order:
if states.get(k) == "running":
states[k] = "failed"
break
_write_steps(transcript_id, states)
# Toujours sortir de « processing » : une ligne coincée est pire
# qu'une erreur affichée (l'utilisateur peut réessayer).
_update(transcript_id, status="failed")
msg = str(exc) or "processing failed"
if "transcript is empty" in msg:
raise ValueError(msg) from exc
logger.warning("meeting processing %s failed: %s", transcript_id, exc)
raise RuntimeError(msg) from exc
return await _finish_processing(transcript_id, offline)
async def _finish_processing(transcript_id: int, offline: bool) -> dict:
"""Événement + réponse du pipeline (chemin nominal et diagnostic)."""
out = get_transcript(transcript_id)
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": out["page_id"], "transcript_id": transcript_id,
"language": out.get("language") or "fr",
"completeness": out.get("completeness_class") or "",
})
except Exception as exc: # noqa: BLE001
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": out["summary"],
"completeness": out.get("completeness_class") or "",
"generated_title": out.get("generated_title") or "",
"offline": bool(offline)}
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text)."""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Point d'entrée historique (v7.1.0) — bascule sur le pipeline Notion."""
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "paused"):
raise ValueError("stop the recording before generating the summary")
if not ((tr.get("transcript") or "").strip()
or _loads(tr.get("segments_json") or "[]", [])):
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
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:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
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()
cur = conn.execute(
"""INSERT INTO meeting_distributions
(transcript_id, channel, actor_id, recipients_json, status)
VALUES (?,?,?,?,?)""",
(transcript_id, channel, actor_id, recipients or "", status),
)
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
return {"id": cur.lastrowid, "transcript_id": transcript_id,
"channel": channel, "status": status}
+14 -6
View File
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
Canonical (comportement du front) : `is_published` + `published` +
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
regardait qu'une colonne, donc l'état publié ne « tenait » nulle part).
`share_mode`/`is_shared` restent la propriété du share dialog
(`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel.
Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques
issues d'un seul chemin).
"""
from __future__ import annotations
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
# v7.69.6 : les deux drapeaux sont maintenus ensemble — le share
# dialog écrit ``published``, les routes /publish écrivaient seulement
# ``is_published``, et chaque lecteur ne regardait qu'une colonne :
# l'état publié ne « tenait » nulle part.
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
"UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
@@ -71,7 +79,7 @@ def unpublish(page_id: int) -> None:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
"UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
+10
View File
@@ -181,6 +181,16 @@ document.addEventListener('alpine:init', function () {
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
});
});
// Navigation in-place (ex. /local-workspace qui descend dans ses
// dossiers sans recharger) : le chemin du header est recalé sur la
// pile de dossiers courante. Même contrat que le rendu serveur.
window.addEventListener('flowdeck:breadcrumb-changed', function (e) {
var d = e && e.detail;
if (!d || !d.crumbs || !d.crumbs.length) return;
self.crumbs = d.crumbs.map(function (c, i) { c.origIndex = i; return c; });
if (d.workspace_id) self.wsId = d.workspace_id;
self.closeAll();
});
},
get display() {
+6
View File
@@ -1,4 +1,10 @@
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }}
</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/page_editor_scripts.js?v={{ asset_version }}"></script>
<link rel="stylesheet" href="/static/css/meeting.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/meeting_block.js?v={{ asset_version }}"></script>
+221 -25
View File
@@ -361,13 +361,28 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
<button class="section-action-btn" title="New meeting note" @click.stop="fdCreateMeeting()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span><span class="page-name text-dim">No meetings</span></li></ul>
<ul class="sidebar-items" id="sidebar-meetings-items">
<template x-for="n in meetingNotes" :key="'mn-' + n.id">
<li class="sidebar-item" @click="meetingNoteOpen(n)"
@contextmenu.prevent="showWsContext($event, n.id, n.title, false)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="n.title"></span>
<span class="sb-item-sub" x-text="n.has_summary ? 'Summary ready' : (n.status && n.status !== 'idle' ? n.status : 'Ready to record')"></span>
</span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingNotes.length">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span>
<span class="page-name text-dim">No meetings</span>
</li>
</ul>
</div>
</div>
@@ -662,6 +677,10 @@
<div class="sb-filter-row">
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'past' }" @click="meetingFilter = 'past'; loadMeetingHistory()">Past</button>
</div>
<div class="sb-search-row">
<input type="search" class="sb-search-input" placeholder="Search meetings…" x-model="meetingSearch" @input="if (meetingFilter === 'past') loadMeetingHistory()" aria-label="Search meetings">
</div>
<div class="sb-quick-actions">
@@ -671,15 +690,18 @@
</div>
<ul class="sidebar-items">
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
<template x-for="ev in meetingVisible" :key="(ev.collection_id || 'h') + '-' + ev.id">
<li class="sidebar-item" @click="meetingOpen(ev)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span class="page-name" x-text="ev.title"></span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="ev.title"></span>
<span class="sb-item-sub" x-text="meetingCta(ev)"></span>
</span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : (ev.date || '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
<span class="page-name text-dim" x-text="meetingEmptyHint()"></span>
</li>
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
<span class="page-name text-dim">Loading…</span>
@@ -719,18 +741,35 @@
</ul>
</div>
<!-- Footer -->
<!-- Footer : bouton "New" + popover "Create new" (vers le haut) -->
<div class="sidebar-footer">
<div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span>
<div class="fd-new-wrap" @click.outside="closeNewMenu()">
<div class="fd-new-popover" x-show="newMenuOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
role="menu" aria-label="Create new">
<div class="fd-new-pop-header">Create new</div>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
<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>
{% 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><!-- /sidebar-scroll -->
@@ -899,6 +938,9 @@
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('open')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open
</div>
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
@@ -1294,6 +1336,8 @@
this.loadTeamspaces();
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
this.loadInbox();
// v7.1.1 : notes de réunion (section Meetings, pas Workspace).
this.loadMeetingNotes();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1310,7 +1354,7 @@
});
document.addEventListener('flowdeck:workspace-changed', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
self._clipRefreshTimer = setTimeout(() => { self.refreshSidebarTree(); self.loadMeetingNotes(); }, 400);
});
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
document.addEventListener('visibilitychange', () => {
@@ -1451,15 +1495,37 @@
// ── Meeting tab ──
meetings: [],
meetingPast: [],
meetingsLoading: false,
meetingFilter: 'upcoming',
meetingSearch: '',
meetingHistoryLoaded: false,
get meetingVisible() {
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today; });
var q = (this.meetingSearch || '').trim().toLowerCase();
function matches(e) {
if (!q) return true;
return ((e.title || '') + ' ' + (e.date || '')).toLowerCase().indexOf(q) >= 0;
}
return this.meetings.filter(function(e) { return !e.today; });
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today && matches(e); });
}
if (this.meetingFilter === 'past') {
return this.meetingPast.filter(matches);
}
return this.meetings.filter(function(e) { return !e.today && matches(e); });
},
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
meetingEmptyHint() {
if (this.meetingFilter === 'today') return 'Nothing scheduled today';
if (this.meetingFilter === 'past') return 'No past meeting notes yet';
return 'No upcoming meetings';
},
meetingCta(ev) {
if (ev.status === 'done' || ev.summary) return 'View summary';
if (ev.today) return 'Join & transcribe';
if (ev.page_id) return 'Open note';
return 'Prepare';
},
loadMeetings(force) {
var self = this;
if (!force && this.meetings.length) return Promise.resolve();
@@ -1472,8 +1538,38 @@
})
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
},
loadMeetingHistory() {
var self = this;
this.meetingsLoading = true;
var q = encodeURIComponent((this.meetingSearch || '').trim());
return fetch('/api/v2/meetings/history?query=' + q + '&limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { meetings: [] }; })
.then(function(d) {
self.meetingPast = (d && d.meetings) || [];
self.meetingHistoryLoaded = true;
self.meetingsLoading = false;
})
.catch(function() { self.meetingPast = []; self.meetingsLoading = false; });
},
meetingOpen(ev) {
var target = ev.url || ('/db/' + ev.collection_id);
var target = ev.url || (ev.page_id ? ('/pages/' + ev.page_id) : ('/db/' + ev.collection_id));
if (ev.today) {
try { this.toast('Opening today\u2019s meeting — press Start transcribing in the note.'); } catch (e) {}
}
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
// ── v7.1.1 : notes avec bloc AI Meeting Notes → section Meetings ──
meetingNotes: [],
loadMeetingNotes() {
var self = this;
return fetch('/api/v2/meetings/notes?limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { notes: [] }; })
.then(function(d) { self.meetingNotes = (d && d.notes) || []; })
.catch(function() { self.meetingNotes = []; });
},
meetingNoteOpen(n) {
var target = '/pages/' + n.id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
@@ -1614,6 +1710,15 @@
this.wsCtx.visible = false;
if (!id) return;
switch(action) {
case 'open':
if (this.wsCtx.isFolder) {
if (typeof this.navigateToFolder === 'function') this.navigateToFolder(id);
} else {
var target = '/pages/' + id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
}
break;
case 'new-file':
this.newPageInFolder(id);
break;
@@ -1632,6 +1737,7 @@
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
@@ -1644,6 +1750,7 @@
if (!id) break;
if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id, true);
if (typeof this.loadMeetingNotes === 'function') this.loadMeetingNotes();
break;
}
},
@@ -1685,20 +1792,58 @@
});
},
// ── Sections collapsible ──
// ── Sections collapsible (accordéon v7.70 : une seule ouverte à la fois) ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
var def = { recents: false, private: false, meetings: false, favorites: false, agents: false, teamspaces: false, shared: false, published: false, apps: false, workspace: true, gitea: false };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
var merged = Object.assign({}, def, saved);
// Normalise : si plusieurs sections ouvertes (ancien état), ne garder que la première.
var first = null;
for (var i = 0; i < order.length; i++) {
if (merged[order[i]]) { first = order[i]; break; }
}
if (first) {
for (var j = 0; j < order.length; j++) merged[order[j]] = (order[j] === first);
} else {
merged.workspace = true;
}
return merged;
} catch(e) { return def; }
})(),
toggleSection(key) {
this.sectionsOpen[key] = !this.sectionsOpen[key];
var isOpen = !!this.sectionsOpen[key];
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
if (isOpen) {
this.sectionsOpen[key] = false;
} else {
for (var i = 0; i < order.length; i++) this.sectionsOpen[order[i]] = false;
this.sectionsOpen[key] = true;
}
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Menu "New" (popover "Create new" vers le haut) ──
newMenuOpen: false,
toggleNewMenu() { this.newMenuOpen = !this.newMenuOpen; },
closeNewMenu() { this.newMenuOpen = false; },
newMenuAction(kind) {
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
} else if (kind === 'meeting') {
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (typeof this.createDatabase === 'function') this.createDatabase();
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
@@ -1933,6 +2078,50 @@
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateMeeting() {
// v7.1.1 : crée une note de réunion vide avec son bloc
// AI Meeting Notes déjà inséré (même action que +, New, /meet).
var self = this;
var iso = new Date().toISOString().slice(0, 10);
var title = 'Meeting @' + iso;
fetch('/api/local-workspace/items', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: title, type: 'page' })
})
.then(function(r) { if (!r.ok) throw new Error('page creation (' + r.status + ')'); return r.json(); })
.then(function(page) {
var block = {
id: 'b1_' + Date.now().toString(36), type: 'meeting', content: '',
meeting: {
v: 2, title: title, date: iso, state: 'idle', mode: 'mic_only',
instruction: 'auto', lang: 'fr', consent: null, consentAudio: false,
tab: 'notes', channels: [], segments: [], segSeq: 0, notes: '',
manual: '', interim: '', startedAt: 0, elapsedBase: 0, pausedMs: 0,
steps: [], summaryMd: '', completeness: '', genTitle: '',
titleEdited: false, shareHidden: false, bannerHidden: false,
transcriptId: null, error: '', remoteSilentWarned: false,
menuOpen: false, settingsOpen: false
}
};
// Amorçage du bloc : en cas d'échec la page s'ouvre quand même
// (l'utilisateur ajoute le bloc via /meet).
return fetch('/board/api/pages/' + page.id + '/blocks', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify({ title: title, blocks: [block] })
}).then(function() { return page; }).catch(function() { return page; });
})
.then(function(page) {
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
if (typeof self.refreshSidebarTree === 'function') self.refreshSidebarTree();
if (window.fdNavigate) window.fdNavigate('/pages/' + page.id);
else window.location.href = '/pages/' + page.id;
})
.catch(function(err) { self.toast('Meeting note creation failed: ' + err.message, 'error'); });
},
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
@@ -2296,6 +2485,12 @@
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
// Le HTML injecté porte des directives Alpine (@contextmenu,
// x-show des dossiers, drag&drop) : les monter explicitement,
// sinon l'arbre perd menu contextuel et toggles après refresh.
try {
if (window.Alpine && window.Alpine.initTree) window.Alpine.initTree(ul);
} catch (e) { console.error('Sidebar Alpine remount failed:', e); }
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
@@ -2526,6 +2721,7 @@
if (data.contextMenu) data.contextMenu.visible = false;
if (data.wsCtx) data.wsCtx.visible = false;
if (data.showNewPageMenu) data.showNewPageMenu = false;
if (data.newMenuOpen) data.newMenuOpen = false;
}
}
else if (e.ctrlKey && e.key === 's') {
+10 -5
View File
@@ -11,9 +11,11 @@
{% endblock %}
{% block content %}
<!-- Prism.js syntax highlighting -->
<link rel="stylesheet" href="/static/css/prism.css">
<script src="/static/js/prism.min.js"></script>
<!-- highlight.js syntax highlighting (~230 file types via FDHighlight helper) -->
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<style>
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
.gw-content{padding:24px;overflow-y:auto;flex:1;}
@@ -31,7 +33,7 @@
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.gw-commit-bar input:focus{border-color:var(--accent);}
/* Prism theme tweaks for dark UI */
/* highlight.js theme tweaks for dark UI */
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
@@ -62,7 +64,10 @@
<div class="skeleton" style="height:15px;width:50%;"></div>
</div>
<!-- Syntax-highlighted code -->
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
</div>
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
+37 -8
View File
@@ -60,20 +60,38 @@
/* ── Table (div-based for hover effects) ── */
.lib-table{width:100%;}
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths — Notion-style */
.lib-col-name{flex:1;min-width:0;}
.lib-col-author{width:150px;flex-shrink:0;display:none;}
.lib-col-source{width:120px;flex-shrink:0;display:none;}
.lib-col-author{width:150px;flex-shrink:0;}
.lib-col-editedby{width:150px;flex-shrink:0;}
.lib-col-source{width:120px;flex-shrink:0;}
.lib-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
.lib-table.show-author .lib-col-author{display:flex;}
.lib-table.show-source .lib-col-source{display:flex;}
.lib-col-visited{width:130px;flex-shrink:0;text-align:right;justify-content:flex-end;}
/* Visibilité des colonnes pilotée par Show columns. Ces règles doivent
battre .lib-table-header .th et .lib-row .row-cell (spécificité 0,2,0) :
avant, seul :not(.show-editedtime) (0,3,0) gagnait, donc seule Last
edited time s'affichait/masquait — les autres options semblaient mortes. */
.lib-table div.lib-col-author,
.lib-table div.lib-col-editedby,
.lib-table div.lib-col-source,
.lib-table div.lib-col-visited{display:none;}
.lib-table.show-author div.lib-col-author,
.lib-table.show-editedby div.lib-col-editedby,
.lib-table.show-source div.lib-col-source,
.lib-table.show-visited div.lib-col-visited{display:flex;}
.lib-table:not(.show-editedtime) div.lib-col-edited{display:none;}
.row-source{display:inline-flex;align-items:center;gap:5px;}
.row-source svg{width:13px;height:13px;flex-shrink:0;color:var(--text-dim);}
/* ── Row ── */
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
.lib-row:hover{background:var(--bg-hover);}
/* ── Date section headers (AI Meeting Notes tab) ── */
.lib-section-header{display:flex;align-items:center;font-size:12px;font-weight:600;color:var(--text-secondary);padding:10px 8px 4px;user-select:none;}
.lib-section-header:first-child{padding-top:2px;}
.lib-row.selected{background:rgba(35,131,226,0.08);}
.lib-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.lib-row.dragging{opacity:0.4;}
@@ -189,7 +207,7 @@
/* ── Responsive ── */
@media(max-width:768px){
.lib-col-author,.lib-col-source{display:none!important;}
.lib-col-author,.lib-col-editedby,.lib-col-source,.lib-col-visited{display:none!important;}
.peek-overlay{width:100vw;max-width:100vw;}
}
</style>
@@ -212,6 +230,9 @@
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg> Recents
</button>
<button class="lib-tab" :class="{ active: tab === 'meetings' }" @click="switchTab('meetings')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> AI Meeting Notes
</button>
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Favorites
</button>
@@ -265,6 +286,9 @@
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
<div class="dd-item" @click="toggleColumn('author')"><span class="check" x-text="showAuthor ? '✓' : ''"></span> Created by</div>
<div class="dd-item" @click="toggleColumn('editedby')"><span class="check" x-text="showEditedBy ? '✓' : ''"></span> Last edited by</div>
<div class="dd-item" @click="toggleColumn('editedtime')"><span class="check" x-text="showEditedTime ? '✓' : ''"></span> Last edited time</div>
<div class="dd-item" @click="toggleColumn('visited')"><span class="check" x-text="showVisited ? '✓' : ''"></span> Last visited time</div>
<div class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
</div>
</div>
@@ -351,7 +375,7 @@
</div>
<!-- ── Table ── -->
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-editedby': showEditedBy, 'show-editedtime': showEditedTime, 'show-visited': showVisited, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
@@ -359,11 +383,16 @@
Page name
</div>
<div class="th lib-col-author">Created by</div>
<div class="th lib-col-source">Source</div>
<div class="th lib-col-editedby">Last edited by</div>
<div class="th lib-col-edited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg>
Last edited time
</div>
<div class="th lib-col-visited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Last visited time
</div>
<div class="th lib-col-source">Source</div>
</div>
<div class="lib-table-body" id="lib-table-body">
</div>
+16 -5
View File
@@ -166,9 +166,12 @@
.bc-edit-go:hover{opacity:.85;}
/* ── Preserved styles ── */
/* NOTE : scopées à .ws-tree (arbre principal) — la variante globale
écrasait aussi les items .ws-tree-item de la sidebar (macro partagée),
d'où un espacement différent sur /local-workspace. */
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
@@ -179,6 +182,8 @@
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
.ws-tree-item .name.file .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
.ws-fav-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;color:#f5a623;}
.ws-pub-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;}
.ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;}
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
@@ -480,7 +485,11 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id, "workspace_name": workspace_name} | tojson }}</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
@@ -528,7 +537,6 @@
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
{% if breadcrumbs %}
<div class="unified-breadcrumb">
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
@@ -549,7 +557,6 @@
</div>
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
</div>
{% endif %}
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
@@ -729,6 +736,8 @@
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span>
@@ -743,6 +752,8 @@
@mouseleave="hidePreview()"
title="Click to rename, double-click to open">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
+9
View File
@@ -173,8 +173,17 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
// hljs — syntax-highlight code blocks (~230 file types via FDHighlight).
try {
if (window.FDHighlight) FDHighlight.highlightAllIn(document.querySelector('.pub-content') || document);
else if (window.hljs) document.querySelectorAll('.pub-content pre code').forEach(function (el) { try { window.hljs.highlightElement(el); } catch (e) {} });
} catch (e) {}
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';