- Add sort_order column to pages table for drag & drop reordering
- Rewrite _sidebar_data with recursive _build_page_tree for nested hierarchy
- Add tree toggle buttons (▶ collapsed / ▼ expanded) on parent items
- Alpine.js expandedNodes state with toggleTreeNode() for expand/collapse
- Jinja2 recursive macro render_tree_item for proper nesting + indentation
- SortableJS integration on .sidebar-items for drag & drop repositioning
- New PUT /board/api/pages/{id}/move API for persisting moves
- Auto-set sort_order on page creation (max+1 for same parent)
- CSS: .tree-toggle, .tree-toggle-placeholder, grab cursor on page-icon
- 28/28 tests pass, all endpoints healthy
590 lines
24 KiB
Python
590 lines
24 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 ? 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"],
|
|
"icon": "📄",
|
|
"url": f"/pages/{row['id']}",
|
|
"active": False,
|
|
"depth": depth,
|
|
"has_children": len(children) > 0,
|
|
"children": children,
|
|
})
|
|
return items
|
|
|
|
|
|
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
|
|
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/")]
|
|
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
|
"current_page": repo or "Dashboard", "last_edited": "now",
|
|
"recent_pages": recent, "private_pages": private_items,
|
|
"favorite_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):
|
|
"""Library page avec onglets Recents, Favorites, Shared, Private."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request)
|
|
# Enrich with timestamps
|
|
for p in sidebar["recent_pages"]:
|
|
p["last_edited"] = "now"
|
|
for p in sidebar["private_pages"]:
|
|
p["last_edited"] = "now"
|
|
template = env.get_template("library.html")
|
|
return template.render(**sidebar)
|
|
|
|
|
|
# ═══════════ 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="New page"),
|
|
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(page_id: int, title: str = Query(default=""),
|
|
content: str = Query(default="")):
|
|
"""Update a page's title and/or content."""
|
|
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))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@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.get("/pages/{page_id}", response_class=HTMLResponse)
|
|
async def view_page(request: Request, page_id: int):
|
|
"""Render a page as HTML."""
|
|
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)
|
|
sidebar = _sidebar_data(request)
|
|
ctx = {**sidebar, "page": dict(row)}
|
|
template = env.get_template("page_editor.html")
|
|
return template.render(**ctx)
|
|
|
|
@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))
|