"""FlowDeck β€” Dashboard: liste des projets Gitea + sidebar data.""" from __future__ import annotations import logging from fastapi import APIRouter, Request, Query from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from app.services.gitea_client import gitea from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) def _get_user_or_redirect(request: Request): """Return decoded user or a RedirectResponse to login page. Skips redirect when DB has no users (fresh install / test env).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: # Allow through if no users exist yet (fresh install / tests) try: with get_conn() as conn: count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} except Exception: pass return RedirectResponse("/auth/login?provider=local", status_code=302) return user def _sidebar_data(request: Request, repos: list[dict]) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws = user.get("login", "Bruno") if user else "Bruno" initial = ws[0].upper() if ws else "B" recent_pages = [] for repo in repos[:10]: full_name = repo.get("full_name", "") recent_pages.append({ "id": full_name, "name": repo.get("name", full_name), "icon": "πŸ“", "url": f"/board/{full_name}", "active": False, "indent": 0, "depth": 0, "has_children": False, "children": [], }) # Active workspace from cookie from app.routers.board import _load_workspace_pages ws_cookie = request.cookies.get("flowdeck_workspace", "") active_ws_name = "Workspace" workspace_pages = [] if ws_cookie: workspace_pages = _load_workspace_pages(ws_cookie) try: with get_conn() as conn: ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone() if ws_row: active_ws_name = ws_row["name"] except (ValueError, Exception): pass return { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, "workspace_pages": workspace_pages, "current_page": "Dashboard", "last_edited": "now", "recent_pages": recent_pages, "private_pages": [], "favorite_pages": [], "shared_pages": [], "user": user, "workspace_key": "", } @router.get("/trash", response_class=HTMLResponse) async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Trash page β€” scoped to workspace if owner/repo provided.""" from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) sidebar = board_sidebar(request, owner, repo) with get_conn() as conn: ws_key = f"{owner}/{repo}" if owner and repo else "" # Pages are soft-deleted via parent_section='Trash' if ws_key: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", (ws_key,), ).fetchall() else: rows = conn.execute( "SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", ).fetchall() sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] template = env.get_template("trash.html") return template.render(**sidebar) @router.get("/library", response_class=HTMLResponse) async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): """Library page avec onglets Recents, Favorites, Shared, Private β€” scopΓ© au workspace. La sidebar garde exactement la mΓͺme arborescence que dans le workspace. Seul le contenu principal change pour afficher les pages triΓ©es par catΓ©gorie. """ from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangΓ©e ws_key = f"{owner}/{repo}" if owner and repo else "" with get_conn() as conn: base_query = ( "SELECT id, title, workspace, parent_section, updated_at FROM pages " "WHERE parent_section != 'Trash'" ) params = [] if ws_key: base_query += " AND workspace=?" params.append(ws_key) base_query += " ORDER BY updated_at DESC" rows = conn.execute(base_query, params).fetchall() def to_page(r): r = dict(r) return { "id": f"page/{r['id']}", "name": r["title"] or "Untitled", "icon": "πŸ“„", "url": f"/pages/{r['id']}", "created_by": "You", "source": r.get("workspace") or "πŸ”’ Private", "last_edited": r.get("updated_at", "now"), "last_visited": r.get("updated_at", "now"), "section": r.get("parent_section", "Private"), } all_pages = [to_page(r) for r in rows] # Load favorites from DB for Library user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) 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 " "JOIN pages p ON p.id = f.page_id " "WHERE f.user_id=? ORDER BY f.position", (uid,) ).fetchall() lib_favs = [] for r in fav_rows: r = dict(r) lib_favs.append({ "id": f"page/{r['id']}", "name": r["title"] or "Untitled", "icon": "πŸ“„", "url": f"/pages/{r['id']}", "created_by": "You", "source": r.get("workspace") or "πŸ”’ Private", "last_edited": r.get("updated_at", "now"), "last_visited": r.get("updated_at", "now"), }) # CatΓ©goriser par parent_section β€” sidebar data intacte sidebar["lib_recent"] = all_pages sidebar["lib_favorites"] = lib_favs sidebar["lib_shared"] = [] sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"] sidebar["lib_has_favorites"] = len(lib_favs) > 0 sidebar["lib_has_shared"] = False template = env.get_template("library.html") return template.render(**sidebar) @router.get("/pages/{page_id}", response_class=HTMLResponse) async def view_page_root(request: Request, page_id: int): """Render a Markdown page at root level with workspace context.""" from jinja2 import Environment, FileSystemLoader from app.routers.board import _sidebar_data as board_sidebar env = Environment(loader=FileSystemLoader("app/templates")) with get_conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: return HTMLResponse("

