- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
883 lines
32 KiB
Python
883 lines
32 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
|
|
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
|
|
# l'espace ACTIF (le bouton Home de base.html pointait sinon
|
|
# sur ``local_workspaces[0]``, premier espace trie par nom).
|
|
local_ws_id = wsi
|
|
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 "",
|
|
}
|
|
|
|
|
|
|