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