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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user