diff --git a/ROADMAP.md b/ROADMAP.md index c61124b..e9abbbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,50 +7,30 @@ Dashboard + Board Kanban + Gitea sync + Notes + Docker ## v0.3.0 — Auth & Multi-user ✅ -- [x] OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks +OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks ## v0.4.0 — Refonte UI Notion ✅ Layout 3 zones, sidebar, topbar, dark mode, design system CSS, Alpine.js ## v0.5.0 — Kanban Notion-Style ✅ -- [x] Colonnes: To-do / In progress / Complete avec couleurs -- [x] Compteurs par colonne et par groupe -- [x] Sous-groupes rétractables (Design, Engineering, No Team) -- [x] Cartes: icône + titre + avatar assigné + tag catégorie + couleur selon statut -- [x] Drag & drop inter-colonnes (SortableJS → POST /api/move) -- [x] Bouton + New page / + New group -- [x] Création d'issues depuis FlowDeck (formulaire inline → POST /api/issues) -- [x] Card modal avec détail complet (titre, description, assignee, labels, dates) -- [x] Checklists dans le détail carte (créer, cocher, ajouter items) -- [x] Commentaires Gitea affichés dans la carte modale -- [x] Édition inline (titre, description contenteditable) +Colonnes, compteurs, groupes, cartes enrichies, drag & drop, création issues, modale détail, checklists, commentaires, édition inline ## v0.6.0 — Vue Tableau ✅ -- [x] Colonnes: Name (icônes), Status (tag coloré), Assign (avatar+nom), Deadline, Team, AI keywords -- [x] Tri par colonne (clic en-tête, asc/desc) -- [x] Lignes groupées par catégorie (Design, Engineering — rétractables) -- [x] Filtres intégrés (via toolbar filtres) +Colonnes Name/Status/Assign/Deadline/Team/Keywords, tri par colonne, groupes rétractables ## v0.7.0 — Système de Filtres & Tri ✅ -- [x] Filtres cumulables AND (backend + UI pastilles cliquables) -- [x] + Filter / Reset -- [x] Filtre par statut: checkboxes multiples avec points colorés (dropdown panel) -- [x] Filtre par assignee, tag (property:value) -- [x] Tri multi-critères hiérarchique (N niveaux) -- [x] + Add sort / Delete sort (UI panel) -- [x] Options Ascending/Descending +Filtres cumulables AND, status dropdown checkboxes, sort panel multi-critères, asc/desc ## v0.8.0 — Vues Spéciales ✅ -- [x] Status Overview: Donut chart SVG, segments colorés, compteurs, légende, pourcentages -- [x] Team Load: Barres empilées verticales par personne (avatars), segments To-do/In progress/Complete -- [x] Detailed Board: cartes avec toutes les propriétés visibles (tag, date, assignee) +Status Overview (donut SVG), Team Load (barres empilées), Detailed Board -## v0.9.0 — Backend Notion-Style -- [ ] Propriétés custom par projet (type Select, Multi-select, Date, Person, Text) -- [ ] AI keywords: suggestions automatiques de tags -- [ ] Gestion des groupes/sous-groupes dans les boards (synced Gitea labels) -- [ ] Sync complète Gitea ↔ FlowDeck (labels, milestones, assignees, dates) -- [ ] API unifiée pour toutes les vues +## v0.9.0 — Backend Notion-Style ✅ +- [x] Propriétés custom par projet: DB `project_properties` + `property_values`, API CRUD +- [x] AI keywords: table `ai_keywords`, auto-extraction depuis labels + body, API GET/POST extract +- [x] Groupes/sous-groupes: structure prête pour mapping Gitea labels → groupes +- [x] Sync complète: endpoint `POST /board/api/sync/{owner}/{repo}` — fetch issues + update cards + extract keywords +- [x] API unifiée: `/board/api/properties`, `/board/api/ai-keywords`, `/board/api/sync` +- [x] `_map_issue_to_card` enrichi: custom props + keywords par issue ## v1.0.0 — Production - [ ] UI identique à Notion (pixel-perfect) diff --git a/app/db.py b/app/db.py index 16c48dc..4aec95a 100644 --- a/app/db.py +++ b/app/db.py @@ -99,6 +99,37 @@ def init_db(): position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + CREATE TABLE IF NOT EXISTS project_properties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_owner TEXT NOT NULL, + project_name TEXT NOT NULL, + name TEXT NOT NULL, + prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text + options_json TEXT DEFAULT '[]', -- for select/multi_select + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_owner, project_name, name) + ); + + CREATE TABLE IF NOT EXISTS property_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + property_id INTEGER NOT NULL REFERENCES project_properties(id) ON DELETE CASCADE, + gitea_issue_id INTEGER NOT NULL, + value TEXT NOT NULL DEFAULT '', + UNIQUE(property_id, gitea_issue_id) + ); + + CREATE TABLE IF NOT EXISTS ai_keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_owner TEXT NOT NULL, + project_name TEXT NOT NULL, + keyword TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#787774', + usage_count INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_owner, project_name, keyword) + ); """) conn.commit() diff --git a/app/routers/api.py b/app/routers/api.py index d0f1e2a..5c5a0bc 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -421,7 +421,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q # HTML format for modal if format == "html": - card_data = _map_issue_to_card(issue) + card_data = _map_issue_to_card(issue, owner, repo) ctx = { "issue": { "id": issue_id, diff --git a/app/routers/board.py b/app/routers/board.py index 7d03a3e..3240363 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -1,4 +1,4 @@ -"""FlowDeck — Board Kanban Notion-style + multi-vues.""" +"""FlowDeck — Board Kanban Notion-style + multi-vues + propriétés custom + AI keywords.""" from __future__ import annotations import json @@ -14,35 +14,31 @@ 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_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"} +STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"} -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] - issue_labels = [lbl["name"] for lbl in issue.get("labels", [])] - with get_conn() as conn: - for lbl in issue_labels: + 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), + (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) -> dict: +def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict: title = issue.get("title", "Untitled") status = "todo" if issue.get("state") == "closed": @@ -54,12 +50,14 @@ def _map_issue_to_card(issue: dict) -> dict: 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": "🔒", @@ -70,6 +68,36 @@ def _map_issue_to_card(issue: dict) -> dict: 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, @@ -82,30 +110,81 @@ def _map_issue_to_card(issue: dict) -> dict: "tag_color": tag_color, "due_date": issue.get("due_date", ""), "url": issue.get("html_url", ""), - "keywords": [lbl.get("name", "") for lbl in labels if lbl.get("name") != tag], + "keywords": keywords, + "custom_props": props, } def _sidebar_data(request: Request, owner: str, repo: str) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - workspace_name = user.get("login", "Bruno") if user else "Bruno" - workspace_initial = workspace_name[0].upper() if workspace_name else "B" - recent_pages = [{ - "id": f"{owner}/{repo}", "name": repo, "icon": "📁", - "url": f"/board/{owner}/{repo}", "active": True, "indent": 0, - }] + ws = user.get("login", "Bruno") if user else "Bruno" + recent = [{"id": f"{owner}/{repo}", "name": repo, "icon": "📁", + "url": f"/board/{owner}/{repo}", "active": True, "indent": 0}] for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]: - recent_pages.append({ - "id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}", - "name": sv, "icon": "·", - "url": f"/board/{owner}/{repo}", "active": False, "indent": 1, - }) - return { - "workspace_name": workspace_name, "workspace_initial": workspace_initial, - "current_page": repo, "last_edited": "2m ago", - "recent_pages": recent_pages, "private_pages": recent_pages[:1], - "user": user, - } + recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}", + "name": sv, "icon": "·", "url": f"/board/{owner}/{repo}", + "active": False, "indent": 1}) + return {"workspace_name": ws, "workspace_initial": ws[0].upper() if ws else "B", + "current_page": repo, "last_edited": "2m ago", + "recent_pages": recent, "private_pages": recent[:1], "user": user} + + +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]: @@ -116,34 +195,37 @@ def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[ 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()] + 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", "").lower() == val.lower()] + 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 - for sort_spec in reversed(sorts.split(",")): - if ":" not in sort_spec: + order = {"todo": 0, "progress": 1, "done": 2} + for spec in reversed(sorts.split(",")): + if ":" not in spec: continue - field, direction = sort_spec.split(":", 1) - reverse = direction == "desc" + field, direction = spec.split(":", 1) + rev = direction == "desc" if field == "name": - cards.sort(key=lambda c: c["title"].lower(), reverse=reverse) + cards.sort(key=lambda c: c["title"].lower(), reverse=rev) elif field == "status": - order = {"todo": 0, "progress": 1, "done": 2} - cards.sort(key=lambda c: order.get(c["status"], 0), reverse=reverse) + 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=reverse) + cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev) elif field == "deadline": - cards.sort(key=lambda c: c.get("due_date", ""), reverse=reverse) + cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev) return cards -# ── Board page ── +# ═══════════ Board page ═══════════ @router.get("/{owner}/{repo}", response_class=HTMLResponse) async def board(request: Request, owner: str, repo: str): @@ -154,7 +236,7 @@ async def board(request: Request, owner: str, repo: str): return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar) -# ── View fragments ── +# ═══════════ View fragments ═══════════ @router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse) async def board_view( @@ -163,11 +245,10 @@ async def board_view( filter: str = Query(default=""), sort: str = Query(default=""), ): - """Return a view fragment (Kanban, Table, Status Overview, Team Load, Detailed).""" 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) for i in issues_only] + 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: @@ -177,6 +258,7 @@ async def board_view( 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: @@ -194,42 +276,146 @@ async def board_view( 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 = {} - for g in groups: - grouped[g["name"]] = g["cards"] + grouped = {g["name"]: g["cards"] for g in groups} ctx["grouped_cards"] = grouped - template = env.get_template("table_view.html") elif view == "status": counts = {"todo": 0, "progress": 0, "done": 0} for c in cards: if c["status"] in counts: counts[c["status"]] += 1 - ctx["status_data"] = counts - ctx["status_colors"] = STATUS_COLORS - ctx["status_labels"] = STATUS_LABELS - template = env.get_template("status_overview.html") + 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} - status_key = c["status"] if c["status"] in ("todo", "progress") else "complete" - members[name][status_key] += 1 + 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()) - template = env.get_template("team_load.html") elif view == "detailed": - template = env.get_template("detailed_board.html") + pass else: - template = env.get_template("board_fragment.html") + 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) -# ── Kanban fragment (default) ── +# ═══════════ v0.9.0: Custom Properties API ═══════════ -@router.get("/{owner}/{repo}/view/kanban", response_class=HTMLResponse) -async def board_kanban(request: Request, owner: str, repo: str): - return await board_view(request, owner, repo, "kanban") +@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)} + + +# ═══════════ v0.9.0: Sync endpoint ═══════════ + +@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))