"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data.""" from __future__ import annotations import logging from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.gitea_client import get_user_gitea_client, gitea logger = logging.getLogger(__name__) router = APIRouter(tags=["dashboard"]) _VERSION = None def _get_app_version() -> str: """Read version from VERSION file with caching.""" global _VERSION if _VERSION is not None: return _VERSION try: import os version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION") if os.path.exists(version_path): with open(version_path) as f: _VERSION = f.read().strip() else: # Docker fallback version_path = "/app/VERSION" if os.path.exists(version_path): with open(version_path) as f: _VERSION = f.read().strip() else: _VERSION = "0.0.0" except Exception: _VERSION = "0.0.0" return _VERSION 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&expired=1", status_code=302) return user def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: """Return list of local workspaces for a user.""" if not user: return [] try: with get_conn() as conn: rows = conn.execute( "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", (user["id"],) ).fetchall() return [{"id": r["id"], "name": r["name"]} for r in rows] except Exception: 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" initial = ws[0].upper() if ws else "B" # Get avatar info from DB avatar_url = "" avatar_color = "#3A3A3A" if user: try: with get_conn() as conn: row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone() if row: avatar_url = row["avatar_url"] or "" avatar_color = row["avatar_color"] or "#3A3A3A" except Exception: pass 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": "folder", "url": f"/board/{full_name}", "active": False, "indent": 0, "depth": 0, "has_children": False, "children": [], }) # Active workspace from cookie (skip on pages like /workspaces where no # workspace context should be shown) from app.routers.board import _load_workspace_pages ws_cookie = request.cookies.get("flowdeck_workspace", "") active_ws_name = "Workspace" workspace_pages = [] gitea_workspace = False gitea_owner = "" gitea_repo = "" has_active_workspace = False local_ws_id = 0 if ws_cookie and ws_cookie.startswith("gitea:"): # Gitea workspace: set owner/repo for client-side tree loading # AND open the local workspace mirror of the same name in the # sidebar's top "My Workspaces" section, in parallel with the # Gitea repository tree. parts = ws_cookie.split(":", 2) if len(parts) >= 3: gitea_owner = parts[1] gitea_repo = parts[2] active_ws_name = f"{gitea_owner}/{gitea_repo}" gitea_workspace = True has_active_workspace = True # Load the local mirror workspace tree so it appears in "My # Workspaces" alongside the Gitea repository section. if user: try: with get_conn() as conn: row = conn.execute( "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") ).fetchone() if row: local_ws_id = row["id"] workspace_pages = _load_workspace_pages(str(local_ws_id)) except (ValueError, Exception): pass elif include_workspace and ws_cookie and user: try: wsi = int(ws_cookie) with get_conn() as conn: # Verify this workspace belongs to the current user row = conn.execute( "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", (wsi, user["id"]) ).fetchone() if row: active_ws_name = row["name"] workspace_pages = _load_workspace_pages(ws_cookie) has_active_workspace = True # else: stale cookie from another user — ignore except (ValueError, Exception): pass # Auth method & OAuth badge data auth_method = "local" gitea_linked = False github_linked = False if user and user.get("id"): try: with get_conn() as conn: am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() if am_row and am_row["auth_method"]: auth_method = am_row["auth_method"] tokens = conn.execute( "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) ).fetchall() for t in tokens: if t["provider"] == "gitea": gitea_linked = True elif t["provider"] == "github": github_linked = True except Exception: pass # Get local workspace ID for Gitea workspace mirror local_ws_id = 0 if gitea_workspace and gitea_owner and gitea_repo: try: with get_conn() as conn: row = conn.execute( "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") ).fetchone() if row: local_ws_id = row["id"] except Exception: pass # Private pages for mirror workspace (when Gitea remote active) private_pages = [] if gitea_workspace and local_ws_id: try: with get_conn() as conn: pp_rows = conn.execute( "SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20", (local_ws_id,) ).fetchall() private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] except Exception: pass # Shared / received / published pages for the sidebar (Par moi / Avec moi) shared_made_pages = [] shared_received_pages = [] published_pages = [] shared_pages = [] if user and user.get("id"): from app.routers.board import _load_shared_sidebar_pages shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) sidebar = { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", "gitea_workspace": gitea_workspace, "gitea_owner": gitea_owner, "gitea_repo": gitea_repo, "local_ws_id": local_ws_id, "workspace_pages": workspace_pages, "current_page": "Dashboard", "last_edited": "now", "recent_pages": recent_pages, "private_pages": private_pages, "favorite_pages": [], "shared_pages": shared_pages, "shared_made_pages": shared_made_pages, "shared_received_pages": shared_received_pages, "published_pages": published_pages, "user": user, "avatar_url": avatar_url, "avatar_color": avatar_color, "auth_method": auth_method, "gitea_linked": gitea_linked, "github_linked": github_linked, "has_active_workspace": has_active_workspace, "app_version": _get_app_version(), } sidebar["local_workspaces"] = _local_workspaces_for_user(user) return sidebar @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] # Pass active workspace for breadcrumb nav menu ws = _get_active_workspace(request, user_id=_get_user_id(request)) sidebar["nav_workspace_id"] = ws["id"] if ws else 0 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 — tabbed view (recents, favorites, shared, published, private, workspace). Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. """ 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) # Pass active workspace for breadcrumb nav menu ws_key_ws = f"{owner}/{repo}" if owner and repo else "" if ws_key_ws: with get_conn() as conn: ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone() sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0 else: ws = _get_active_workspace(request, user_id=_get_user_id(request)) sidebar["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("library.html") sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0) # Gitea workspace context for Repository tab sidebar["is_gitea_workspace"] = bool(owner and repo) sidebar["gitea_workspace_owner"] = owner sidebar["gitea_workspace_repo"] = repo 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 — or file viewer. ?embed=1 — minimal mode for side peek (editor only, no header).""" embed = request.query_params.get("embed") == "1" 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 RedirectResponse("/workspaces", status_code=302) 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() fav = conn.execute( "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (1, page_id), ).fetchone() # Build page_data, including file metadata for uploaded files _locked = bool(page.get("is_locked", 0)) _locked_by = page.get("locked_by") if "locked_by" in page else None _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)), "is_locked": _locked, "locked_by": _locked_by, "can_edit": _can_edit, "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": import json as _json try: meta = _json.loads(page.get("content", "{}")) except _json.JSONDecodeError: meta = {} file_path = meta.get("file_path", "").replace("\\", "/") mime_type = meta.get("mime_type", "application/octet-stream") file_size = meta.get("size", 0) fp_parts = file_path.split("/") ws_id = "" for p in fp_parts: if p.startswith("workspace_"): ws_id = p.replace("workspace_", "") break filename = fp_parts[-1] if fp_parts else page.get("title", "File") from urllib.parse import quote safe_name = quote(filename, safe='') file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else "" page_data["file_url"] = file_url page_data["file_mime"] = mime_type page_data["file_size"] = file_size page_data["file_name"] = filename # For collection (database) pages, load collection + properties + pages collection_data = None if page.get("content_format") == "collection" and page.get("collection_id"): with get_conn() as conn: col = conn.execute( "SELECT * FROM collections WHERE id=?", (page["collection_id"],) ).fetchone() if col: props = [ dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] cpages = [ dict(r) for r in conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] cviews = [ dict(r) for r in conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (page["collection_id"],), ).fetchall() ] collection_data = { "collection": dict(col), "properties": props, "pages": cpages, "views": cviews, } page_data["collection_id"] = page["collection_id"] with get_conn() as conn: nav_crumbs = _nav_breadcrumb(conn, page_id) ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), "page_data": page_data, "collection_data": collection_data, "breadcrumb_items": nav_crumbs, "nav_workspace_id": page.get("workspace_id") or 0, "nav_page_id": page_id, "embed_mode": embed} # Select template: collection pages use database table view if page.get("content_format") == "collection" and not embed: template = env.get_template("page_editor_collection.html") else: template = env.get_template("page_editor_embed.html" if embed else "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("/help", response_class=HTMLResponse) async def help_page(request: Request): """Comprehensive help & documentation page.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) # Render via a block-based template so content_html lands in {% block content %} block_tpl = env.from_string( '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' ) return HTMLResponse(block_tpl.render( **sidebar, request=request, page_title="Help", title_prefix="Help", page_icon="❓", content_html="""

