highlightCode() applique une coloration par regex côté client: - Commentaires: # (Python/Bash), // (JS/Go/Rust/Java/SQL), /* */, <!-- --> - Strings: "double", 'simple', - Nombres: entiers et décimaux - Mots-clés par langage (Python, JS/TS, Go, Rust, Java, SQL, Bash, PS) - Décorateurs Python (@decorator) - Appliqué ligne par ligne dans le viewer - Couleurs: hl-kw=#ff7b72, hl-str=#a5d6ff, hl-cmt=#8b949e, hl-num=#79c0ff, hl-fn=#d2a8ff
1093 lines
48 KiB
Python
1093 lines
48 KiB
Python
"""FlowDeck — Board Kanban Notion-style + multi-vues + propriétés custom + AI keywords."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.db import get_conn
|
|
from app.services.gitea_client import gitea
|
|
from app.auth.session import SessionManager
|
|
|
|
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",
|
|
]
|
|
|
|
# ── 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 = "📄"
|
|
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 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 emoji icon."""
|
|
import re
|
|
# FlowDeck internal pages (no extension)
|
|
if content_format and content_format != 'file':
|
|
return '📝'
|
|
n = name.lower()
|
|
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️'
|
|
if n.endswith('.pdf'): return '📕'
|
|
if re.search(r'\.(md|markdown)$', n): return '📝'
|
|
if n.endswith('.py'): return '🐍'
|
|
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜'
|
|
if re.search(r'\.(html?|xml)$', n): return '🌐'
|
|
if n.endswith('.css'): return '🎨'
|
|
if n.endswith('.json'): return '📋'
|
|
if n.endswith('.sql'): return '🗃️'
|
|
if re.search(r'\.(sh|bash|zsh)$', n): return '💻'
|
|
if n.endswith('.ps1'): return '⚡'
|
|
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜'
|
|
if re.search(r'\.(txt|log)$', n): return '📄'
|
|
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦'
|
|
return '📄'
|
|
|
|
|
|
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 FROM pages WHERE workspace_id=? AND parent_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"
|
|
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,
|
|
"children": _load_children(r["id"]),
|
|
})
|
|
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 FROM pages WHERE parent_id=? ORDER BY created_at",
|
|
(parent_id,),
|
|
).fetchall()
|
|
children = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
title = r["title"] or "Untitled"
|
|
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,
|
|
"children": _load_children(r["id"]),
|
|
})
|
|
return children
|
|
|
|
|
|
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"
|
|
if ws_cookie:
|
|
try:
|
|
with get_conn() as conn:
|
|
ws = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone()
|
|
if ws:
|
|
active_ws_name = ws["name"]
|
|
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": "📁" 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 (anyone with link or published)
|
|
with get_conn() as conn:
|
|
shared_rows = conn.execute(
|
|
"SELECT id, title, workspace, share_mode, published FROM pages "
|
|
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
|
|
).fetchall()
|
|
shared_pages = []
|
|
for r in shared_rows:
|
|
shared_pages.append({
|
|
"id": f"page/{r['id']}",
|
|
"db_id": r["id"],
|
|
"name": r["title"] or "New page",
|
|
"icon": "🌐" if r["published"] else "🔗",
|
|
"url": f"/pages/{r['id']}",
|
|
"active": False,
|
|
"indent": 0,
|
|
"depth": 0,
|
|
"has_children": False,
|
|
"children": [],
|
|
})
|
|
|
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
|
"active_ws_name": active_ws_name,
|
|
"workspace_pages": _load_workspace_pages(ws_cookie),
|
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
|
"recent_pages": recent, "private_pages": private_items,
|
|
"favorite_pages": favorites, "shared_pages": shared_pages,
|
|
"user": user, "workspace_key": ws_key}
|
|
|
|
|
|
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
|
|
import re
|
|
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]
|
|
|
|
|
|
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
|
|
"""Return groups from Gitea labels/milestones or fallback to defaults."""
|
|
try:
|
|
labels = json.loads(
|
|
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
|
|
) if False else []
|
|
except Exception:
|
|
labels = []
|
|
return labels if labels else ["Design", "Engineering", "No Team"]
|
|
|
|
|
|
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 ═══════════
|
|
|
|
@router.get("/library", response_class=HTMLResponse)
|
|
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
# Load all pages for the workspace from DB
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
with get_conn() as conn:
|
|
if ws_key:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, updated_at FROM pages "
|
|
"WHERE workspace=? ORDER BY updated_at DESC",
|
|
(ws_key,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, updated_at FROM pages "
|
|
"ORDER BY updated_at DESC",
|
|
).fetchall()
|
|
all_pages = []
|
|
for r in rows:
|
|
page = dict(r)
|
|
all_pages.append({
|
|
"id": f"page/{page['id']}",
|
|
"name": page["title"] or "Untitled",
|
|
"icon": "📄",
|
|
"url": f"/pages/{page['id']}",
|
|
"created_by": "You",
|
|
"source": page.get("workspace") or "🔒 Private",
|
|
"last_edited": page.get("updated_at", "now"),
|
|
"last_visited": page.get("updated_at", "now"),
|
|
})
|
|
sidebar["recent_pages"] = all_pages
|
|
sidebar["favorite_pages"] = []
|
|
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
|
sidebar["shared_pages"] = []
|
|
template = env.get_template("library.html")
|
|
return template.render(**sidebar)
|
|
|
|
# ═══════════ Favorites API ═══════════
|
|
|
|
@router.get("/api/favorites")
|
|
async def list_favorites(request: Request):
|
|
"""List favorited page IDs 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 page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
|
).fetchall()
|
|
return {"favorites": [r["page_id"] for r in rows]}
|
|
|
|
|
|
@router.post("/api/favorites/{page_id:int}")
|
|
async def add_favorite(request: Request, page_id: int):
|
|
"""Add a page to favorites."""
|
|
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:
|
|
existing = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
|
).fetchone()
|
|
if not existing:
|
|
pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
|
).fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
|
(uid, page_id, pos),
|
|
)
|
|
conn.commit()
|
|
return {"status": "added", "page_id": page_id}
|
|
|
|
|
|
@router.delete("/api/favorites/{page_id:int}")
|
|
async def remove_favorite(request: Request, page_id: int):
|
|
"""Remove a page from favorites."""
|
|
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:
|
|
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
|
conn.commit()
|
|
return {"status": "removed", "page_id": page_id}
|
|
|
|
# ═══════════ Share API ═══════════
|
|
|
|
@router.post("/api/share/{page_id:int}")
|
|
async def update_share(request: Request, page_id: int):
|
|
"""Save share settings for a page."""
|
|
import json
|
|
body = await request.json()
|
|
mode = body.get("mode", "private")
|
|
published = body.get("published", False)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
|
(mode, 1 if published else 0, page_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "share_mode": mode, "published": published}
|
|
|
|
|
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
|
|
|
@router.get("/api/trash")
|
|
async def list_trash(request: Request):
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
|
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
|
|
|
|
|
@router.post("/api/trash/{page_id}/restore")
|
|
async def restore_page(request: Request, page_id: int):
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "restored": page_id}
|
|
|
|
|
|
@router.delete("/api/trash/{page_id}")
|
|
async def permanent_delete(request: Request, page_id: int):
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "deleted": page_id}
|
|
|
|
|
|
@router.get("/trash", response_class=HTMLResponse)
|
|
async def trash_page(request: Request):
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
template = env.get_template("trash.html")
|
|
return template.render(**_sidebar_data(request))
|
|
|
|
|
|
# ═══════════ Board page ═══════════
|
|
|
|
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
|
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
template = env.get_template("board.html")
|
|
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
|
initial_view=view, **sidebar)
|
|
|
|
|
|
# ═══════════ View fragments ═══════════
|
|
|
|
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
|
async def board_view(
|
|
request: Request, owner: str, repo: str, view: str,
|
|
status: str = Query(default=""),
|
|
filter: str = Query(default=""),
|
|
sort: str = Query(default=""),
|
|
):
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
issues_only = [i for i in issues if not i.get("pull_request")]
|
|
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
|
|
cards = _apply_filters(cards, status, filter)
|
|
cards = _apply_sorts(cards, sort)
|
|
except Exception as e:
|
|
logger.error("Board view error: %s", e)
|
|
cards = []
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
|
|
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
|
group_names = ["Design", "Engineering", "No Team"]
|
|
groups = []
|
|
for gname in group_names:
|
|
gid = gname.lower().replace(" ", "-")
|
|
gcards = cards
|
|
groups.append({
|
|
"id": gid, "name": gname,
|
|
"counts": {
|
|
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
|
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
|
"done": len([c for c in gcards if c["status"] == "done"]),
|
|
},
|
|
"cards": gcards,
|
|
})
|
|
|
|
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
|
|
|
template_map = {
|
|
"table": "table_view.html",
|
|
"status": "status_overview.html",
|
|
"teamload": "team_load.html",
|
|
"detailed": "detailed_board.html",
|
|
}
|
|
|
|
if view == "table":
|
|
grouped = {g["name"]: g["cards"] for g in groups}
|
|
ctx["grouped_cards"] = grouped
|
|
elif view == "status":
|
|
counts = {"todo": 0, "progress": 0, "done": 0}
|
|
for c in cards:
|
|
if c["status"] in counts:
|
|
counts[c["status"]] += 1
|
|
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
|
|
elif view == "teamload":
|
|
members = {}
|
|
for c in cards:
|
|
name = c.get("assignee") or "Unassigned"
|
|
if name not in members:
|
|
members[name] = {"name": name, "initial": name[0].upper(),
|
|
"todo": 0, "progress": 0, "complete": 0, "total": 0}
|
|
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
|
members[name][sk] += 1
|
|
members[name]["total"] += 1
|
|
ctx["team_data"] = list(members.values())
|
|
elif view == "detailed":
|
|
pass
|
|
else:
|
|
template_map["kanban"] = "board_fragment.html"
|
|
|
|
template_name = template_map.get(view, "board_fragment.html")
|
|
template = env.get_template(template_name)
|
|
return template.render(**ctx)
|
|
|
|
|
|
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
|
|
|
@router.get("/api/properties/{owner}/{repo}")
|
|
async def get_properties(owner: str, repo: str):
|
|
return {"properties": _get_project_properties(owner, repo)}
|
|
|
|
|
|
@router.post("/api/properties/{owner}/{repo}")
|
|
async def create_property(owner: str, repo: str, name: str = Query(...),
|
|
prop_type: str = Query(default="select"),
|
|
options: str = Query(default="")):
|
|
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
|
with get_conn() as conn:
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
|
|
(owner, repo, name, prop_type, opts),
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
raise HTTPException(409, f"Property already exists: {e}")
|
|
return {"status": "ok", "name": name, "type": prop_type}
|
|
|
|
|
|
@router.delete("/api/properties/{owner}/{repo}")
|
|
async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
|
(owner, repo, name),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/properties/{owner}/{repo}/values")
|
|
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
|
name: str = Query(...), value: str = Query(default="")):
|
|
with get_conn() as conn:
|
|
prop = conn.execute(
|
|
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
|
(owner, repo, name),
|
|
).fetchone()
|
|
if not prop:
|
|
raise HTTPException(404, f"Property '{name}' not found")
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
|
|
(prop["id"], issue_id, value),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
|
|
|
@router.get("/api/ai-keywords/{owner}/{repo}")
|
|
async def get_ai_keywords(owner: str, repo: str):
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
|
(owner, repo),
|
|
).fetchall()
|
|
return {"keywords": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
|
|
async def extract_ai_keywords(owner: str, repo: str):
|
|
"""Re-extract keywords from all issues in the repo."""
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
for issue in issues:
|
|
if not issue.get("pull_request"):
|
|
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
return {"status": "ok", "issues_scanned": len(issues)}
|
|
|
|
|
|
# ═══════════ Pages Markdown ═══════════
|
|
|
|
@router.post("/api/pages")
|
|
async def create_page(request: Request, title: str = Query(default=""),
|
|
section: str = Query(default="Private"),
|
|
project: str = Query(default=""),
|
|
parent_id: int = Query(default=0)):
|
|
"""Create a new Markdown page, optionally as a sub-page."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
|
with get_conn() as conn:
|
|
# Compute next sort_order for this parent
|
|
next_order = 0
|
|
parent_val = parent_id if parent_id > 0 else None
|
|
row = conn.execute(
|
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
|
(ws_key, parent_val),
|
|
).fetchone()
|
|
if row:
|
|
next_order = row[0]
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
|
(ws_key, title, section, parent_val, next_order),
|
|
)
|
|
conn.commit()
|
|
page_id = cur.lastrowid
|
|
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
|
|
|
|
|
|
@router.get("/api/pages/{page_id}")
|
|
async def get_page(page_id: int):
|
|
"""Get a Markdown page."""
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
return dict(row)
|
|
|
|
|
|
@router.put("/api/pages/{page_id}")
|
|
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
|
content: str = Query(default=""),
|
|
content_format: str = Query(default="")):
|
|
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
|
with get_conn() as conn:
|
|
if title:
|
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
|
if content:
|
|
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
|
if content_format:
|
|
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/pages/{page_id}/blocks")
|
|
async def save_page_blocks(request: Request, page_id: int):
|
|
"""Save blocks JSON content (Notion-style block editor)."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid JSON body")
|
|
blocks_json = json.dumps(body.get("blocks", []))
|
|
title = body.get("title", "")
|
|
with get_conn() as conn:
|
|
if title:
|
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
|
conn.execute(
|
|
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(blocks_json, page_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "id": page_id}
|
|
|
|
|
|
@router.put("/api/pages/{page_id}/move")
|
|
async def move_page(request: Request, page_id: int,
|
|
new_parent_id: int = Query(default=0),
|
|
new_order: int = Query(default=0)):
|
|
"""Move/reorder a page in the tree (drag & drop).
|
|
new_parent_id=0 means move to root level.
|
|
new_order=0 means append to end (or set explicit position)."""
|
|
with get_conn() as conn:
|
|
# Verify page exists
|
|
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
# Update parent (None for root level)
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(new_parent_id if new_parent_id > 0 else None, page_id),
|
|
)
|
|
# Update sort order
|
|
conn.execute(
|
|
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(new_order, page_id),
|
|
)
|
|
# Re-index siblings to ensure no gaps
|
|
siblings = conn.execute(
|
|
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
|
|
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
|
|
).fetchall()
|
|
# Recompute sort_order for all siblings after the insertion point
|
|
insert_point = new_order
|
|
reorder = []
|
|
for sib in siblings:
|
|
if sib["sort_order"] >= insert_point:
|
|
reorder.append(sib["id"])
|
|
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
|
|
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
|
|
conn.commit()
|
|
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
|
|
|
|
|
|
@router.delete("/api/pages/{page_id}")
|
|
async def delete_page(request: Request, page_id: int):
|
|
"""Move a page to trash (soft delete)."""
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Page not found")
|
|
import datetime
|
|
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
|
|
|
|
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
|
async def view_page(request: Request, page_id: int):
|
|
"""Render a page as HTML, or a file viewer for uploaded files."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
|
page = dict(row)
|
|
|
|
# ── File viewer for uploaded files ──
|
|
if page.get("content_format") == "file":
|
|
return _render_file_viewer(page, request)
|
|
|
|
ws = page.get("workspace", "")
|
|
parts = ws.split("/") if "/" in ws else ["", ""]
|
|
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
|
sidebar = _sidebar_data(request, owner, repo)
|
|
# Check if page is favorited
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
fav = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
|
).fetchone()
|
|
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
|
"page_share_mode": page.get("share_mode", "private"),
|
|
"page_published": bool(page.get("published", 0)),
|
|
"page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}}
|
|
template = env.get_template("page_editor.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
|
|
|
|
|
def _render_file_viewer(page: dict, request: Request) -> HTMLResponse:
|
|
"""Render a file viewer for uploaded files (content_format='file')."""
|
|
import json
|
|
page_id = page["id"]
|
|
title = page.get("title", "File")
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
|
initial = ws_name[0].upper() if ws_name else "B"
|
|
|
|
try:
|
|
meta = json.loads(page.get("content", "{}"))
|
|
except json.JSONDecodeError:
|
|
meta = {}
|
|
file_path = meta.get("file_path", "")
|
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
|
size = meta.get("size", 0)
|
|
|
|
# Build workspace_id from file_path
|
|
parts = file_path.split("/")
|
|
ws_id = ""
|
|
for p in parts:
|
|
if p.startswith("workspace_"):
|
|
ws_id = p.replace("workspace_", "")
|
|
break
|
|
filename = parts[-1] if parts else title
|
|
|
|
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
|
|
|
# Determine viewer type
|
|
is_image = mime_type.startswith("image/")
|
|
is_pdf = mime_type == "application/pdf"
|
|
is_text = mime_type.startswith("text/") or mime_type in (
|
|
"application/json", "application/javascript", "application/xml",
|
|
"application/x-python", "application/x-sh"
|
|
)
|
|
is_code = any(ext in (filename or "").lower() for ext in (
|
|
".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css",
|
|
".json", ".xml", ".yaml", ".yml", ".md", ".sql", ".sh", ".bash",
|
|
".ps1", ".bat", ".cmd", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
|
|
".h", ".hpp", ".php", ".swift", ".kt", ".scala", ".r", ".toml",
|
|
".ini", ".cfg", ".conf", ".env", ".dockerfile", ".makefile"
|
|
))
|
|
if is_code:
|
|
is_text = True
|
|
|
|
# Determine language class for syntax highlighting
|
|
ext_map = {
|
|
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
".html": "html", ".css": "css", ".json": "json", ".md": "markdown",
|
|
".sql": "sql", ".sh": "bash", ".ps1": "powershell", ".rs": "rust",
|
|
".go": "go", ".java": "java", ".rb": "ruby", ".xml": "xml",
|
|
".yaml": "yaml", ".yml": "yaml", ".toml": "toml",
|
|
}
|
|
lang = "plaintext"
|
|
for ext, name in ext_map.items():
|
|
if (filename or "").lower().endswith(ext):
|
|
lang = name
|
|
break
|
|
|
|
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB"
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en" data-theme="dark">
|
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{title} — FlowDeck</title>
|
|
<style>
|
|
:root{{--bg:#0d1117;--bg2:#161b22;--text:#c9d1d9;--text2:#8b949e;--accent:#58a6ff;--border:#30363d;--line-num:#484f58;}}
|
|
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;}}
|
|
.topbar{{display:flex;align-items:center;gap:10px;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10;}}
|
|
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:var(--bg);border:1px solid var(--border);cursor:pointer;font-size:12px;padding:5px 10px;border-radius:6px;display:inline-flex;align-items:center;gap:4px;transition:all 150ms;}}
|
|
.topbar a:hover,.topbar button:hover{{background:var(--border);color:var(--text);}}
|
|
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
|
|
.topbar .lang-badge{{font-size:11px;padding:2px 8px;background:var(--accent);color:#fff;border-radius:10px;font-weight:500;border:none;}}
|
|
.meta{{font-size:11px;color:var(--text2);white-space:nowrap;}}
|
|
.btn-copy{{gap:4px;}}
|
|
.btn-copy.copied{{background:#238636;border-color:#238636;color:#fff;}}
|
|
.viewer{{max-width:100%;margin:0 auto;}}
|
|
.viewer img{{max-width:100%;max-height:85vh;display:block;margin:0 auto;}}
|
|
.viewer iframe{{width:100%;height:90vh;border:none;}}
|
|
.code-container{{display:flex;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono','SF Mono',monospace;font-size:12px;line-height:20px;}}
|
|
.line-numbers{{flex-shrink:0;padding:12px 10px;text-align:right;color:var(--line-num);background:var(--bg2);border-right:1px solid var(--border);user-select:none;min-width:48px;}}
|
|
.line-numbers span{{display:block;}}
|
|
.code-content{{flex:1;padding:12px 16px;overflow:auto;white-space:pre;color:var(--text);background:var(--bg);}}
|
|
.code-content span{{display:block;}}
|
|
.code-content .hl-kw{{color:#ff7b72;}}
|
|
.code-content .hl-str{{color:#a5d6ff;}}
|
|
.code-content .hl-cmt{{color:#8b949e;font-style:italic;}}
|
|
.code-content .hl-num{{color:#79c0ff;}}
|
|
.code-content .hl-fn{{color:#d2a8ff;}}
|
|
.unsupported{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;text-align:center;}}
|
|
.unsupported .icon{{font-size:64px;margin-bottom:16px;}}
|
|
.unsupported h2{{font-size:20px;margin-bottom:8px;}}
|
|
.unsupported p{{color:var(--text2);margin-bottom:20px;}}
|
|
.unsupported a{{display:inline-block;padding:10px 24px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-weight:500;}}
|
|
</style></head>
|
|
<body>
|
|
<div class="topbar">
|
|
<a href="/local-workspace">← Workspace</a>
|
|
<span class="title">{title}</span>
|
|
<span class="lang-badge">{lang}</span>
|
|
<span class="meta">{size_str}</span>
|
|
<button class="btn-copy" onclick="copyCode()" title="Copy to clipboard">📋 Copy</button>
|
|
<a href="{file_url}" download>⬇ Download</a>
|
|
</div>
|
|
<div class="viewer">"""
|
|
|
|
if is_image:
|
|
html += f'<img src="{file_url}" alt="{title}">'
|
|
elif is_pdf:
|
|
html += f'<iframe src="{file_url}"></iframe>'
|
|
elif is_text:
|
|
html += f'''<div class="code-container">
|
|
<div class="line-numbers" id="line-numbers"></div>
|
|
<div class="code-content" id="code-content">Loading...</div>
|
|
</div>
|
|
<script>
|
|
var codeEl=document.getElementById("code-content");
|
|
var lineEl=document.getElementById("line-numbers");
|
|
fetch("{file_url}").then(function(r){{return r.text()}}).then(function(t){{
|
|
var lines=t.split("\\n");
|
|
if(lines.length>1&&lines[lines.length-1]==="")lines.pop();
|
|
var numHtml="",codeHtml="";
|
|
for(var i=0;i<lines.length;i++){{
|
|
numHtml+="<span>"+(i+1)+"</span>";
|
|
codeHtml+="<span>"+highlightCode(lines[i])+"</span>";
|
|
}}
|
|
lineEl.innerHTML=numHtml;
|
|
codeEl.innerHTML=codeHtml;
|
|
}}).catch(function(e){{codeEl.textContent="Error loading file: "+e;}});
|
|
function escHtml(s){{return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}
|
|
function highlightCode(line){{
|
|
var s=escHtml(line);
|
|
var lang="{lang}";
|
|
// Comments
|
|
if(lang==="python"||lang==="bash"||lang==="yaml"||lang==="toml"||lang==="powershell"){{
|
|
s=s.replace(/^(\\s*#.*)$/gm,'<span class="hl-cmt">$1</span>');
|
|
}}else if(lang==="javascript"||lang==="typescript"||lang==="go"||lang==="rust"||lang==="java"||lang==="sql"){{
|
|
s=s.replace(/^(\\s*\\/\\/.*)$/gm,'<span class="hl-cmt">$1</span>');
|
|
s=s.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g,'<span class="hl-cmt">$1</span>');
|
|
}}else if(lang==="html"||lang==="xml"){{
|
|
s=s.replace(/(<!--[\\s\\S]*?-->)/g,'<span class="hl-cmt">$1</span>');
|
|
}}else if(lang==="css"){{
|
|
s=s.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g,'<span class="hl-cmt">$1</span>');
|
|
}}
|
|
// Strings
|
|
s=s.replace(/("(?:[^&]|&(?!quot;))*")/g,'<span class="hl-str">$1</span>');
|
|
s=s.replace(/('(?:[^&]|&(?!#x27;))*')/g,'<span class="hl-str">$1</span>');
|
|
s=s.replace(/(`[^`]*`)/g,'<span class="hl-str">$1</span>');
|
|
// Numbers
|
|
s=s.replace(/\\b(\\d+\\.?\\d*)\\b/g,'<span class="hl-num">$1</span>');
|
|
// Keywords
|
|
var kw=[];
|
|
if(lang==="python")kw=["def","class","import","from","return","if","elif","else","for","while","try","except","finally","with","as","yield","raise","pass","break","continue","and","or","not","in","is","None","True","False","lambda","async","await","self"];
|
|
else if(lang==="javascript"||lang==="typescript")kw=["function","const","let","var","return","if","else","for","while","do","switch","case","break","continue","try","catch","finally","throw","new","class","extends","import","export","default","from","async","await","typeof","instanceof","this","super","null","undefined","true","false","of","in"];
|
|
else if(lang==="go")kw=["func","package","import","return","if","else","for","range","switch","case","break","continue","defer","go","chan","map","struct","interface","type","var","const","nil","true","false"];
|
|
else if(lang==="rust")kw=["fn","let","mut","const","struct","enum","impl","trait","pub","use","mod","match","if","else","for","while","loop","return","break","continue","self","super","where","as","in","ref","move","true","false","async","await"];
|
|
else if(lang==="java")kw=["public","private","protected","class","interface","extends","implements","static","final","void","int","long","double","boolean","char","String","new","return","if","else","for","while","switch","case","break","continue","try","catch","finally","throw","throws","import","package","this","super","null","true","false"];
|
|
else if(lang==="sql")kw=["SELECT","FROM","WHERE","INSERT","UPDATE","DELETE","CREATE","ALTER","DROP","TABLE","INDEX","VIEW","INTO","VALUES","SET","JOIN","LEFT","RIGHT","INNER","OUTER","ON","AND","OR","NOT","NULL","AS","ORDER","BY","GROUP","HAVING","LIMIT","OFFSET","UNION","ALL","DISTINCT","COUNT","SUM","AVG","MAX","MIN","EXISTS","BETWEEN","LIKE","IN","CASE","WHEN","THEN","ELSE","END","PRIMARY","KEY","FOREIGN","REFERENCES","CONSTRAINT","DEFAULT","CHECK","UNIQUE","CASCADE"];
|
|
else if(lang==="bash")kw=["if","then","else","elif","fi","for","while","do","done","case","esac","function","return","exit","export","local","readonly","unset","echo","printf","source","exec","trap"];
|
|
else if(lang==="powershell")kw=["function","param","if","else","elseif","foreach","for","while","do","until","switch","case","break","continue","return","throw","try","catch","finally","trap","begin","process","end","param","Write-Host","Write-Output","Get-Content","Set-Content","Out-File"];
|
|
if(kw.length>0){{
|
|
var re=new RegExp("\\\\b("+kw.join("|")+")\\\\b","g");
|
|
s=s.replace(re,'<span class="hl-kw">$1</span>');
|
|
}}
|
|
// Decorators (Python) & function calls
|
|
if(lang==="python"){{
|
|
s=s.replace(/(@\\w+)/g,'<span class="hl-fn">$1</span>');
|
|
}}
|
|
return s;
|
|
}}
|
|
function copyCode(){{
|
|
var text=Array.from(codeEl.children).map(function(s){{return s.textContent}}).join("\\n");
|
|
navigator.clipboard.writeText(text).then(function(){{
|
|
var btn=document.querySelector(".btn-copy");
|
|
btn.textContent="✓ Copied!";
|
|
btn.classList.add("copied");
|
|
setTimeout(function(){{btn.textContent="📋 Copy";btn.classList.remove("copied");}},2000);
|
|
}});
|
|
}}
|
|
</script>'''
|
|
else:
|
|
html += f'''<div class="unsupported">
|
|
<div class="icon">📎</div>
|
|
<h2>{title}</h2>
|
|
<p>{size_str} · {mime_type}<br>This file type cannot be previewed.</p>
|
|
<a href="{file_url}" download>⬇ Download</a>
|
|
</div>'''
|
|
|
|
html += "</div></body></html>"
|
|
return HTMLResponse(content=html)
|
|
|
|
@router.post("/api/sync/{owner}/{repo}")
|
|
async def sync_project(owner: str, repo: str):
|
|
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
|
try:
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
issues_only = [i for i in issues if not i.get("pull_request")]
|
|
with get_conn() as conn:
|
|
board = conn.execute(
|
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
|
(owner, repo),
|
|
).fetchone()
|
|
if board:
|
|
board_id = board["id"]
|
|
columns = json.loads(board["columns_json"])
|
|
for issue in issues_only:
|
|
col = _issue_column(issue, columns, board_id)
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
|
(board_id, issue["number"], col),
|
|
)
|
|
# Extract AI keywords from each issue
|
|
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
|
conn.commit()
|
|
return {"status": "ok", "issues_synced": len(issues_only)}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|