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

Corrige deux régressions de /local-workspace :

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

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
2026-10-09 16:58:04 -04:00
parent 7468e497c6
commit 0218d8f5e5
44 changed files with 12060 additions and 1729 deletions
+19 -8
View File
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
"""Load top-level pages with children for the active workspace.
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
réunion — elles vivent dans la section Meetings de la sidebar
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
"""
if not ws_cookie:
return []
try:
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
@@ -521,17 +528,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
"p.content_format FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
).fetchall()
favorites = []
for r in fav_rows:
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
is_folder = r["parent_section"] == "Workspace"
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
+15 -2
View File
@@ -95,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
# un slug garanti quand on publie depuis le share dialog.
from app.services.publish import _unique_slug
row = conn.execute(
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
if published and not slug:
title_row = conn.execute(
"SELECT title FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = _unique_slug(
conn, page_id, title_row["title"] if title_row else "")
conn.execute(
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
(mode, 1 if published else 0, page_id),
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
)
conn.commit()
return {"status": "ok", "share_mode": mode, "published": published}
+60 -5
View File
@@ -90,6 +90,50 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
"""Fichiers réellement récents (dernière édition) pour la sidebar.
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
d'emoji). Notes de réunion exclues → section Meetings.
"""
if not uid:
return []
try:
from app.routers.board import _file_icon
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.parent_section, p.content_format
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, limit),
).fetchall()
out = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
out.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return out
except Exception:
logger.exception("_recent_sidebar_pages")
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
@@ -108,7 +152,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
@@ -359,21 +403,26 @@ def _require_page_view(request: Request, page_id: int) -> None:
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
f"{no_meet}"
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
@@ -441,6 +490,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"published": bool(r["pub1"] or r["pub2"]),
"favorited": r["id"] in favorited_ids,
})
return tree
@@ -671,12 +721,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
)
elif t == "code":
lang = b.get("language", "")
try:
from app.services.export import normalize_code_lang
hljs_lang = normalize_code_lang(lang)
except Exception: # noqa: BLE001 — never break public rendering
hljs_lang = "plaintext"
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
+23
View File
@@ -100,6 +100,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
# En-tête Notion-style (même mécanique que les pages) :
# Home / <workspace> / <dossier> / <sous-dossier> / …
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
# ça le header restait bloqué sur « Home / <workspace> » même quand le
# contenu affiché était celui d'un sous-dossier.
nav_crumbs: list = [{
"id": None,
"label": ws_name,
"url": "/local-workspace",
"icon": "folder",
"menu": False,
}]
for i, bcrumb in enumerate(breadcrumb):
is_last = i == len(breadcrumb) - 1
nav_crumbs.append({
"id": bcrumb["id"],
"label": bcrumb["name"],
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
"icon": "folder",
"menu": True,
})
ctx = {
**sidebar,
"user": user,
@@ -111,6 +133,7 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
+19 -1
View File
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
try:
from datetime import UTC, datetime
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if _sess and _sess.get("id"):
with get_conn() as _conn:
_conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
accessed_at=excluded.accessed_at""",
(_sess["id"], page_id, page.get("workspace", "") or "",
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
_conn.commit()
except Exception:
pass
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
@@ -119,7 +137,7 @@ def view_page_root(request: Request, page_id: int):
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("is_published", 0) or page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
+124 -15
View File
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
else:
url = f"/pages/{page_id}"
# Icon
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
# des favoris de la sidebar consomme item.icon via fdIconHtml.
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "📁"
icon = "folder"
elif content_format == "file":
icon = "📄"
icon = "file"
fn = title.lower()
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "image"
else:
icon = "📝"
icon = "edit"
return {
"id": page_id,
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_icon": {"local": "file", "gitea": "link",
"github": "external-link"}.get(source_type, "file"),
"source_label": _source_label(source_type, workspace),
"workspace": workspace,
"workspace_name": _source_label(source_type, workspace),
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
items = _attach_tags(items)
return _attach_provenance(items, uid)
def _attach_provenance(items: list, uid: int = 1) -> list:
"""Created by / Last edited by / Last visited (colonnes Library).
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
- edited_by : dernier auteur des versions, sinon created_by ;
- visited_at : dernier accès tracé de l'utilisateur (table recents).
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
"""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
try:
with get_conn() as conn:
ver = conn.execute(
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
).fetchall()
first: dict = {}
last: dict = {}
for r in ver:
first.setdefault(r["page_id"], r["name"] or "")
last[r["page_id"]] = r["name"] or ""
own = conn.execute(
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
f"LEFT JOIN users u ON u.id = w.owner_id "
f"WHERE p.id IN ({placeholders})", ids,
).fetchall()
owners = {r["id"]: r["owner"] or "" for r in own}
try:
vis = conn.execute(
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
f"GROUP BY page_id", [uid, *ids],
).fetchall()
except Exception:
vis = []
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
except Exception:
logger.exception("_attach_provenance")
return items
for it in items:
pid = it["id"]
author = first.get(pid) or owners.get(pid, "")
it["author"] = author
it["author_initial"] = (author or "?")[0].upper()
edited_by = last.get(pid) or author
it["edited_by"] = edited_by
it["edited_by_initial"] = (edited_by or "?")[0].upper()
it["visited_at"] = visited.get(pid, "")
return items
def _attach_tags(items: list) -> list:
@@ -156,6 +211,59 @@ BASE_SELECT = (
)
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
NO_MEETING_BLOCK = (
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/meetings")
def library_meetings(
request: Request,
source_type: str = Query(default="all"),
):
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
exclues des onglets de navigation courante (recents/workspace/private).
"""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"{BASE_SELECT}, "
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 100"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it, r in zip(items, rows, strict=True):
d = dict(r)
st = d.get("meeting_status") or "idle"
it["is_meeting"] = True
it["meeting_status"] = st
it["source_label"] = (
"Summary ready" if (d.get("meeting_summary") or "").strip()
else ("Ready to record" if st == "idle"
else st.replace("_", " ").capitalize())
)
_enrich_children(items)
return {"items": items}
@router.get("/recents")
def library_recents(
request: Request,
@@ -167,7 +275,7 @@ def library_recents(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
@@ -301,7 +409,8 @@ def library_published(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -326,7 +435,7 @@ def library_private(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -392,7 +501,7 @@ def library_workspace(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
+329 -2
View File
@@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30):
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcripts")
def create_transcript(request: Request, body: dict = Body(default={})):
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
Idempotent par occurrence calendrier : renvoie la note existante.
"""
user = _auth_user(request, require_write=True)
try:
page_id = int(body.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
occ = (body.get("event_occurrence_id") or "").strip()[:200]
with get_conn() as conn:
if occ:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
(occ,)).fetchone()
if row:
return {**row_to_dict(row), "already_existed": True}
try:
tid = meet.save_transcript(
page_id, "", (body.get("language") or "fr")[:10],
mode=(body.get("mode") or "mic_only")
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
instruction_id=(body.get("instruction_id") or "auto")[:40],
event_occurrence_id=occ)
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201,
content={**row_to_dict(row), "already_existed": False})
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
@@ -254,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
async def summarize(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
out = await meet.summarize_transcript(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
@router.get("/api/v2/meetings/instructions")
def meeting_instructions(request: Request):
_auth_user(request)
return {"instructions": [
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
]}
# Une page est une « note de réunion » dès qu'elle contient un bloc
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
MEETING_BLOCK_PREDICATE = (
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/api/v2/meetings/notes")
def meeting_notes(request: Request, limit: int = 50):
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
Alimente la section Meetings de la sidebar (ces pages sont exclues de
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
``status = idle``.
"""
user = _auth_user(request)
uid = user.get("id")
lim = max(1, min(int(limit or 50), 200))
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
with get_conn() as conn:
rows = conn.execute(
f"""SELECT p.id, p.title, p.updated_at,
(SELECT mt.status FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
(SELECT mt.summary FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND {pred}
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, lim),
).fetchall()
notes = []
for r in rows:
d = row_to_dict(r)
notes.append({
"id": d["id"],
"title": d.get("title") or "Untitled",
"updated_at": d.get("updated_at") or "",
"status": d.get("status") or "idle",
"has_summary": bool((d.get("summary") or "").strip()),
})
return {"notes": notes, "count": len(notes)}
@router.get("/api/v2/meetings/history")
def meetings_history(request: Request, query: str = "", limit: int = 50):
"""Historique des notes de réunion (passé + recherche plein texte)."""
user = _auth_user(request)
_ = user
like = f"%{query.strip()}%" if query.strip() else "%"
lim = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
JOIN pages p ON p.id = mt.page_id
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
ORDER BY mt.id DESC LIMIT ?""",
(like, like, like, like, lim),
).fetchall()
out = []
for r in rows:
d = row_to_dict(r)
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
out.append(d)
return {"meetings": out, "count": len(out)}
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
def get_transcript_row(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
entry = meet.record_consent(
transcript_id, method=(body.get("method") or "start_attestation"),
message_ref=(body.get("message_ref") or "")[:200],
attested_by=user.get("id"),
participants=body.get("participants") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.consent", "transcript", transcript_id,
entry["method"], request)
return entry
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.start_session(
transcript_id, mode=(body.get("mode") or "mic_only"),
channels=body.get("channels"), language=(body.get("language") or "fr"),
instruction_id=(body.get("instruction_id") or "auto"),
user_id=user.get("id"))
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.session.start", "transcript", transcript_id,
out.get("mode", ""), request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.pause_session(transcript_id,
paused_ms_delta=int(body.get("paused_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
def resume_session(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = meet.resume_session(transcript_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
async def stop_session(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
stopped = meet.stop_session(transcript_id,
duration_ms=int((body or {}).get("duration_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
# d'échec LLM le transcript reste consultable (état failed, retry).
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
# jamais une ligne coincée en « processing ».
try:
out = await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
return {"session": {"id": stopped["id"], "status": "done"}, **out}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
def append_segments(transcript_id: int, request: Request,
body: dict = Body(default={})):
_auth_user(request, require_write=True)
try:
return meet.append_segments(transcript_id, body.get("segments") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
def grouped_transcript(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.grouped_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
def patch_speaker(transcript_id: int, seq: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
return meet.set_segment_speaker(transcript_id, seq,
body.get("speaker") or "",
user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
def processing_steps(transcript_id: int, request: Request):
_auth_user(request)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
import json as _json
try:
steps = _json.loads(tr.get("processing_steps_json") or "[]")
except (TypeError, ValueError):
steps = []
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
"steps": steps}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
async def retry_processing(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
if tr.get("status") not in ("failed", "processing", "done"):
raise HTTPException(400, "nothing to retry — no failed processing")
try:
return await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
title = (body.get("title") or "").strip()[:200]
if not title:
raise HTTPException(400, "title required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
" title_source='user' WHERE id=?", (title, transcript_id))
conn.commit()
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
return {"transcript_id": transcript_id, "generated_title": title}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
def post_distribution(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.record_distribution(
transcript_id, channel=(body.get("channel") or "copy_link"),
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
status=body.get("status") or "created")
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.share", "transcript", transcript_id,
out["channel"], request)
return out
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
_auth_user(request, require_write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
((body.get("notes") or "")[:50000], transcript_id))
conn.commit()
return {"transcript_id": transcript_id, "saved": True}