❓ FlowDeck Help

Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.

🚀Getting Started

FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.

  • Create a workspace from the Workspaces page
  • Click 📄 New Page in the sidebar to start writing
  • Use Ctrl+N anywhere to create a page

📝Pages & Editor

Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.

  • Type / for the slash command menu
  • Drag & drop pages in the sidebar to reorganize
  • Right-click for context menu (duplicate, rename, delete)

{{ fd_icon("folder",16) }}Workspaces

Organize your work into separate workspaces. Each has its own pages and files.

  • Local Files stored on your server
  • Gitea Connect to browse & edit repos
  • GitHub Connect via Settings → Integrations

🦎Gitea Integration

Connect your Gitea account to access repositories directly from FlowDeck.

  • Go to Settings → Integrations to connect
  • Browse repo file trees in the sidebar
  • Create & edit files with commit messages
  • Sync labels as tags

🌐Sharing & Publishing

Share pages with collaborators or publish them to the web.

  • Click Share in the page editor top-right
  • Share with specific users or get a public link
  • Publish to make a page visible at /p/your-slug

📚Library, Trash & Tasks

Find all your content in one place with powerful filtering.

  • Library — Tabs for Recents, Favorites, Shared, Published
  • Trash — Soft-deleted pages (30-day retention)
  • My Tasks — Aggregated tasks from all collections

