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
+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}