feat: v0.9.0 — Backend Notion-Style (propriétés custom, AI keywords, sync)
CI / test (push) Failing after 3s
CI / lint (push) Failing after 3s

DB:
- project_properties: propriétés custom par projet (select, multi_select, date, person, text)
- property_values: valeurs par issue (UNIQUE property+issue)
- ai_keywords: mots-clés extraits automatiquement (keyword, color, usage_count)

Backend:
- /board/api/properties/{o}/{r} — CRUD propriétés custom
- /board/api/ai-keywords/{o}/{r} — GET keywords list
- /board/api/ai-keywords/{o}/{r}/extract — POST ré-extraction
- /board/api/sync/{o}/{r} — POST sync bidirectionnelle complète
- _map_issue_to_card enrichi: custom_props + keywords par issue
- _extract_ai_keywords: extraction depuis labels + body texte

Documentation:
- ROADMAP v0.9.0 ✅ (5/5 checkboxes)
This commit is contained in:
2026-07-08 14:34:32 -04:00
parent 751f13869a
commit 281a8055fc
4 changed files with 297 additions and 100 deletions
+12 -32
View File
@@ -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)
+31
View File
@@ -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()
+1 -1
View File
@@ -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,
+253 -67
View File
@@ -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))