diff --git a/app/main.py b/app/main.py index fdb8c74..0fbe265 100644 --- a/app/main.py +++ b/app/main.py @@ -34,6 +34,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.config import APP_VERSION, get_settings from app.gitea_client import GiteaClient +from app import digest as digest_service from app.briefing import format_briefing_text, ntfy_configured, send_ntfy from app.capture import ntfy_capture_loop, parse_capture_text from app.models import ( @@ -140,6 +141,7 @@ async def lifespan(app: FastAPI): "GITEA_TOKEN is not set: only public Gitea content will be visible." ) scheduler = asyncio.create_task(_briefing_scheduler()) + digest_task = asyncio.create_task(_digest_scheduler()) capture_task = None if settings.capture_ntfy_topic and settings.ntfy_url: async def _capture_create(title: str, body: str) -> None: @@ -162,6 +164,7 @@ async def lifespan(app: FastAPI): ) yield scheduler.cancel() + digest_task.cancel() if capture_task is not None: capture_task.cancel() await gitea.aclose() @@ -1176,6 +1179,199 @@ async def capture_submit( ) +async def _weekly_digest() -> dict: + """Compose the weekly digest (journal of the last 7 days + focus).""" + now = datetime.now(timezone.utc) + since = (now - timedelta(days=7)).replace(tzinfo=None) + events = [] + for event in get_events(limit=1000): + at = _parse_datetime(str(event.get("created_at") or "")) + if at is not None and at.replace(tzinfo=None) >= since: + events.append(event) + + closed_keys: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + for event in events: + if event["event_type"] == "closed": + key = (event["repo_full_name"], event["issue_number"]) + if key not in seen: + seen.add(key) + closed_keys.append(key) + + titles: dict[tuple[str, int], dict] = {} + for repo_name in {repo for repo, _ in closed_keys}: + owner, _, name = repo_name.partition("/") + try: + items = await gitea.get_repo_issues(owner, name, state="all") + except Exception: + continue + for item in items: + if _is_issue(item): + titles[(repo_name, item["number"])] = item + closed_items = [ + { + "repo": repo, + "number": number, + "title": (titles.get((repo, number)) or {}).get("title", ""), + "html_url": (titles.get((repo, number)) or {}).get("html_url", ""), + } + for repo, number in closed_keys + ] + + focus_groups = [] + for group in await _collect_focus_groups(): + focus_groups.append( + { + "full_name": group["full_name"], + "cards": [ + { + "issue": card["issue"], + "column": COLUMN_LABELS[card["column"]], + } + for card in group["cards"] + ], + } + ) + + week_label = ( + f"{(now - timedelta(days=6)).strftime('%Y-%m-%d')}" + f" au {now.strftime('%Y-%m-%d')}" + ) + markdown = digest_service.format_digest_markdown( + week_label, + closed_items, + created_count=sum(1 for e in events if e["event_type"] == "created"), + moved_count=sum(1 for e in events if e["event_type"] == "moved"), + focus_groups=focus_groups, + ) + return { + "title": f"Kanbante — semaine du {week_label}", + "week": now.strftime("%G-W%V"), + "markdown": markdown, + "closed_count": len(closed_items), + } + + +def _render_digest(request: Request, banner: str | None = None, + tools: list[str] | None = None): + settings = get_settings() + return render( + request, + "digest.html", + pending=get_kv("digest_pending"), + last_result=get_kv("digest_last_result"), + obsigate_configured=bool(settings.obsigate_mcp_url), + obsigate_url=settings.obsigate_mcp_url, + obsigate_tool=settings.obsigate_mcp_tool, + webhook_configured=bool(settings.digest_webhook_url), + digest_time=settings.digest_time, + digest_weekday=settings.digest_weekday, + banner=banner, + tools=tools, + ) + + +@app.get("/digest", response_class=HTMLResponse) +async def digest_page(request: Request): + """Weekly digest: preview, approval and delivery status.""" + return _render_digest(request) + + +@app.post("/digest/probe", response_class=HTMLResponse) +async def digest_probe(request: Request, csrf_token: str = Form("")): + """List the tools of the configured ObsiGate MCP server.""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + settings = get_settings() + if not settings.obsigate_mcp_url: + return _render_digest(request, banner="❌ Aucun serveur MCP ObsiGate configuré (KANBANTE_OBSIGATE_MCP_URL).") + ok, result = await digest_service.mcp_list_tools( + settings.obsigate_mcp_url, settings.obsigate_mcp_token + ) + if ok: + return _render_digest( + request, + banner=f"✅ Connexion MCP réussie — {len(result)} outil(s) disponible(s).", + tools=result, + ) + return _render_digest(request, banner=f"❌ {result}") + + +@app.post("/digest/send", response_class=HTMLResponse) +async def digest_send( + request: Request, + target: str = Form(...), + csrf_token: str = Form(""), +): + """Deliver the digest — only ever from this explicit approval.""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + settings = get_settings() + digest = await _weekly_digest() + if target == "webhook": + if not settings.digest_webhook_url: + return _render_digest(request, banner="❌ Webhook non configuré (KANBANTE_DIGEST_WEBHOOK_URL).") + ok, message = await digest_service.send_webhook( + settings.digest_webhook_url, + digest["title"], digest["markdown"], digest["week"], + ) + elif target == "obsigate": + if not settings.obsigate_mcp_url or not settings.obsigate_mcp_tool: + return _render_digest(request, banner="❌ ObsiGate incomplet : KANBANTE_OBSIGATE_MCP_URL et KANBANTE_OBSIGATE_MCP_TOOL sont requis (utilise « Tester la connexion » pour voir les outils).") + try: + arguments = digest_service.render_mcp_args( + settings.obsigate_mcp_args, digest["title"], digest["markdown"] + ) + except ValueError as exc: + return _render_digest(request, banner=f"❌ {exc}") + ok, message = await digest_service.mcp_call_tool( + settings.obsigate_mcp_url, settings.obsigate_mcp_token, + settings.obsigate_mcp_tool, arguments, + ) + else: + return _render_digest(request, banner="❌ Cible inconnue.") + stamp = datetime.now().strftime("%Y-%m-%d %H:%M") + set_kv("digest_last_result", f"{stamp} · {target} · {message}") + if ok: + set_kv("digest_pending", "") + return _render_digest(request, banner=f"✅ Digest envoyé ({target}) : {message}") + return _render_digest(request, banner=f"❌ Échec de l'envoi ({target}) : {message}") + + +async def _digest_scheduler() -> None: + """Compose the weekly digest and notify — never deliver it. + + Delivery to ObsiGate is a cross-app write: it waits for the + one-click approval on /digest (standing rule). + """ + while True: + try: + settings = get_settings() + if settings.digest_time: + now = datetime.now() + week = now.strftime("%G-W%V") + if ( + now.weekday() == settings.digest_weekday + and now.strftime("%H:%M") >= settings.digest_time + and get_kv("digest_pending_week") != week + ): + digest = await _weekly_digest() + set_kv("digest_pending", digest["markdown"]) + set_kv("digest_pending_week", week) + logger.info("Weekly digest composed for %s", week) + if ntfy_configured(settings): + await send_ntfy( + settings, + "🧠 Digest hebdo Kanbante prêt — " + "en attente d'approbation sur la page /digest.", + ) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("Digest scheduler error: %s", exc) + await asyncio.sleep(60) + + @app.get("/api/stream") async def live_stream(request: Request): """Server-Sent Events: browsers reload softly on Gitea activity.""" @@ -1806,6 +2002,59 @@ async def api_stats(): return get_stats() +@app.get("/api/palette") +async def api_palette(q: str = ""): + """Data for the Ctrl+K command palette (roadmap #10). + + Projects are always returned (the palette filters them locally); + issues are searched globally once the query has 2+ characters, + annotated with their current board column. + """ + repos = await gitea.search_repos() + active, inactive, _archived = classify_repos(repos) + projects = [ + {"full_name": r["full_name"]} for r in (active + inactive)[:60] + ] + issues: list[dict] = [] + query = q.strip() + if len(query) >= 2: + try: + found = await gitea.search_issues(query, state="all") + except Exception as exc: + logger.debug("palette search failed: %s", exc) + found = [] + placements: dict[str, dict[int, str]] = {} + for item in found[:40]: + if not _is_issue(item): + continue + full = (item.get("repository") or {}).get("full_name", "") + if not full: + continue + if full not in placements: + placements[full] = { + card["issue_number"]: col + for col, cards in get_board_state(full).items() + for card in cards + } + if item.get("state") == "closed": + column = "done" + else: + column = placements[full].get(item["number"], "backlog") + issues.append( + { + "repo": full, + "number": item["number"], + "title": item.get("title", ""), + "state": item.get("state", "open"), + "column": column, + "column_label": COLUMN_LABELS[column], + } + ) + if len(issues) >= 8: + break + return {"projects": projects, "issues": issues} + + @app.get("/api/projects") async def api_projects(): """Get all projects (JSON), classified like the dashboard.""" diff --git a/app/templates/_palette.html b/app/templates/_palette.html new file mode 100644 index 0000000..73036dc --- /dev/null +++ b/app/templates/_palette.html @@ -0,0 +1,12 @@ + diff --git a/app/templates/activity.html b/app/templates/activity.html index c212e68..498d2b9 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -21,7 +21,9 @@ 📊 Briefing 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -59,5 +61,7 @@ Kanbante v{{ version }} · journal d'événements +{% include "_palette.html" %} + diff --git a/app/templates/board.html b/app/templates/board.html index 9836b7b..cda4938 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -24,7 +24,9 @@ 📰 Activité 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + {% if repo_info.html_url %} 🔗 Gitea ↗ {% endif %} @@ -205,5 +207,7 @@ {% endif %} +{% include "_palette.html" %} + diff --git a/app/templates/briefing.html b/app/templates/briefing.html index fcb491b..1cc3f71 100644 --- a/app/templates/briefing.html +++ b/app/templates/briefing.html @@ -21,7 +21,9 @@ 📰 Activité 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -69,5 +71,7 @@ status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status)); }); +{% include "_palette.html" %} + diff --git a/app/templates/capture.html b/app/templates/capture.html index 621eb14..2b1d1d3 100644 --- a/app/templates/capture.html +++ b/app/templates/capture.html @@ -18,7 +18,9 @@ 📊 Briefing 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -96,5 +98,7 @@ Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy +{% include "_palette.html" %} + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 4600e80..9332110 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -21,7 +21,9 @@ 📊 Briefing 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -134,5 +136,7 @@ } }); +{% include "_palette.html" %} + diff --git a/app/templates/focus.html b/app/templates/focus.html index 49d9073..67ca97f 100644 --- a/app/templates/focus.html +++ b/app/templates/focus.html @@ -21,7 +21,9 @@ 📊 Briefing 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + {{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours @@ -70,5 +72,7 @@ Kanbante v{{ version }} · vue Focus +{% include "_palette.html" %} + diff --git a/app/templates/my.html b/app/templates/my.html index d760ae8..d8395f4 100644 --- a/app/templates/my.html +++ b/app/templates/my.html @@ -20,7 +20,9 @@ 📰 Activité 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -61,5 +63,7 @@ Kanbante v{{ version }} · mes issues +{% include "_palette.html" %} + diff --git a/app/templates/search.html b/app/templates/search.html index 1d5b98b..8947216 100644 --- a/app/templates/search.html +++ b/app/templates/search.html @@ -20,7 +20,9 @@ 📰 Activité 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -67,5 +69,7 @@ Kanbante v{{ version }} · recherche globale +{% include "_palette.html" %} + diff --git a/static/css/style.css b/static/css/style.css index 67d4c65..5247195 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -980,3 +980,49 @@ footer { background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5; } + +/* ── Palette de commandes (0.8.0, #10) ── */ +.palette-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(5, 10, 20, 0.62); + backdrop-filter: blur(3px); + display: flex; justify-content: center; align-items: flex-start; + padding: 10vh 16px 16px; +} +.palette-overlay[hidden] { display: none; } +.palette { + width: min(640px, 100%); + background: var(--surface-1, #131a29); + border: 1px solid var(--border-strong, #2c3a52); + border-radius: 16px; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); + overflow: hidden; + animation: wall-in 0.15s ease; +} +.palette-input-row { + display: flex; align-items: center; gap: 12px; + padding: 14px 16px; border-bottom: 1px solid var(--border, #22304a); +} +.palette-input-row input { + flex: 1; background: transparent; border: none; outline: none; + color: var(--text-1); font: inherit; font-size: 1.05rem; +} +.palette-input-row kbd { + background: var(--surface-3, #1d2942); border: 1px solid var(--border, #22304a); + border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; color: var(--text-3); +} +.palette-results { list-style: none; margin: 0; padding: 8px; max-height: 46vh; overflow-y: auto; } +.palette-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border-radius: 10px; cursor: pointer; +} +.palette-item.active { background: var(--surface-3, #1d2942); } +.palette-item-icon { font-size: 1.05rem; width: 24px; text-align: center; } +.palette-item-main { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette-item-sub { color: var(--text-3); font-size: 0.8rem; white-space: nowrap; } +.palette-empty { padding: 18px 14px; color: var(--text-3); text-align: center; } +.palette-hint { + padding: 9px 16px; border-top: 1px solid var(--border, #22304a); + color: var(--text-3); font-size: 0.78rem; text-align: right; +} +.palette-trigger { cursor: pointer; font: inherit; } diff --git a/static/js/palette.js b/static/js/palette.js new file mode 100644 index 0000000..f0241b3 --- /dev/null +++ b/static/js/palette.js @@ -0,0 +1,178 @@ +/** + * Kanbante — Command palette (Ctrl+K), roadmap #10. + * + * One overlay for the whole app: static actions, every project, and + * global issue search (via /api/palette). Fully keyboard driven. + */ +(function () { + var overlay = document.getElementById('palette-overlay'); + if (!overlay) return; + var input = document.getElementById('palette-input'); + var list = document.getElementById('palette-results'); + + var ACTIONS = [ + { icon: '🏠', label: 'Projets — dashboard', href: '/' }, + { icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' }, + { icon: '👤', label: 'Mes issues', href: '/my' }, + { icon: '📰', label: 'Activité — journal des événements', href: '/activity' }, + { icon: '📊', label: 'Briefing quotidien', href: '/briefing' }, + { icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' }, + { icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' }, + { icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' }, + { icon: '🔍', label: 'Recherche avancée', href: '/search' }, + ]; + + var projects = null; // cached after first fetch + var issues = []; // latest search results for the query + var items = []; // rendered items + var activeIndex = 0; + var debounce = null; + var lastQuery = ''; + + function open() { + overlay.hidden = false; + document.body.style.overflow = 'hidden'; + input.value = ''; + issues = []; + lastQuery = ''; + render(''); + input.focus(); + if (projects === null) { + fetch('/api/palette') + .then(function (r) { return r.json(); }) + .then(function (data) { + projects = data.projects || []; + render(input.value.trim()); + }) + .catch(function () { projects = []; }); + } + } + + function close() { + overlay.hidden = true; + document.body.style.overflow = ''; + } + + function buildItems(query) { + var q = query.toLowerCase(); + var out = []; + ACTIONS.forEach(function (a) { + if (!q || a.label.toLowerCase().includes(q)) { + out.push({ icon: a.icon, label: a.label, sub: 'Action', href: a.href }); + } + }); + (projects || []).forEach(function (p) { + if (out.length >= 14) return; + if (!q || p.full_name.toLowerCase().includes(q)) { + out.push({ + icon: '📂', label: p.full_name, sub: 'Projet · ouvrir le board', + href: '/board/' + p.full_name, + }); + } + }); + issues.forEach(function (i) { + out.push({ + icon: '🐛', + label: '#' + i.number + ' · ' + i.title, + sub: i.repo + ' · ' + i.column_label + (i.state === 'closed' ? ' · fermée' : ''), + href: '/board/' + i.repo, + }); + }); + return out.slice(0, 14); + } + + function render(query) { + items = buildItems(query); + activeIndex = Math.min(activeIndex, Math.max(0, items.length - 1)); + list.innerHTML = ''; + if (!items.length) { + var empty = document.createElement('li'); + empty.className = 'palette-empty'; + empty.textContent = 'Aucun résultat — essaie un nom de projet ou un mot du titre d\'une issue.'; + list.appendChild(empty); + return; + } + items.forEach(function (item, idx) { + var li = document.createElement('li'); + li.className = 'palette-item' + (idx === activeIndex ? ' active' : ''); + var icon = document.createElement('span'); + icon.className = 'palette-item-icon'; + icon.textContent = item.icon; + var main = document.createElement('span'); + main.className = 'palette-item-main'; + main.textContent = item.label; + var sub = document.createElement('span'); + sub.className = 'palette-item-sub'; + sub.textContent = item.sub; + li.appendChild(icon); + li.appendChild(main); + li.appendChild(sub); + li.addEventListener('click', function () { go(item); }); + li.addEventListener('mousemove', function () { + if (activeIndex !== idx) { activeIndex = idx; render(input.value.trim()); } + }); + list.appendChild(li); + }); + } + + function go(item) { + if (item) window.location.href = item.href; + } + + function searchIssues(query) { + if (query.length < 2) { issues = []; render(query); return; } + fetch('/api/palette?q=' + encodeURIComponent(query)) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (input.value.trim() !== query) return; // stale response + issues = data.issues || []; + if (projects === null) projects = data.projects || []; + render(query); + }) + .catch(function () { /* keep previous results */ }); + } + + input.addEventListener('input', function () { + var query = input.value.trim(); + activeIndex = 0; + render(query); + if (query !== lastQuery) { + lastQuery = query; + clearTimeout(debounce); + debounce = setTimeout(function () { searchIssues(query); }, 180); + } + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeIndex = Math.min(activeIndex + 1, items.length - 1); + render(input.value.trim()); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeIndex = Math.max(activeIndex - 1, 0); + render(input.value.trim()); + } else if (e.key === 'Enter') { + e.preventDefault(); + go(items[activeIndex]); + } + }); + + overlay.addEventListener('click', function (e) { + if (e.target === overlay) close(); + }); + + document.addEventListener('keydown', function (e) { + var combo = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k'; + if (combo) { + e.preventDefault(); + if (overlay.hidden) open(); else close(); + } else if (e.key === 'Escape' && !overlay.hidden) { + close(); + } + }); + + document.querySelectorAll('.palette-trigger').forEach(function (btn) { + btn.addEventListener('click', function () { open(); }); + }); +})();