Page not found

", status_code=404) page = dict(row) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" sidebar = board_sidebar(request, owner, repo) # Load sub-pages with get_conn() as conn: subs = conn.execute( "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", (page_id,), ).fetchall() ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": False, "page_share_mode": page.get("share_mode", "private"), "page_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": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}} template = env.get_template("page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) @router.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """Account management panel.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]} template = env.get_template("accounts.html") return template.render(**ctx) @router.get("/accounts/settings", response_class=HTMLResponse) async def settings_page(request: Request): """User settings page β€” profile, forges, tokens.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: from fastapi.responses import RedirectResponse return RedirectResponse("/auth/login?provider=local", status_code=302) # Check forge connections gitea_connected = False github_connected = False if user.get("id"): with get_conn() as conn: tokens = conn.execute( "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) ).fetchall() for t in tokens: if t["provider"] == "gitea": gitea_connected = True elif t["provider"] == "github": github_connected = True ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected} template = env.get_template("settings.html") response = template.render(**ctx) return HTMLResponse(content=response) # ═══════════ User API endpoints ═══════════ def _get_user_id(request: Request) -> int: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) return user["id"] if user and user.get("id") else 1 @router.put("/api/user/profile") async def update_profile(request: Request): import json body = await request.json() full_name = body.get("full_name", "").strip() uid = _get_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) conn.commit() return {"status": "ok"} @router.put("/api/user/password") async def update_password(request: Request): import json from app.password_utils import hash_password body = await request.json() password = body.get("password", "").strip() if len(password) < 6: return {"error": "Password must be at least 6 characters"} uid = _get_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) conn.commit() return {"status": "ok"} @router.post("/api/user/token") async def generate_token(request: Request): import secrets uid = _get_user_id(request) token = secrets.token_hex(32) with get_conn() as conn: conn.execute( "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", (uid, token), ) conn.commit() return {"token": f"fd_{token}"} @router.delete("/api/user/forge/{provider}") async def disconnect_forge(request: Request, provider: str): uid = _get_user_id(request) with get_conn() as conn: conn.execute( "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) ) conn.commit() return {"status": "ok"} @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, search: str = Query(default=""), show_archived: bool = Query(default=False), ): auth = _get_user_or_redirect(request) if isinstance(auth, RedirectResponse): return auth # Check if user has Gitea connected β€” if not, show local workspace user_id = auth.get("id") if isinstance(auth, dict) else 1 has_gitea = False try: with get_conn() as conn: tok = conn.execute( "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user_id,), ).fetchone() has_gitea = bool(tok) except Exception: pass if not has_gitea: return RedirectResponse("/local-workspace", status_code=302) try: repos = await gitea.get_user_repos(page=1, limit=50) if search: q = search.lower() repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()] if not show_archived: repos = [r for r in repos if not r.get("archived", False)] repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True) except Exception as e: logger.error("Dashboard error: %s", e) repos = [] from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, repos) template = env.get_template("dashboard.html") return template.render(request=request, repos=repos, search=search, show_archived=show_archived, **sidebar) # ═══════════ Workspace ═══════════ @router.get("/workspace", response_class=HTMLResponse) async def workspace_page(request: Request): """Unified workspace showing all projects.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) 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) ctx = {**sidebar, "user": user} template = env.get_template("workspace.html") return template.render(**ctx) @router.get("/api/workspace/projects") async def list_workspace_projects(request: Request): """List all projects: built-in + Gitea + GitHub.""" builtin = [] with get_conn() as conn: rows = conn.execute( "SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20" ).fetchall() for r in rows: count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0] builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"}) gitea_repos = [] try: repos = await gitea.get_user_repos(page=1, limit=50) for repo in repos: gitea_repos.append({ "id": str(repo.get("id", "")), "name": repo.get("name", ""), "full_name": repo.get("full_name", ""), "description": repo.get("description", ""), "html_url": repo.get("html_url", ""), "language": repo.get("language", ""), "forge": "gitea", }) except Exception: pass return {"builtin": builtin, "gitea": gitea_repos, "github": []} @router.post("/api/workspace/projects") async def create_workspace_project(request: Request): import json body = await request.json() name = body.get("name", "").strip() if not name: return {"error": "Name required"} with get_conn() as conn: cursor = conn.execute( "INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')", (name,), ) conn.commit() pid = cursor.lastrowid return {"id": pid, "name": name, "forge": "builtin"} # ═══════════ Workspace Members API ═══════════ @router.get("/api/workspace/{ws_id:int}/members") async def list_members(request: Request, ws_id: int): """List all members of a workspace.""" with get_conn() as conn: rows = conn.execute( """SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id = wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,) ).fetchall() return {"members": [dict(r) for r in rows]} @router.post("/api/workspace/{ws_id:int}/members") async def invite_member(request: Request, ws_id: int): """Invite a user to a workspace by email.""" import json body = await request.json() email = body.get("email", "").strip() role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"}, 400 with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone() if not user: return {"error": "User not found"}, 404 try: conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (ws_id, user["id"], role), ) conn.commit() except Exception: return {"error": "Already a member"}, 409 return {"status": "ok", "user_id": user["id"], "role": role} @router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") async def update_member_role(request: Request, ws_id: int, user_id: int): """Change a member's role.""" import json body = await request.json() role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"} with get_conn() as conn: conn.execute( "UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, ws_id, user_id), ) conn.commit() return {"status": "ok"} @router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") async def remove_member(request: Request, ws_id: int, user_id: int): """Remove a member from a workspace.""" with get_conn() as conn: conn.execute( "DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id), ) conn.commit() return {"status": "ok"} # ═══════════ Local Workspace (file/folder CRUD) ═══════════ @router.get("/local-workspace", response_class=HTMLResponse) async def local_workspace_page(request: Request, folder: int = None): """Local workspace page with file/folder tree. If ?folder=ID is provided, shows that folder's contents with breadcrumb. """ from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) 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 = _get_active_workspace(request) ws_id = ws["id"] if ws else None # 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) ctx = { **sidebar, "user": user, "workspace_name": sidebar.get("active_ws_name", "My Workspace"), "current_folder_id": current_folder_id or 0, "breadcrumb": breadcrumb, } template = env.get_template("local_workspace.html") return template.render(**ctx) @router.get("/api/local-workspace/tree") async 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) ws_id = ws["id"] if ws else None if not ws_id: return {"tree": [], "breadcrumb": []} with get_conn() as conn: if folder: # Show only this folder's children + build breadcrumb children = _build_tree_children(conn, folder, ws_id) 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) return {"tree": roots, "breadcrumb": [], "current_folder": None} @router.get("/api/local-workspace/breadcrumb") async 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} def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: """Recursively build the tree of children for a node.""" if parent_id is None: rows = conn.execute( "SELECT id, title, parent_section FROM pages " "WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC", (ws_id,), ).fetchall() else: rows = conn.execute( "SELECT id, title, parent_section FROM pages " "WHERE parent_id=? AND workspace_id=? ORDER BY created_at DESC", (parent_id, ws_id), ).fetchall() tree = [] for r in rows: is_folder = r["parent_section"] == "Workspace" children = _build_tree_children(conn, r["id"], ws_id) tree.append({ "id": r["id"], "name": r["title"] or "Untitled", "type": "folder" if is_folder else "page", "is_folder": is_folder, "children": children, "has_children": len(children) > 0, }) return tree def _build_breadcrumb(conn, folder_id: int) -> list: """Build breadcrumb trail from root to folder_id.""" breadcrumb = [] current = folder_id seen = set() while current and current not in seen: seen.add(current) row = conn.execute( "SELECT id, title, parent_id, parent_section FROM pages WHERE id=?", (current,), ).fetchone() if row: breadcrumb.insert(0, { "id": row["id"], "name": row["title"] or "Untitled", "is_folder": row["parent_section"] == "Workspace", }) current = row["parent_id"] else: break return breadcrumb @router.post("/api/local-workspace/items") async def create_local_workspace_item(request: Request): """Create a new file in the active workspace.""" import json body = await request.json() name = body.get("name", "Untitled").strip() item_type = body.get("type", "page") parent_id = body.get("parent_id") ws = _get_active_workspace(request) ws_id = ws["id"] if ws else None 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_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}") async def rename_local_workspace_item(request: Request, item_id: int): """Rename a file.""" import json body = await request.json() 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}") async def delete_local_workspace_item(request: Request, item_id: int): """Delete a file (and its children).""" with get_conn() as conn: conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id)) conn.commit() return {"status": "ok"} @router.put("/api/local-workspace/items/{item_id:int}/move") async def move_local_workspace_item(request: Request, item_id: int): """Move an item to a new parent (drag & drop).""" import json body = await request.json() 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 ws = _get_active_workspace(request) 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) upload_dir = Path(f"/data/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() file_path.write_bytes(content) # Determine if this is a folder marker or actual file rel_path = str(file_path.relative_to("/data")) 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 ws = _get_active_workspace(request) 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) upload_dir = Path(f"/data/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() file_path.write_bytes(content) rel_path = str(file_path.relative_to("/data")) 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 ═══════════ WORKSPACE_COOKIE = "flowdeck_workspace" def _get_active_workspace(request: Request) -> dict | None: """Get the active workspace ID from the cookie, or first workspace, or None.""" ws_id = request.cookies.get(WORKSPACE_COOKIE) if ws_id: try: with get_conn() as conn: ws = conn.execute("SELECT * FROM workspaces WHERE id=?", (int(ws_id),)).fetchone() if ws: return dict(ws) except (ValueError, Exception): pass # Fallback: first workspace with get_conn() as conn: ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() if ws: return dict(ws) return None @router.get("/workspaces", response_class=HTMLResponse) async def workspaces_page(request: Request): """Workspaces list page.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) 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) ctx = {**sidebar, "user": user} template = env.get_template("workspaces.html") return template.render(**ctx) @router.get("/api/workspaces") async def list_workspaces(request: Request): """List all workspaces for the current user.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: rows = conn.execute( "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count " "FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC", (uid,), ).fetchall() workspaces = [] for r in rows: d = dict(r) workspaces.append(d) active = _get_active_workspace(request) return {"workspaces": workspaces, "active_id": active["id"] if active else None} @router.post("/api/workspaces") async def create_workspace(request: Request): """Create a new workspace.""" import json body = await request.json() name = body.get("name", "New Workspace").strip() if not name: return {"error": "Name required"} user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 with get_conn() as conn: # Ensure user exists (FK constraint) uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() if not uid_ok: conn.execute( "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)", (uid, user.get("login", "admin") if user else "admin", user.get("full_name", "Admin") if user else "Admin"), ) cursor = conn.execute( "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, uid), ) ws_id = cursor.lastrowid # Add owner as member conn.execute( "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (ws_id, uid), ) conn.commit() return {"id": ws_id, "name": name} @router.put("/api/workspaces/{ws_id:int}") async def rename_workspace(request: Request, ws_id: int): """Rename a workspace.""" import json body = await request.json() name = body.get("name", "").strip() if not name: return {"error": "Name required"} with get_conn() as conn: conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id)) conn.commit() return {"status": "ok"} @router.delete("/api/workspaces/{ws_id:int}") async def delete_workspace(request: Request, ws_id: int): """Delete a workspace and all its pages.""" with get_conn() as conn: conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) conn.commit() return {"status": "ok"} @router.post("/api/workspaces/{ws_id:int}/select") async def select_workspace(request: Request, ws_id: int): """Set the active workspace via cookie.""" from fastapi.responses import JSONResponse response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") return response