📶Offline & PWA

Install FlowDeck as an app and keep working without a connection.

  • Install — browser menu → Install app / Add to Home Screen
  • Edits made offline are queued locally and synced automatically
  • A ⟳ marker shows pages with pending changes

⌨️ Keyboard Shortcuts

Ctrl+N
Create new page
Ctrl+K
Quick find / command palette
Ctrl+S
Save current page
F2
Rename selected item
Delete
Move selected item to trash
Escape
Close modal / cancel editing
Enter
Open selected page
Ctrl+O
New AI chat (in footer)

🔐 Authentication

FlowDeck supports three authentication methods:
Local Email + password — create an account on the login page.
Gitea OAuth Login with your Gitea account. Your repos appear as workspaces.
GitHub OAuth Login or link your GitHub account in Settings → Integrations.

Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.

📶 Offline mode (PWA)

FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.

Install: open your browser menu and choose Install app (Chrome/Edge) or Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
Offline editing: while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon appears next to pages that have unsynced edits.
Reconnection: the queue is replayed automatically (and via Background Sync). A spinner badge appears while syncing, followed by a confirmation toast.
Conflicts: if a page changed on the server, the latest edit wins and a notice is shown. If a page was deleted server-side, your offline copy is recreated as an orphan page. If a page with the same title already exists, the offline copy is renamed “Title (copie offline)”.

💡 Tips

• Toggle the sidebar with the « button in the top-left corner.
• Switch between workspaces using the dropdown menu in the sidebar header.
• The Private section appears when a remote workspace is active — files here stay local.
• Hover over any sidebar item to see action buttons (favorite, share, delete).
• Use Ctrl+Click or Shift+Click to multi-select items in the sidebar.

