fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
+1
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <> ''"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;">')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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') || '';
|
||||
|
||||
Reference in New Issue
Block a user