Files
flowdeck/app/routers/board/_common.py
T
bruno 0bc74ad728
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m17s
FlowDeck CI / docker (push) Canceled after 0s
refactor: A28 TERMINÉ — board.py (2 101 L) → package 14 fichiers (v7.32.0)
Lot 4/4 de l'A28 (god files) : l'ancien app/routers/board.py (2 101
lignes, 53 routes) devient le package `app/routers/board/` :

- 12 modules de routes : pages 271 L (7 r.), page_api 229 (5),
  board_views 223 (8), page_ops 176 (3), sharing 175 (10), synced 144 (8),
  page_media 122 (4), import_ 85 (2), wiki 76 (2), library 66 (1),
  embed 64 (2), sync 51 (1)
- _common.py (878 L) : 23 helpers dont 4 async + les 4 constantes
  (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, _REPO_REF_RE)
- __init__.py : __all__ complet — importateurs inchangés (api.py ×4
  top-level, webhooks top-level, dashboard ×5 lazy, tests ×4)

Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE
byte-à-byte (509 chemins, ordre préservé).

Pièges rattrapés :
- constantes d'état oubliées dans _common à la 1ʳᵉ passe (F821 +
  ImportError au chargement) → ré-insérées avec les valeurs exactes
- docstring du header copié → F404 → slice [1:21]
- helpers `async def` non détectés par `def ` seul

A28 TERMINÉ en 4 lots : api_v2 (7.29.0), dashboard (7.30.0),
collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4.

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-02 08:27:57 -04:00

879 lines
31 KiB
Python