""" )) @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): 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): 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), ): """Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) # ── Not authenticated → show landing page ── if not user: # Allow through if DB is empty (fresh install) try: with get_conn() as conn: count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) template = env.get_template("landing.html") return template.render() except Exception: pass from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) template = env.get_template("landing.html") return template.render() # ── Authenticated ── user_id = user.get("id", 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: # Check if user has any workspace try: with get_conn() as conn: ws_count = conn.execute( "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,) ).fetchone()[0] if ws_count == 0: # v5.2.0: first-launch → onboarding wizard return RedirectResponse("/welcome", status_code=302) except Exception: pass return RedirectResponse("/local-workspace", status_code=302) # ── Gitea user → full dashboard ── 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} # Pass active workspace for breadcrumb nav menu ws = _get_active_workspace(request, user_id=_get_user_id(request)) ctx["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("workspace.html") return template.render(**ctx) @router.get("/gitea-workspace", response_class=HTMLResponse) async def gitea_workspace_page(request: Request): """Gitea workspace — browse repo files.""" import json 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) owner = request.query_params.get("owner", "") repo = request.query_params.get("repo", "") ws_key = f"{owner}/{repo}" if owner and repo else "" ws_name = ws_key or "Gitea Workspace" # Auto-create local workspace mirror for storing local files local_ws_id = None if ws_key: with get_conn() as conn: existing = conn.execute( "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", (user["id"], ws_key, "%gitea_repo%") ).fetchone() if existing: local_ws_id = existing["id"] else: c = conn.execute( "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner})) ) local_ws_id = c.lastrowid conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (local_ws_id, user["id"], "admin") ) conn.commit() ctx = { **sidebar, "user": user, "active_ws_name": ws_name, "workspace_key": ws_key, "gitea_workspace": True, # always true on this page "gitea_owner": owner, "gitea_repo": repo, "workspace_name": ws_name, "workspace_initial": repo[0].upper() if repo else "G", "owner": owner, "repo": repo, "nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu } template = env.get_template("gitea_workspace.html") resp = HTMLResponse(content=template.render(**ctx)) resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax") return resp @router.get("/api/workspace/projects") async def list_workspace_projects(request: Request): """List all projects: built-in + Gitea + GitHub. Uses the user's own Gitea token if connected, not the global admin token.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) 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 = [] # Use per-user token if available, otherwise return empty user_gitea = get_user_gitea_client(request) if user else None if user_gitea: try: repos = await user_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): 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.""" 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.""" 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, user_id=user["id"]) ws_id = ws["id"] if ws else None # If no workspace exists for this user, redirect to workspaces page if not ws_id: return RedirectResponse("/workspaces", status_code=302) # 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, "workspace_id": ws_id or 0, "nav_workspace_id": ws_id or 0, "breadcrumb": breadcrumb, "breadcrumbs": breadcrumb, } 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") 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, 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}") async 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}) return {"content": row["content"] or "", "format": fmt} def _file_page_disk_path(page: dict): """Resolve the on-disk file behind a ``content_format == 'file'`` page. Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None when the row is not a file page, references a non-textual/missing file, or the path escapes the data root (path-traversal guard). """ if (page.get("content_format") or "") != "file": return None import json as _json try: meta = _json.loads(page.get("content", "{}")) except (_json.JSONDecodeError, TypeError): meta = {} if not isinstance(meta, dict): return None rel = (meta.get("file_path") or "").replace("\\", "/").strip() if not rel or not rel.startswith("uploads/"): return None parts = rel.split("/") if ".." in parts or "." in parts: return None import os as _os from pathlib import Path root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve() full = (root / rel).resolve() try: full.relative_to(root) except ValueError: return None if not full.exists() or not full.is_file(): return None filename = parts[-1] or page.get("title", "file") mime = meta.get("mime_type") or "application/octet-stream" size = meta.get("size") or 0 return (full, filename, mime, size) @router.get("/api/pages/{page_id}/download") async def download_page_file(page_id: int): """Download the original uploaded file of a ``file`` page (attachment).""" 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") async def page_file_content(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 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") 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, uid: int | None = None) -> list: """Recursively build the tree of children for a node.""" 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, " "created_at, updated_at FROM pages " "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL " "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, " "created_at, updated_at FROM pages " "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " "ORDER BY created_at DESC", (parent_id, ws_id), ).fetchall() # Get workspace owner name for author display ws_owner = conn.execute( "SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?", (ws_id,), ).fetchone() author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—" # Collect all page IDs to fetch tags in one query all_ids = [r["id"] for r in rows] tags_map = {} favorited_ids = set() if all_ids: placeholders = ",".join("?" for _ in all_ids) tag_rows = conn.execute( f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt " f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})", all_ids, ).fetchall() for tr in tag_rows: tags_map.setdefault(tr["page_id"], []).append({ "id": tr["id"], "name": tr["name"], "color": tr["color"], }) if uid is not None: fav_rows = conn.execute( f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})", [uid, *all_ids], ).fetchall() favorited_ids = {fr["page_id"] for fr in fav_rows} tree = [] for r in rows: is_folder = r["parent_section"] == "Workspace" children = _build_tree_children(conn, r["id"], ws_id, uid) # Compute size size = 0 if r["content_format"] == "file": import json as _json try: meta = _json.loads(r["content"]) size = meta.get("size", 0) except Exception: size = len(r["content"] or "") else: size = len(r["content"] or "") tree.append({ "id": r["id"], "name": r["title"] or "Untitled", "type": "folder" if is_folder else "page", "is_folder": is_folder, "content_format": r["content_format"] if not is_folder else None, "page_icon": r["page_icon"] or "", "children": children, "has_children": len(children) > 0, "child_count": len(children), "size": size, "size_display": _format_size(size), "created_at": r["created_at"], "updated_at": r["updated_at"], "author": author, "tags": tags_map.get(r["id"], []), "is_shared": bool(r["is_shared"]), "favorited": r["id"] in favorited_ids, }) return tree def _format_size(size_bytes: int) -> str: """Human-readable file size.""" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" elif size_bytes < 1024 * 1024 * 1024: return f"{size_bytes / (1024 * 1024):.1f} MB" return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" 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 def _nav_breadcrumb(conn, page_id: int) -> list: """Build a Notion-style breadcrumb chain (root -> page) for the header. Returns a list of dicts: {id, label, url, icon, menu}. The last item is the current page (url = None). Every item has ``menu: True`` so the header can open a sibling-navigation dropdown for it. """ from app.routers.board import _file_icon chain = [] current = page_id seen = set() while current and current not in seen: seen.add(current) row = conn.execute( "SELECT id, title, parent_id, parent_section, content_format " "FROM pages WHERE id=? AND deleted_at IS NULL", (current,), ).fetchone() if not row: break is_folder = row["parent_section"] == "Workspace" title = row["title"] or "Untitled" chain.insert(0, { "id": row["id"], "label": title, "url": None, "icon": "folder" if is_folder else _file_icon(title, row["content_format"]), "menu": True, }) current = row["parent_id"] # All items except the current page are navigable links. for i, item in enumerate(chain): if i < len(chain) - 1: item["url"] = f"/pages/{item['id']}" return chain @router.get("/api/nav/menu") async 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 " "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") async def create_local_workspace_item(request: Request): """Create a new file in the active workspace.""" body = await request.json() 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}") async def rename_local_workspace_item(request: Request, item_id: int): """Rename a file.""" 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): """Soft-delete a file/folder (sets deleted_at).""" from datetime import datetime with get_conn() as conn: conn.execute( "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (datetime.utcnow().isoformat(), item_id), ) conn.commit() return {"status": "ok"} @router.post("/api/local-workspace/items/{item_id:int}/restore") async def restore_local_workspace_item(request: Request, item_id: int): """Restore a soft-deleted file/folder.""" 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}") async def serve_uploaded_file(ws_id: int, filename: str): """Serve an uploaded file from disk.""" import mimetypes import os from pathlib import Path root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) 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") async def move_local_workspace_item(request: Request, item_id: int): """Move an item to a new parent (drag & drop).""" 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, user_id=_get_user_id(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, user_id=_get_user_id(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, user_id: int = None) -> dict | None: """Get the active workspace ID from the cookie (verified for current user), or first user 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: ws_dict = dict(ws) # Verify ownership — only return if it belongs to the current user if user_id is None or ws_dict.get("owner_id") == user_id: return ws_dict except (ValueError, Exception): pass # Fallback: first workspace owned by this user if user_id: with get_conn() as conn: ws = conn.execute( "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", (user_id,) ).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, [], include_workspace=False) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) ctx = {**sidebar, "user": user} # Pass active workspace for breadcrumb nav menu (null on workspaces home) ws = _get_active_workspace(request, user_id=_get_user_id(request)) ctx["nav_workspace_id"] = ws["id"] if ws else 0 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, user_id=_get_user_id(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.""" 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.""" 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 # ═══════════ Settings Page ═══════════ @router.get("/settings", response_class=HTMLResponse) async def app_settings_page(request: Request): """Settings & configuration 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} # Pass active workspace for breadcrumb nav menu ws = _get_active_workspace(request, user_id=_get_user_id(request)) ctx["nav_workspace_id"] = ws["id"] if ws else 0 template = env.get_template("settings.html") return template.render(**ctx) @router.post("/api/settings/avatar") async def upload_avatar(request: Request): """Upload a user avatar image.""" import os import uuid from pathlib import Path form = await request.form() file = form.get("file") if not file: return {"error": "No file"}, 400 user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return {"error": "Not authenticated"}, 401 # Save to data/avatars avatars_dir = Path("/data/avatars") avatars_dir.mkdir(parents=True, exist_ok=True) ext = os.path.splitext(file.filename)[1] or ".png" filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}" filepath = avatars_dir / filename content = await file.read() filepath.write_bytes(content) # Update user avatar_url avatar_url = f"/api/settings/avatar/{filename}" with get_conn() as conn: conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"])) conn.commit() return {"avatar_url": avatar_url} @router.get("/api/settings/avatar/{filename:path}") async def serve_avatar_file(filename: str): """Serve an uploaded avatar image file.""" from pathlib import Path from fastapi.responses import FileResponse filepath = Path("/data/avatars") / filename if not filepath.is_file(): return JSONResponse({"error": "Not found"}, status_code=404) return FileResponse(filepath) @router.get("/api/avatar/{user_id:int}") async def get_avatar(user_id: int): """Redirect to the user's avatar.""" with get_conn() as conn: row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() if row and row["avatar_url"]: return RedirectResponse(row["avatar_url"], status_code=302) return JSONResponse({"error": "No avatar"}, status_code=404) @router.post("/api/settings/avatar-color") async def set_avatar_color(request: Request): """Set the user's avatar background color.""" body = await request.json() color = body.get("color", "#3A3A3A") user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return {"error": "Not authenticated"}, 401 with get_conn() as conn: conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"])) conn.commit() return {"status": "ok", "color": color} # ═══════════ Tag Management API (per-user) ═══════════ @router.post("/api/settings/tags") async def create_tag_global(request: Request): """Create a tag for the current user.""" body = await request.json() tag_name = body.get("name", "").strip().lower() color = body.get("color", "#787774") uid = _get_user_id(request) if not tag_name or not uid: return {"error": "Tag name required"}, 400 with get_conn() as conn: tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() if tag: conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"])) conn.commit() return {"tag": {"id": tag["id"], "name": tag_name, "color": color}} cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid)) conn.commit() return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}} @router.put("/api/settings/tags/{tag_id:int}") async def update_tag_global(tag_id: int, request: Request): """Update a tag (name or color) — only if owned by user.""" body = await request.json() uid = _get_user_id(request) with get_conn() as conn: if "name" in body: conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid)) if "color" in body: conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid)) conn.commit() return {"status": "ok"} @router.delete("/api/settings/tags/{tag_id:int}") async def delete_tag_global(tag_id: int, request: Request): """Delete a tag — only if owned by user.""" uid = _get_user_id(request) with get_conn() as conn: conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,)) conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid)) conn.commit() return {"status": "ok"} @router.get("/api/settings/tags/all") async def list_all_tags_global(request: Request): """List current user's tags with counts.""" uid = _get_user_id(request) with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " "FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id " "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", (uid,), ).fetchall() return JSONResponse( {"tags": [dict(r) for r in rows]}, headers={"Cache-Control": "no-store"}, ) # ═══════════ Workspace Tags API ═══════════ @router.get("/api/local-workspace/tags") async def list_tags(request: Request): """List ALL user tags with counts scoped to the active workspace.""" ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None uid = _get_user_id(request) if not ws_id: return {"tags": []} with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color, " "(SELECT COUNT(*) FROM page_tags pt " " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " " WHERE pt.tag_id = t.id) as count " "FROM tags t WHERE t.user_id = ? ORDER BY t.name", (ws_id, uid), ).fetchall() return JSONResponse( {"tags": [dict(r) for r in rows]}, headers={"Cache-Control": "no-store"}, ) @router.get("/api/local-workspace/items/{item_id:int}/tags") async def get_item_tags(item_id: int): """Get tags for a specific item.""" with get_conn() as conn: rows = conn.execute( "SELECT t.id, t.name, t.color FROM tags t " "JOIN page_tags pt ON pt.tag_id = t.id " "WHERE pt.page_id = ? ORDER BY t.name", (item_id,), ).fetchall() return {"tags": [dict(r) for r in rows]} @router.post("/api/local-workspace/items/{item_id:int}/tags") async def add_item_tag(request: Request, item_id: int): """Add a tag to an item (creates tag if new, scoped to user).""" body = await request.json() tag_name = body.get("name", "").strip().lower() tag_color = body.get("color", "#787774") uid = _get_user_id(request) if not tag_name: return {"error": "Tag name required"}, 400 with get_conn() as conn: # Get or create tag (per user) tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone() if not tag: cursor = conn.execute( "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid) ) conn.commit() tag_id = cursor.lastrowid tag = {"id": tag_id, "name": tag_name, "color": tag_color} else: tag_id = tag["id"] # Link tag to page (ignore duplicate) try: conn.execute( "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (item_id, tag_id), ) conn.commit() except Exception: pass return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} @router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") async def remove_item_tag(item_id: int, tag_id: int): """Remove a tag from an item.""" with get_conn() as conn: conn.execute( "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", (item_id, tag_id), ) conn.commit() return {"status": "ok"} @router.get("/api/local-workspace/tags/search") async def search_by_tags(request: Request, tags: str = ""): """Search items by tags (comma-separated).""" 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": []} tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] if not tag_names: return {"items": []} with get_conn() as conn: placeholders = ",".join("?" for _ in tag_names) rows = conn.execute( f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " f"p.content, p.created_at, p.updated_at " f"FROM pages p " f"JOIN page_tags pt ON pt.page_id = p.id " f"JOIN tags t ON t.id = pt.tag_id " f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " f"ORDER BY p.updated_at DESC", tag_names + [ws_id], ).fetchall() items = [] for r in rows: is_folder = r["parent_section"] == "Workspace" size = len(r["content"] or "") items.append({ "id": r["id"], "name": r["title"] or "Untitled", "is_folder": is_folder, "content_format": r["content_format"], "created_at": r["created_at"], "updated_at": r["updated_at"], "size": size, "size_display": _format_size(size), }) return {"items": items} # ── Account update ── @router.put("/api/settings/account") async def update_account(request: Request): """Update current user's profile: full_name, login, email, password.""" from app.password_utils import hash_password user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return JSONResponse({"error": "Not authenticated"}, status_code=401) try: body = await request.json() except Exception: body = {} with get_conn() as conn: uid = user["id"] if "full_name" in body: conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid)) if "login" in body: new_login = body["login"].strip() if new_login and new_login != user.get("login"): existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone() if existing: return JSONResponse({"error": "Username already taken"}, status_code=409) conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid)) if "email" in body: conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid)) if "password" in body and body["password"].strip(): pw = body["password"].strip() if len(pw) < 6: return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid)) conn.commit() row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() user_data = dict(row) # Refresh session cookie with updated data (keeps the same session id) cookie = request.cookies.get("flowdeck_session", "") new_session = SessionManager.refresh_session(cookie, user_data, request) response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}}) response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/") return response # ═══════════ Sidebar Refresh API ═══════════ @router.get("/api/sidebar/workspace-tree") async def sidebar_workspace_tree(request: Request): """Return the sidebar workspace tree as HTML fragment. Called by appState().refreshSidebarTree() after CRUD operations in the main content area to keep the sidebar in sync. """ from jinja2 import Environment, FileSystemLoader from app.routers.board import _load_workspace_pages user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return HTMLResponse("") ws_cookie = request.cookies.get("flowdeck_workspace", "") if not ws_cookie: return HTMLResponse('') # Gitea workspace — no server-side tree, loaded client-side if ws_cookie.startswith("gitea:"): return HTMLResponse('') try: ws_id = int(ws_cookie) with get_conn() as conn: # Verify workspace belongs to user row = conn.execute( "SELECT id FROM workspaces WHERE id=? AND owner_id=?", (ws_id, user["id"]) ).fetchone() if not row: return HTMLResponse( '' ) pages = _load_workspace_pages(ws_cookie) if not pages: return HTMLResponse( '' ) # Render the tree using the extracted macro env = Environment(loader=FileSystemLoader("app/templates")) template = env.from_string( "{% from '_workspace_tree_macro.html' import render_workspace_tree %}" "{{ render_workspace_tree(pages) }}" ) html = template.render(pages=pages) return HTMLResponse(html) except (ValueError, Exception) as e: logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True) return HTMLResponse( '' ) # ═══════════ Public Published Page ═══════════ @router.get("/p/{slug}", response_class=HTMLResponse) async def public_published_page(request: Request, slug: str): """Serve a published page at /p/ — no auth required.""" from jinja2 import Environment, FileSystemLoader with get_conn() as conn: row = conn.execute( "SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon " "FROM pages WHERE publish_slug=? AND is_published=1", (slug,), ).fetchone() if not row: return HTMLResponse( """ Not Found — FlowDeck

