Files
flowdeck/app/routers/dashboard/local_workspace.py
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
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.
2026-10-09 16:58:04 -04:00

630 lines
23 KiB
Python

"""FlowDeck — Dashboard : local_workspace.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, Body, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from ._common import (
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
_file_page_disk_path,
_get_active_workspace,
_get_user_id,
_require_page_view,
_require_user_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
produit par le sidebar mais **ignoré** par la route : le contenu affiché
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
pas à l'utilisateur est ignoré (et le workspace actif conservé).
"""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ws_row = _get_active_workspace(request, user_id=user["id"])
if ws is not None:
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
# l'utilisateur (même règle de propriété que _get_active_workspace).
with get_conn() as conn:
requested = conn.execute(
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
(ws, user["id"]),
).fetchone()
if requested is not None:
ws_row = dict(requested)
# L'URL fait foi : on aligne le cookie pour que le reste de
# l'application (sidebar, API tree, breadcrumbs) suive le même
# workspace que celui affiché.
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
max_age=86400 * 30, httponly=True, path="/")
return response
ws_id = ws_row["id"] if ws_row else None
# If no workspace exists for this user, redirect to workspaces page
if not ws_id:
return RedirectResponse("/workspaces", status_code=302)
return _render_local_workspace(env, sidebar, user, ws_row, folder)
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
folder: int | None) -> HTMLResponse:
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
``sidebar`` a été calculé **avant** le éventuel changement de workspace
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
sinon la page afficherait le contenu d'un workspace sous le titre d'un
autre.
"""
ws_id = ws_row["id"]
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
# Build breadcrumb if navigating into a folder
breadcrumb = []
current_folder_id = folder
if folder and ws_id:
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,
"workspace_name": ws_name,
"active_ws_name": ws_name,
"active_workspace_id": ws_id,
"current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
content=template.render(**ctx),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
)
@router.get("/api/local-workspace/tree")
def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root.
"""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tree": [], "breadcrumb": []}
uid = _get_user_id(request)
with get_conn() as conn:
if folder:
# Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id, uid)
breadcrumb = _build_breadcrumb(conn, folder)
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
else:
# Full tree from root
roots = _build_tree_children(conn, None, ws_id, uid)
return {"tree": roots, "breadcrumb": [], "current_folder": None}
@router.get("/api/local-workspace/page-content/{page_id:int}")
def get_page_content(page_id: int):
"""Return the raw content of a page (for preview)."""
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
fmt = row["content_format"]
if fmt == "file":
return JSONResponse({"content": "(uploaded file)", "format": fmt})
# v6.5.0: resolve synced blocks server-side (fresh content on read).
from app.services.synced_blocks import resolve_content_json
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
@router.get("/api/pages/{page_id}/download")
def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id)
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
resolved = _file_page_disk_path(dict(row))
if not resolved:
return JSONResponse({"error": "No downloadable file"}, status_code=404)
full, filename, mime, _size = resolved
from fastapi.responses import FileResponse
return FileResponse(
str(full), media_type=mime or "application/octet-stream",
filename=filename, content_disposition_type="attachment",
)
@router.get("/api/pages/{page_id}/file-content")
def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
only meaningful for plain-text / code / markdown files.
"""
from app.services.export import _file_text
_require_page_view(request, page_id)
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
page = dict(row)
text = _file_text(page)
if text is None:
return JSONResponse(
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
status_code=415,
)
return {"ok": True, "name": page.get("title") or "File", "content": text}
@router.get("/api/local-workspace/breadcrumb")
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
return {"breadcrumb": breadcrumb}
@router.get("/api/nav/menu")
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the
workspace's root pages. Each item includes ``has_children`` so the frontend
can render an expandable sub-menu.
"""
from app.routers.board import _file_icon
ws_id = workspace_id
if not ws_id:
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
with get_conn() as conn:
if parent_id:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(parent_id, ws_id),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(ws_id,),
).fetchall()
ids = [r["id"] for r in rows]
child_counts = {}
if ids:
placeholders = ",".join("?" for _ in ids)
cc_rows = conn.execute(
f"SELECT parent_id, COUNT(*) AS c FROM pages "
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
f"GROUP BY parent_id",
ids,
).fetchall()
for cr in cc_rows:
child_counts[cr["parent_id"]] = cr["c"]
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
items.append({
"id": r["id"],
"name": title,
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
"has_children": child_counts.get(r["id"], 0) > 0,
"url": f"/pages/{r['id']}",
})
return {"items": items}
@router.post("/api/local-workspace/items")
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
explicit_ws_id = body.get("workspace_id")
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = explicit_ws_id or (ws["id"] if ws else None)
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
section = 'Workspace' if item_type == 'folder' else 'Private'
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
(ws_key, ws_id, name, section, parent_id)
)
conn.commit()
pid = cursor.lastrowid
return {"id": pid, "name": name, "type": item_type}
@router.put("/api/local-workspace/items/{item_id:int}")
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
_require_user_id(request)
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/local-workspace/items/{item_id:int}")
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
_require_user_id(request)
from datetime import datetime
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/local-workspace/items/{item_id:int}/restore")
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
_require_user_id(request)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=NULL WHERE id=?",
(item_id,),
)
conn.commit()
return {"status": "ok"}
@router.get("/api/files/{ws_id:int}/{filename:path}")
def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
from pathlib import Path
root = Path(settings.data_dir)
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
fp.relative_to(base_dir)
except ValueError:
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
if not fp.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
mime, _ = mimetypes.guess_type(str(fp))
content = fp.read_bytes()
from fastapi.responses import Response
return Response(content=content, media_type=mime or "application/octet-stream")
@router.put("/api/local-workspace/items/{item_id:int}/move")
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
_require_user_id(request)
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
"UPDATE pages SET parent_id=? WHERE id=?",
(new_parent_id, item_id),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/local-workspace/upload")
async def upload_local_workspace_file(request: Request):
"""Upload one or more files via drag-and-drop.
Accepts multipart form with 'files' field (one or multiple files).
Optional: 'parent_id' to place files in a specific folder.
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
"""
import json
from pathlib import Path
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
try:
form = await request.form()
except Exception:
return JSONResponse({"error": "Invalid form data"}, status_code=400)
parent_id_raw = form.get("parent_id")
parent_id = int(parent_id_raw) if parent_id_raw else None
files = form.getlist("files")
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
with get_conn() as conn:
for f in files:
filename = f.filename or "untitled"
# Sanitize filename: only keep basename, prevent path traversal
safe_name = Path(filename).name
if not safe_name:
safe_name = "untitled"
# Unique filename on disk
file_path = upload_dir / safe_name
stem, suffix = file_path.stem, file_path.suffix
counter = 1
while file_path.exists():
file_path = upload_dir / f"{stem} ({counter}){suffix}"
counter += 1
content = await f.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to(data_root))
size = len(content)
mime = f.content_type or "application/octet-stream"
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
(ws_id, file_path.name,
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
parent_id),
)
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
conn.commit()
return {"status": "ok", "items": results}
@router.post("/api/local-workspace/upload-folder")
async def upload_local_workspace_folder(request: Request):
"""Handle recursive folder upload.
Frontend walks the directory tree with webkitGetAsEntry and sends:
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
- 'files': multipart files (one per file in the structure)
- 'parent_id': target folder (optional)
Creates folders first, then uploads files into their respective folders.
"""
import json
from pathlib import Path
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
try:
form = await request.form()
except Exception:
return JSONResponse({"error": "Invalid form data"}, status_code=400)
parent_id_raw = form.get("parent_id")
root_parent_id = int(parent_id_raw) if parent_id_raw else None
structure_raw = form.get("structure")
if not structure_raw:
return JSONResponse({"error": "No structure provided"}, status_code=400)
try:
structure = json.loads(structure_raw)
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
created_folders = {} # relative_path -> db_id
with get_conn() as conn:
# Phase 1: Create all folders
for item in structure:
if item.get("type") != "folder":
continue
path_parts = item["path"].strip("/").split("/")
folder_name = path_parts[-1]
# Determine parent: parent of this folder in the tree
if len(path_parts) == 1:
actual_parent = root_parent_id
else:
parent_path = "/".join(path_parts[:-1])
actual_parent = created_folders.get(parent_path)
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
(ws_id, folder_name, actual_parent),
)
fid = cursor.lastrowid
created_folders[item["path"].strip("/")] = fid
results.append({"id": fid, "name": folder_name, "type": "folder"})
# Phase 2: Upload files into their respective folders
for item in structure:
if item.get("type") != "file":
continue
path_parts = item["path"].strip("/").split("/")
file_name = path_parts[-1]
if len(path_parts) == 1:
file_parent = root_parent_id
else:
parent_path = "/".join(path_parts[:-1])
file_parent = created_folders.get(parent_path)
# Find the matching file in multipart data
matched = None
for f in form.getlist("files"):
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
matched = f
break
if not matched:
continue
safe_name = Path(file_name).name
file_path = upload_dir / safe_name
stem, suffix = file_path.stem, file_path.suffix
counter = 1
while file_path.exists():
file_path = upload_dir / f"{stem} ({counter}){suffix}"
counter += 1
content = await matched.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
rel_path = str(file_path.relative_to(data_root))
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
(ws_id, file_path.name,
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
file_parent),
)
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
conn.commit()
return {"status": "ok", "items": results}
# ═══════════ Workspaces CRUD ═══════════