"""FlowDeck — Board : helpers et constantes partagés (A28).
Les 23 helpers de l'ancien board.py (dont 4 async) + constantes
(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) —
ré-exportés : api.py, webhooks, dashboard, tests.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.gitea_client import gitea
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
AI_KEYWORD_COLORS = [
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
]
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
Allowed to edit a locked page: admins and the user who locked it
(locked_by). Unauthenticated callers only pass when the page is unlocked.
"""
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
if not row or not row["is_locked"]:
return
uid = (user or {}).get("id")
is_admin = bool((user or {}).get("is_admin"))
if is_admin or (uid and row["locked_by"] == uid):
return
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
def _ensure_block_ids(blocks) -> None:
"""Assign unique ids to blocks missing one, recursively.
Built-in page templates ship without ids (the editor used to assign them
client-side only). Without persisted ids, the realtime layer and the editor
disagree on block identity, which duplicated lines / shuffled blocks when
editing a template-created page. We now materialize ids at creation time.
"""
import uuid
if not isinstance(blocks, list):
return
for b in blocks:
if isinstance(b, dict):
if not b.get("id"):
b["id"] = "b" + uuid.uuid4().hex[:12]
if isinstance(b.get("children"), list):
_ensure_block_ids(b["children"])
# ── Core helpers ──
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1]
for lbl in issue.get("labels", []):
with get_conn() as conn:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl["name"]),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
return columns[0] if columns else "Backlog"
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
title = issue.get("title", "Untitled")
status = "todo"
if issue.get("state") == "closed":
status = "done"
labels = issue.get("labels", [])
for lbl in labels:
name = lbl.get("name", "").lower()
if "progress" in name or "doing" in name:
status = "progress"
elif "done" in name or "complete" in name or "terminé" in name:
status = "done"
assignee = issue.get("assignee", {}) or {}
assignee_name = assignee.get("login", "")
tag = labels[0].get("name", "") if labels else ""
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
if tag_color and not tag_color.startswith("#"):
tag_color = f"#{tag_color}"
icon_map = {
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
}
icon = "file"
for lbl in labels:
for kw, emoji in icon_map.items():
if kw in lbl.get("name", "").lower():
icon = emoji
break
# Load custom property values
props = {}
if owner and repo:
with get_conn() as conn:
pvs = conn.execute("""
SELECT pp.name, pp.prop_type, pv.value
FROM property_values pv
JOIN project_properties pp ON pp.id = pv.property_id
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
""", (owner, repo, issue.get("number", 0))).fetchall()
for pv in pvs:
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
# AI keywords from DB
keywords = []
if owner and repo:
with get_conn() as conn:
kw_rows = conn.execute(
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
(owner, repo),
).fetchall()
# Filter: show keywords matching this issue's labels
label_names = {lbl.get("name", "").lower() for lbl in labels}
for kw in kw_rows:
if kw["keyword"].lower() in label_names or any(
kw["keyword"].lower() in lbl for lbl in label_names
):
keywords.append({"name": kw["keyword"], "color": kw["color"]})
return {
"id": str(issue.get("number", 0)),
"title": title,
"status": status,
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
"status_label": STATUS_LABELS.get(status, "To-do"),
"icon": icon,
"assignee": assignee_name,
"tag": tag if tag else None,
"tag_color": tag_color,
"due_date": issue.get("due_date", ""),
"url": issue.get("html_url", ""),
"keywords": keywords,
"custom_props": props,
}
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
if depth >= max_depth:
return []
rows = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
(ws_key, parent_id),
).fetchall()
items = []
for row in rows:
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
items.append({
"id": f"page/{row['id']}",
"db_id": row["id"],
"name": row["title"] or "New page",
"icon": "📄",
"url": f"/pages/{row['id']}",
"active": False,
"depth": depth,
"has_children": len(children) > 0,
"children": children,
})
return items
def _file_icon(name: str, content_format: str = "") -> str:
"""Map file extension to icon name (SVG-safe)."""
# FlowDeck internal pages (no extension)
if content_format and content_format != 'file':
return 'edit'
n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
return 'image'
if n.endswith('.pdf'):
return 'file'
if re.search(r'\.(md|markdown)$', n):
return 'edit'
if n.endswith('.py'):
return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n):
return 'file'
if re.search(r'\.(html?|xml)$', n):
return 'file'
if n.endswith('.css'):
return 'file'
if n.endswith('.json'):
return 'file'
if n.endswith('.sql'):
return 'file'
if re.search(r'\.(sh|bash|zsh)$', n):
return 'file'
if n.endswith('.ps1'):
return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
return 'file'
if re.search(r'\.(txt|log)$', n):
return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
return 'file'
return 'file'
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
if not ws_cookie:
return []
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
items.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
return items
except (ValueError, Exception):
return []
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
children.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
return children
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:
from app.db import get_conn
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 _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
own_ws = (
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
)
own_ws_names = (
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT w.name FROM workspaces w "
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
)
# Scope "shared by me"-style lists to pages in the user's own workspaces
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
scope_cond = (
f"(workspace_id IN ({own_ws}) "
f"OR (workspace_id IS NULL AND lower(workspace) IN "
f"(SELECT lower(name) FROM ({own_ws_names}))) "
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
f"(SELECT login FROM users WHERE id=?))))"
)
scope_params = (user_id, user_id, user_id, user_id, user_id)
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.created_by=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
made_link = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
made_flag = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
try:
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
(user_id, user_id, user_id),
).fetchall()
except Exception:
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
def _entry(r, icon):
return {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
made_map = {}
for r in (*made_nominal, *made_link, *made_flag):
made_map.setdefault(r["id"], r)
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_made = [_entry(r, "link") for r in made_sorted]
received_sorted = [r for r in received_rows if r["id"] not in made_map]
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_received = [_entry(r, "users") for r in received_sorted]
published = [_entry(r, "globe") for r in published_rows]
shared_all = [_entry(r, "link") for r in made_sorted]
return shared_made, shared_received, published, shared_all
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno"
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
# Active workspace name from cookie (for local workspace display)
from app.routers.dashboard import WORKSPACE_COOKIE
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
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: preserve context across pages. Also load the local
# mirror workspace so it appears in "My Workspaces" in the top section
# of the sidebar, 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
# Get local workspace ID for mirror and load its tree
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 Exception:
logger.exception("_sidebar_data")
elif ws_cookie and user:
try:
wsi = int(ws_cookie)
with get_conn() as conn:
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
except (ValueError, Exception):
pass
recent = []
if owner and repo:
view_map = {
"Kanban board": "kanban", "Detailed board": "detailed",
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
}
first = True
for label, view in view_map.items():
indent = 0 if first else 1
active = first
recent.append({
"id": f"{owner}/{repo}/{view}",
"name": label, "icon": "folder" if first else "",
"url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [],
})
first = False
# Load pages as nested tree for this project workspace
with get_conn() as conn:
tree_pages = _build_page_tree(conn, None, ws_key)
for p in tree_pages:
recent.append(p)
# Private pages: same as recent but filtered for page/ items (non-board views)
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
# Load favorite pages from DB
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()
favorites = []
for r in fav_rows:
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
# Load shared pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
# 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:
logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
"workspace_pages": workspace_pages,
"gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": favorites, "shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user),
"sidebar_config": get_sidebar_config_sync(uid)}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
"""Extract and persist AI keywords from issue labels and body."""
if not owner or not repo:
return
candidates = set()
for lbl in labels:
name = lbl.get("name", "").strip().lower()
if name and len(name) > 1:
candidates.add(name)
# Simple extraction from body: single words > 3 chars
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
if word not in ("this", "that", "with", "from", "have", "when", "will"):
candidates.add(word)
with get_conn() as conn:
for kw in candidates:
kw = kw[:30]
existing = conn.execute(
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
(owner, repo, kw),
).fetchone()
if existing:
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
(existing["usage_count"] + 1, existing["id"]))
else:
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
conn.execute(
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
)
conn.commit()
def _get_project_properties(owner: str, repo: str) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
(owner, repo),
).fetchall()
return [dict(r) for r in rows]
async def asyncio_get_labels(owner: str, repo: str):
return await gitea.get_labels(owner, repo)
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
if status_filter:
allowed = set(status_filter.split(","))
cards = [c for c in cards if c["status"] in allowed]
if filters:
for f in filters.split(","):
if ":" in f:
prop, val = f.split(":", 1)
val_lower = val.lower()
if prop == "assignee":
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
elif prop == "tag":
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
elif prop == "keyword":
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
return cards
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
if not sorts:
return cards
order = {"todo": 0, "progress": 1, "done": 2}
for spec in reversed(sorts.split(",")):
if ":" not in spec:
continue
field, direction = spec.split(":", 1)
rev = direction == "desc"
if field == "name":
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
elif field == "status":
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
elif field == "assignee":
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
elif field == "deadline":
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
return cards
# ═══════════ Library page ═══════════
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
"""Insert a version snapshot unless it is byte-identical to the latest one."""
prev = conn.execute(
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
(page_id,),
).fetchone()
if prev is not None and prev["blocks_json"] == blocks_json:
if prev["title"] != (title or ""):
conn.execute(
"UPDATE page_versions SET title=? WHERE id="
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
(title or "", page_id),
)
return
conn.execute(
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
(page_id, user_id, title or "", blocks_json),
)
def _block_texts(b: dict) -> list[str]:
"""Flatten a block (including children) into searchable text chunks."""
out = []
raw = b.get("content")
if isinstance(raw, str) and raw.strip():
out.append(raw)
for child in b.get("children") or []:
out.extend(_block_texts(child))
return out
# ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> Path:
return Path(settings.data_dir)
def _ws_id_for(request: Request, page_id: int) -> int:
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
cookie = request.cookies.get("flowdeck_workspace", "")
try:
ws_id = int(cookie)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
if row and row["workspace_id"]:
return int(row["workspace_id"])
return 1
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
{file_url, file_path, mime_type, size, file_name}."""
import datetime
import re as _re
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
(folder / final).write_bytes(await upload.read())
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
return {
"file_url": f"/api/files/{ws_id}/{final}",
"file_path": f"uploads/workspace_{ws_id}/{final}",
"mime_type": mime,
"size": (folder / final).stat().st_size,
"file_name": name,
}
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
"""Convert markdown → blocks (server-side, same mapping as the editor) and
create a page in the caller's workspace."""
from app.services.export import _md_to_blocks
blocks = _md_to_blocks(markdown or "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = user.get("login", "Bruno") if user else "Bruno"
file_title = title.strip() or "Import"
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
if not blocks:
blocks = [{"type": "paragraph", "content": markdown or ""}]
with get_conn() as conn:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
)
conn.commit()
return cur.lastrowid
async def _unfurl_repo(forge: str, owner: str, repo: str):
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
try:
if forge == "gitea":
from app.services.gitea_client import GiteaClient
info = await GiteaClient().get_repo_info(owner, repo)
site = "Gitea"
else:
from app.config import settings
from app.services.github_adapter import GitHubAdapter
token = getattr(settings, "github_token", None) or ""
if token:
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
else:
async with shared_client(timeout=10) as client:
r = await client.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers={"Accept": "application/vnd.github+json"},
)
r.raise_for_status()
info = r.json()
site = "GitHub"
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
return None
branch = info.get("default_branch") or "main"
return {
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
"title": info.get("full_name") or f"{owner}/{repo}",
"description": (info.get("description") or f"{site} repository "
f"{owner}/{repo} · default branch: {branch}"),
"image": "",
"site_name": site,
"language": info.get("language") or "",
}