404

""", status_code=404, ) page = dict(row) env = Environment(loader=FileSystemLoader("app/templates")) # Convert blocks to HTML for rendering content_html = "" if page.get("content_format") == "blocks" and page.get("content"): import json as _json try: blocks = _json.loads(page["content"]) from app.db import get_conn as _gc from app.services.wiki_links import token_labels with _gc() as conn: wiki_titles_map = token_labels(conn, page["content"]) content_html = _render_blocks_public(blocks, wiki_titles_map) except (_json.JSONDecodeError, Exception): content_html = f"

{page.get('content', '')}

" elif page.get("content"): # Plain text / markdown text = page["content"] content_html = f"
{text}
" template = env.get_template("public_page.html") return template.render( title=page["title"] or "Untitled", content_html=content_html, updated_at=page.get("updated_at", ""), created_at=page.get("created_at", ""), cover_url=page.get("cover_url", ""), page_icon=page.get("page_icon", ""), ) def _sanitize_id(block_id: str) -> str: """Sanitize a block id for use as an HTML anchor (only alnum kept).""" if not block_id: return "" return "".join(ch for ch in str(block_id) if ch.isalnum()) def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: """Render FlowDeck blocks as plain HTML for public pages. v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links. """ html_parts = [] def _wiki(c: str) -> str: if titles and ("[[fdpage:" in c or "[[fddate:" in c): from app.services.wiki_links import resolve_tokens_html return resolve_tokens_html(c, titles) return c for b in blocks: t = b.get("type", "paragraph") c = _wiki(b.get("content", "") or "") if t == "heading_1": html_parts.append(f'

{c}

') elif t == "heading_2": html_parts.append(f'

{c}

') elif t == "heading_3": html_parts.append(f'

{c}

') elif t == "heading_4": html_parts.append(f'

{c}

') elif t == "bulleted_list": html_parts.append(f'
  • {c}
  • ') elif t == "numbered_list": html_parts.append(f'
  • {c}
  • ') elif t == "to_do": checked = "checked" if b.get("checked") else "" todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else "" html_parts.append( f'
    ' f'' f'{c}' f'
    ' ) elif t == "toggle": children_html = "" if b.get("children"): children_html = '
    ' children_html += _render_blocks_public(b["children"], titles) children_html += "
    " html_parts.append( f'
    {c}{children_html}
    ' ) elif t == "quote": html_parts.append( f'
    {c}
    ' ) elif t == "table_of_contents": toc = [ x for x in blocks if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() ] if toc: items = [] for h in toc: lvl = int(h["type"].split("_")[-1]) items.append( f'
    ' f'{h.get("content","")}
    ' ) html_parts.append( '
    ' '
    On this page
    ' + "".join(items) + "
    " ) elif t == "math": tex = c.replace("&", "&").replace("<", "<").replace(">", ">") html_parts.append( f'
    ' ) elif t == "columns": cols_html = "" for child in b.get("children") or []: cols_html += ( '
    ' + _render_blocks_public([child], titles) + "
    " ) html_parts.append( f'
    {cols_html}
    ' ) elif t == "callout": icon = b.get("icon", "💡") bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)") html_parts.append( f'
    ' f'{icon}' f'{c}
    ' ) elif t == "code": lang = b.get("language", "") lang_label = f"
    {lang}
    " if lang else "" html_parts.append( f'
    '
                    f'{lang_label}'
                    f'{c}
    ' ) elif t == "divider": html_parts.append('
    ') elif t == "image": src = b.get("src", "") alt = b.get("alt", "") html_parts.append( f'
    ' f'{alt}' f'
    ' ) elif t == "video": src = b.get("src", "") if src: html_parts.append( f'' ) elif t == "audio": src = b.get("src", "") if src: html_parts.append( f'' ) elif t == "bookmark": url = b.get("url") or b.get("src") or "" title = b.get("title") or url desc = b.get("description") or "" img = b.get("image") or "" site = b.get("site_name") or "" img_html = ( f'' if img else "" ) desc_html = f'
    {desc}
    ' if desc else "" site_html = f'
    {site}
    ' if site else "" html_parts.append( f'' f'
    ' f'
    {title}
    ' f'{desc_html}{site_html}
    {img_html}
    ' ) elif t == "embed": url = b.get("src", "") emb = b.get("embed_type") or "" if emb in ("inline_dbs", "collection"): html_parts.append('
    [Embedded content]
    ') elif emb == "download": html_parts.append( f'⬇ {b.get("file_name") or "Download"}' ) elif emb == "pdf" and url: html_parts.append( f'' ) elif url: from app.services.embeds import embed_src src = b.get("embed_src") or embed_src(url) or url height = b.get("height") or 520 try: height = int(height) except (ValueError, TypeError): height = 520 html_parts.append( f'
    ' ) else: html_parts.append(f'

    {c}

    ') return "\n".join(html_parts) # ═══════════ Library page actions API ═══════════ @router.get("/api/pages/{page_id:int}/content") async def api_page_content(page_id: int): """Get page content for side peek preview.""" with get_conn() as conn: row = conn.execute( "SELECT 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) return { "title": row["title"], "content": row["content"], "format": row["content_format"] or "blocks", } @router.put("/api/pages/{page_id:int}/rename") async def api_rename_page(page_id: int, request: Request): """Inline rename a page title.""" body = await request.json() title = (body.get("title") or "").strip() if not title: return JSONResponse({"error": "Title required"}, status_code=400) with get_conn() as conn: conn.execute( "UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", (title, page_id), ) conn.commit() return {"status": "ok", "title": title} @router.post("/api/pages/{page_id:int}/trash") async def api_trash_page(page_id: int): """Soft-delete a page (move to trash).""" with get_conn() as conn: conn.execute( "UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", (page_id,), ) conn.commit() return {"status": "ok"} @router.post("/api/pages/{page_id:int}/convert-to-database") async def api_convert_to_database(page_id: int, request: Request): """Convert a page into a full-page database (Notion-style). Creates a collection linked to this page, adds the default 'Name' property, and sets the page's content_format to 'collection'. """ import json as _json try: body = await request.json() except Exception: body = {} db_name = (body.get("name") or "").strip() with get_conn() as conn: page = conn.execute( "SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,), ).fetchone() if not page: return JSONResponse({"error": "Page not found"}, status_code=404) if not db_name: db_name = page["title"] or "New Database" # Create the collection cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) VALUES (?, '', '📋', '[]', 0, ?, ?)""", (db_name, page_id, page["workspace_id"]), ) collection_id = cur.lastrowid # Create default "Name" property (text, position 0) conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, position, required, visible_in_views) VALUES (?, 'Name', 'title', 0, 1, 1)""", (collection_id,), ) # Create default "Table" view conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, 'Table', 'table', ?, 0)""", (collection_id, _json.dumps({"visible_properties": ["Name"]})), ) # Update the page to be a database page conn.execute( "UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (collection_id, page_id), ) conn.commit() return { "status": "converted", "collection_id": collection_id, "name": db_name, "view_url": f"/pages/{page_id}", } @router.get("/api/collections/{collection_id:int}/table-data") async def api_collection_table_data(collection_id: int): """Get collection properties + pages for rendering the table view.""" with get_conn() as conn: coll = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not coll: return JSONResponse({"error": "Collection not found"}, status_code=404) properties = [ dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] pages = [ dict(r) for r in conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] views = [ dict(r) for r in conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] return { "collection": dict(coll), "properties": properties, "pages": pages, "views": views, } @router.post("/api/collections/{collection_id:int}/pages") async def api_create_collection_page(collection_id: int, request: Request): """Create a new page (row) in a collection.""" import json as _json try: body = await request.json() except Exception: body = {} title = body.get("title", "New page").strip() or "New page" icon = body.get("icon", "file") with get_conn() as conn: coll = conn.execute( "SELECT id FROM collections WHERE id=?", (collection_id,) ).fetchone() if not coll: return JSONResponse({"error": "Collection not found"}, status_code=404) # Get next position max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,), ).fetchone()[0] # Load default property values from collection properties props = [ dict(r) for r in conn.execute( "SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] default_values = {} for p in props: if p["prop_type"] == "title": default_values[str(p["id"])] = title # Caller-provided values (e.g. board "add card in column X") win. incoming = body.get("properties") or {} if isinstance(incoming, dict): default_values.update(incoming) from app.services.property_types import apply_auto_properties user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1} apply_auto_properties(props, default_values, user, is_create=True) cur = conn.execute( """INSERT INTO collection_pages (collection_id, title, icon, cover_url, position, property_values_json) VALUES (?, ?, ?, ?, ?, ?)""", (collection_id, title, icon, body.get("cover_url", ""), max_pos, _json.dumps(default_values)), ) page_id = cur.lastrowid conn.commit() return { "id": page_id, "title": title, "icon": icon, "position": max_pos, "property_values_json": default_values, "status": "created", }