diff --git a/.env.example b/.env.example
index 374620f..0dc0445 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,10 @@
# ── Gitea ──
GITEA_URL=https://git.dracodev.net
GITEA_TOKEN=change-me
+# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
+# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
+# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
+# invalide (authentification OAuth impossible).
GITEA_OAUTH_CLIENT_ID=
GITEA_OAUTH_CLIENT_SECRET=
GITEA_WEBHOOK_SECRET=
@@ -82,3 +86,15 @@ APP_BASE_URL=http://localhost:8080
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
# SSO_DEFAULT_WORKSPACE_ID=0
+
+# ── Web tools de l'agent (v7.46.0) ──
+# Skills « recherche-marche », « veille-techno », « debug-web ».
+# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
+# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
+# search_code → GitHub public (10 req/min sans jeton, 30 avec)
+# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
+# compte mais parsé au HTML) : utilisable, moins fiable.
+WEB_SEARCH_PROVIDER=exa
+EXA_API_KEY=
+GITHUB_TOKEN=
+WEB_FETCH_MAX_CHARS=20000
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a5deb3..e301b7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,571 @@
# Changelog - FlowDeck
+## v7.49.0 (2026-10-05) — Side peek : l'éditeur plein écran, et un calendrier complet
+
+### Added
+
+- **Calendrier My Tasks : cinq vues** — Mois, Année, Semaine, Jour et Planning,
+ avec une navigation propre à chaque granularité (`←` / `→` et « Aujourd'hui »
+ se recalculent : par exemple 5 – 11 octobre → 12 – 18 octobre en semaine, ou
+ un jour en année). L'Année affiche les douze mini-mois de l'année courante et
+ le Planning une ligne par jour avec ses tâches réparties par statut.
+- **Icône d'ouverture sur chaque tâche du calendrier** — même bouton « Open in
+ side peek » que le tableau, révélé au survol. Elle porte le même attribut de
+ ligne (`data-open-row`), donc un seul gestionnaire de clic pour toutes les vues :
+ le titre est transmis par `data-open-title`, disponible là où il n'y a pas de
+ ligne `.mt-row` (vues Jour, Semaine et Année).
+
+### Fixed
+
+- **Impossible d'ajuster la largeur du side peek des bases** — le panneau avait
+ été réécrit en bindings Alpine (`:class="dbPeekOpen"`, `@click.outside`) alors
+ que le conteneur n'a aucun `x-data` : bindings inertes, `openPageInSidePeek`
+ passait par `container.__x.$data` (undefined), le panneau ne s'ouvrait pas et
+ la poignée de redimensionnement ne réagissait pas. Repassé en vanilla JS
+ (câblage direct sur le document, repli discret pour les collections embarquées
+ inline). Au passage : les quatre implémentations dupliquées du drag de
+ largeur (Library, workspace local, My Tasks, bases) sont remplacées par un
+ helper unique `window.fdWirePeekResize` (`app.js`) — pointer capture, borne
+ 300 px–90 vw, clic = fermer, largeur persistée (`fd_peek_width`) et commune
+ à tous les peeks, restaurée au chargement. My Tasks ne capturait ni le
+ pointeur ni la largeur : son drag était erratique et non mémorisé.
+- **Largeur des pages standardisée sur celle de Library (1100 px)** — le
+ conteneur des bases (`.database-table-container`) passe à `margin: 0` : le
+ tableau est collé à gauche, au ras de la marge, au lieu d'être centré dans la
+ page. `/trash` utilisait la classe `.lib-container` dont la règle vivait dans
+ le `
-{sections}
-"""
+def _public_source(s: dict) -> dict:
+ return {
+ "id": s["id"],
+ "name": s["name"],
+ "icon": s["icon"],
+ "workspace_id": s["workspace_id"],
+ "workspace_name": s.get("workspace_name") or "",
+ "configured": s["configured"],
+ "missing_roles": s["missing_roles"],
+ "status_options": s["status_options"],
+ }
+
+def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
+ hide_done: bool) -> dict:
+ """Charge et normalise tout ce dont les trois vues ont besoin."""
+ user = _get_current_user(request) or {}
+ tasks = collect_tasks(conn, user_id, mine_only=True,
+ login=user.get("login", ""))
+ # Même jour de référence que `due_state` (fuseau de l'utilisateur).
+ tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
+ today=user_today(conn, user_id))
+ sources = list_task_sources(conn, user_id)
+ return {
+ "tasks": tasks,
+ "total": len(tasks),
+ "sources": [_public_source(s) for s in sources],
+ "sources_total": len(sources),
+ "sources_configured": sum(1 for s in sources if s["configured"]),
+ "max_sources": MAX_TASK_SOURCES,
+ "filters": {"due": due, "sort": sort, "hide_done": hide_done},
+ }
+
+
+# ── Rendu HTML ───────────────────────────────────────────────────────────
+
+def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
+ """Enveloppe le contenu dans le layout commun (sidebar + base.html).
+
+ `static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
+ : un `` placé dans la zone swappée pouvait être retiré par htmx lors
+ d'une navigation partielle, et la page revenait sans aucun style. Seul
+ `static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
+ connectée, il n'y a rien à monter côté client.
+ """
from app.routers.dashboard import _sidebar_data
- env = ENV
+
sidebar = _sidebar_data(request, [])
- block_tpl = env.from_string(
- '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
+ # `assets` est rendu par un mini-template : `asset_version` (cache-busting)
+ # n'est pas substitutionné si on le concatène à la main.
+ assets = ENV.from_string(
+ "{% if app_js %}"
+ ''
+ "{% endif %}"
+ ).render(app_js=app_js)
+
+ block_tpl = ENV.from_string(
+ '{% extends "base.html" %}'
+ "{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
+ assets=assets,
page_title="My Tasks",
title_prefix="My Tasks",
page_icon="📋",
)
-@router.get("/api")
-def my_tasks_api(request: Request, view: str = "all", days: int = 7):
- """API: return my tasks as JSON."""
+def _json_attr(obj) -> str:
+ """JSON échappé pour un attribut HTML."""
+ return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
+
+
+def _render_app(data: dict, view: str) -> str:
+ """Coquille du tableau de bord : barre d'outils, filtres et zone de vue
+ sont rendus côté client à partir de la configuration ci-dessous."""
+ config = {
+ "view": view,
+ "filters": data["filters"],
+ "sources": data["sources"],
+ "maxSources": data["max_sources"],
+ "views": list(VIEWS),
+ }
+ return ('
')
+
+
+def _render_empty(data: dict) -> str:
+ """État vide « aucune source » : explique la conversion en base de tâches.
+
+ Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
+ style inline : la mise en page suit le thème clair/sombre et reste
+ modifiable en un seul endroit. Les autres états vides (filtres actifs,
+ rien à faire) sont rendus par le client, qui connaît l'état des filtres.
+ """
+ return f"""
+
📋
+
My Tasks
+
Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :
"""
+
+
+
+# ── Routes ───────────────────────────────────────────────────────────────
+
+@router.get("", response_class=HTMLResponse)
+def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
+ sort: str = "due", hide_done: bool = False):
+ """My Tasks — toutes vos tâches, tous workspaces confondus."""
user = _get_current_user(request)
- user.get("login", "admin") if user else "admin"
+ if not user or not user.get("id"):
+ return RedirectResponse("/auth/login?provider=local", status_code=302)
+
+ view, due, sort = _validated(view, due, sort)
+ with get_conn() as conn:
+ if not _workspace_names(conn, user["id"]):
+ return _render_shell(request, _render_no_workspace(), app_js=False)
+ data = _payload(conn, request, user["id"], due=due, sort=sort,
+ hide_done=hide_done)
+
+ # Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
+ # en charge par le client (3 vues + états vides contextualisés). Sans
+ # aucune source, le serveur affiche l'invite à convertir une base — le
+ # fichier JS n'est alors pas chargé.
+ if data["total"] or data["sources_total"]:
+ return _render_shell(request, _render_app(data, view))
+ return _render_shell(request, _render_empty(data), app_js=False)
+
+
+@router.get("/api")
+def my_tasks_api(request: Request, view: str = "table", due: str = "all",
+ sort: str = "due", hide_done: bool = False):
+ """API: tâches normalisées + sources connectées (alimente les 3 vues)."""
+ user = _require_user(request)
+ view, due, sort = _validated(view, due, sort)
+ with get_conn() as conn:
+ return _payload(conn, request, user["id"], due=due, sort=sort,
+ hide_done=hide_done)
+
+
+@router.post("/api/task")
+def my_tasks_create(request: Request, body: dict = Body(default={})):
+ """Création rapide multi-destinations : la tâche part dans la base choisie.
+
+ Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
+ tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
+ illisible pour l'utilisateur qui vient de la créer.
+ """
+ user = _require_user(request)
+ collection_id = body.get("collection_id")
+ title = str(body.get("title") or "").strip()
+ if not collection_id:
+ raise HTTPException(status_code=400, detail="collection_id is required")
+ if not title:
+ raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
- collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
- result = {}
+ coll = conn.execute(
+ "SELECT id, task_assignee_prop, task_due_prop FROM collections "
+ "WHERE id=? AND is_task=1",
+ (collection_id,),
+ ).fetchone()
+ if coll is None:
+ raise HTTPException(status_code=400,
+ detail="This database does not feed My Tasks")
- for col in collections:
- pages = conn.execute(
- "SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
- (col["id"],),
+ props_meta = [
+ dict(p) for p in conn.execute(
+ "SELECT id, name, prop_type FROM collection_properties "
+ "WHERE collection_id=?",
+ (collection_id,),
).fetchall()
+ ]
- tasks = []
- for p in pages:
- props = json.loads(p["property_values_json"])
- tasks.append({
- "id": p["id"], "title": p["title"], "icon": p["icon"],
- "properties": props,
- })
+ properties: dict = {}
+ title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
+ if title_prop:
+ properties[str(title_prop["id"])] = title
- if tasks:
- result[col["name"]] = tasks
+ if body.get("due") and coll["task_due_prop"]:
+ properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
- return {"tasks": result, "total": sum(len(t) for t in result.values())}
+ if coll["task_assignee_prop"]:
+ member = conn.execute(
+ "SELECT id, login, full_name, avatar_url, avatar_color "
+ "FROM users WHERE id=?",
+ (user["id"],),
+ ).fetchone()
+ if member:
+ properties[str(coll["task_assignee_prop"])] = [{
+ "id": member["id"],
+ "login": member["login"],
+ "full_name": member["full_name"],
+ "avatar_url": member["avatar_url"] or "",
+ "avatar_color": member["avatar_color"] or "",
+ }]
+
+ apply_auto_properties(props_meta, properties, user, is_create=True)
+
+ nxt = conn.execute(
+ "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
+ "WHERE collection_id=?",
+ (collection_id,),
+ ).fetchone()[0]
+ cur = conn.execute(
+ """INSERT INTO collection_pages
+ (collection_id, title, icon, position, property_values_json)
+ VALUES (?, ?, '📄', ?, ?)""",
+ (collection_id, title, nxt, json.dumps(properties)),
+ )
+ page_id = cur.lastrowid
+ conn.commit()
+
+ if title_prop:
+ from app.services.row_pages import sync_row_title_to_page
+
+ sync_row_title_to_page(conn, page_id)
+ conn.commit()
+
+ logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
+ return {"status": "created", "id": page_id, "collection_id": collection_id,
+ "title": title}
+
+
+@router.put("/api/task/{page_id}")
+def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
+ """Édition en direct du statut / de l'échéance / du titre.
+
+ L'écriture passe par le **mapping enregistré de la base source** (et non
+ par une devinette « première colonne de type X »), puis la tâche est relue
+ et renvoyée normalisée : le front n'a rien à recalculer.
+ """
+ user = _require_user(request)
+ field = body.get("field")
+ if field not in ("status", "due", "title", "assignee"):
+ raise HTTPException(status_code=400, detail="Unsupported field")
+
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id, collection_id, title, property_values_json "
+ "FROM collection_pages WHERE id=?",
+ (page_id,),
+ ).fetchone()
+ if row is None:
+ raise HTTPException(status_code=404, detail="Task not found")
+
+ coll = conn.execute(
+ "SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
+ "FROM collections WHERE id=?",
+ (row["collection_id"],),
+ ).fetchone()
+ if coll is None:
+ raise HTTPException(status_code=404, detail="Source not found")
+ if not coll["is_task"]:
+ raise HTTPException(status_code=400,
+ detail="This database no longer feeds My Tasks")
+
+ column = {"status": coll["task_status_prop"],
+ "due": coll["task_due_prop"],
+ "assignee": coll["task_assignee_prop"]}.get(field)
+ if field != "title" and not column:
+ raise HTTPException(status_code=400,
+ detail=f"This database has no {field} column")
+
+ try:
+ pv = json.loads(row["property_values_json"] or "{}")
+ except (json.JSONDecodeError, TypeError):
+ pv = {}
+ if not isinstance(pv, dict):
+ pv = {}
+
+ new_title = None
+ if field == "title":
+ new_title = str(body.get("value") or "").strip()
+ title_prop = conn.execute(
+ "SELECT id FROM collection_properties "
+ "WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
+ (coll["id"],),
+ ).fetchone()
+ if title_prop:
+ pv[str(title_prop["id"])] = new_title
+ elif field == "assignee":
+ # La valeur est une liste de personnes : elle est écrite telle
+ # quelle (booleenner écraserait la colonne).
+ pv[str(column)] = body.get("value") or []
+ elif field == "due":
+ pv[str(column)] = str(body.get("value") or "")[:10]
+ else:
+ pv[str(column)] = str(body.get("value") or "")
+
+ if new_title is not None:
+ conn.execute(
+ "UPDATE collection_pages SET title=?, property_values_json=?, "
+ "updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (new_title, json.dumps(pv), page_id),
+ )
+ from app.services.row_pages import sync_row_title_to_page
+
+ sync_row_title_to_page(conn, page_id)
+ else:
+ conn.execute(
+ "UPDATE collection_pages SET property_values_json=?, "
+ "updated_at=CURRENT_TIMESTAMP WHERE id=?",
+ (json.dumps(pv), page_id),
+ )
+ conn.commit()
+
+ tasks = collect_tasks(conn, user["id"], mine_only=True,
+ login=user.get("login", ""))
+ updated = next((t for t in tasks if t["id"] == page_id), None)
+
+ if updated is None:
+ # La tâche vient de cesser d'être « mienne » (désassignée) : le front
+ # doit la retirer, pas la re-rendre à l'identique.
+ return {"status": "updated", "task": None, "left_mytasks": True}
+
+ return {"status": "updated", "task": updated, "left_mytasks": False}
diff --git a/app/routers/workspace.py b/app/routers/workspace.py
index 5216fe9..3502d45 100644
--- a/app/routers/workspace.py
+++ b/app/routers/workspace.py
@@ -8,7 +8,7 @@ import json
import logging
import sqlite3
-from fastapi import APIRouter, Body, HTTPException, Request
+from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.auth.session import SessionManager
@@ -16,14 +16,32 @@ from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
logger = logging.getLogger(__name__)
-router = APIRouter(tags=["workspace"], prefix="/workspace")
-
ROLES = ["admin", "editor", "commenter", "viewer"]
+def _require_session(request: Request) -> dict:
+ """A3/A4 — session obligatoire sur TOUT le router `/workspace`.
+
+ Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
+ {id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
+ anonyme, et les POST créaient des données au nom de l'utilisateur 1.
+ La seule route publique (`/workspace/public/...`) vit désormais dans
+ ``public_router``, sans dépendance.
+ """
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user or not user.get("id"):
+ raise HTTPException(401, "Authentication required")
+ return user
+
+
+router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
+# Route de partage public : AUCUNE dépendance d'authentification.
+public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
+
+
def _current_user(request: Request) -> dict:
- s = request.cookies.get("flowdeck_session", "")
- return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
+ """Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
+ return _require_session(request)
def _require_admin(request: Request) -> dict:
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
@router.get("/favorites")
def list_favorites(request: Request):
- user = _current_user(request)
- uid = user.get("id", 1)
+ """List the caller's favorites.
+
+ v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
+ ``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
+ référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
+ levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
+ """
+ user = _require_session(request)
+ uid = user["id"]
with get_conn() as conn:
rows = conn.execute(
- """SELECT f.*, cp.title as page_title, c.name as collection_name
+ """SELECT f.*, p.title AS page_title
FROM favorites f
- LEFT JOIN collection_pages cp ON f.page_id=cp.id
- LEFT JOIN collections c ON f.collection_id=c.id
+ LEFT JOIN pages p ON f.page_id=p.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
+
@router.post("/favorites")
def add_favorite(request: Request, body: dict = Body(default={})):
- user = _current_user(request)
- uid = user.get("id", 1)
+ user = _require_session(request)
+ uid = user["id"]
page_id = body.get("page_id")
- collection_id = body.get("collection_id")
+ if not page_id:
+ raise HTTPException(400, "page_id required")
with get_conn() as conn:
- conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
- "INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
- (uid, page_id, collection_id),
+ "INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
+ (uid, page_id),
)
conn.commit()
try:
- run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
+ run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "favorited"}
+
@router.delete("/favorites/{fav_id}")
def remove_favorite(request: Request, fav_id: int):
+ user = _require_session(request)
with get_conn() as conn:
- conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
+ cur = conn.execute(
+ "DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
+ )
conn.commit()
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Favorite not found")
return {"status": "removed"}
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ──
-@router.get("/public/{collection_id}")
+@public_router.get("/public/{collection_id}")
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.
diff --git a/app/services/calendar_sync.py b/app/services/calendar_sync.py
index 3a2969c..6770007 100644
--- a/app/services/calendar_sync.py
+++ b/app/services/calendar_sync.py
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
return str(raw or "")
+# Public aliases — read-only helpers reused by the router layer (sidebar
+# "Meeting" tab needs the date property + value of a collection row).
+date_prop_id = _date_prop_id
+row_date = _row_date
+
+
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
diff --git a/app/services/collection_lifecycle.py b/app/services/collection_lifecycle.py
new file mode 100644
index 0000000..edf8773
--- /dev/null
+++ b/app/services/collection_lifecycle.py
@@ -0,0 +1,222 @@
+"""FlowDeck — cohérence collections ↔ workspace.
+
+Une database (`collections`) peut être :
+
+* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
+* **de page** — page convertie en base (`collections.parent_page_id` +
+ `pages.collection_id`) ou base inline insérée dans une page.
+
+Ce module centralise les deux sens de la relation :
+
+* **lecture** — quelles collections sont encore *vivantes* dans un workspace
+ (leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
+ quelles lignes sont encore vivantes (leur page contenu n'est pas à la
+ corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
+ d'un workspace dont tous les documents ont été supprimés ;
+* **écriture** — la suppression en cascade d'une collection. L'ordre des
+ écritures est dicté par le schéma SQLite :
+
+ - ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
+ la page hôte doit être détachée **avant** la suppression ;
+ - ``collection_data_sources.source_collection_id`` référence également en
+ ``NO ACTION`` : les vues liées qui pointent vers la collection doivent
+ être détachées ;
+ - ``collection_pages`` (lignes), ``collection_properties``,
+ ``collection_views``, ``collection_permissions`` et les pages contenu
+ (``pages.collection_row_id``) partent en ``CASCADE``.
+
+ Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
+ dès qu'une page hôte (ou une vue liée) existe encore.
+"""
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# ── Lecture ──────────────────────────────────────────────────────────────
+
+#: SQL du workspace **effectif** d'une collection.
+#:
+#: ``collections.workspace_id`` est renseigné pour les databases créées via
+#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
+#: convertie en base, base inline insérée dans un document) : son workspace
+#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
+#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
+#: ``workspaces`` les exclut définitivement.
+EFFECTIVE_WORKSPACE_SQL = (
+ "COALESCE({a}.workspace_id,"
+ " (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
+)
+
+
+def effective_workspace_sql(alias: str = "c") -> str:
+ """Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
+ return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
+
+
+def user_workspace_ids(conn, user_id: int) -> list[int]:
+ """IDs des workspaces accessibles à l'utilisateur.
+
+ Propriétaire **ou** membre : c'est le même périmètre que la recherche
+ globale (`app/services/search._scope_where`), à la différence près qu'on
+ n'inclut pas les éléments sans workspace — une base non rattachable
+ n'appartient à personne et ne doit donc apparaître chez personne.
+ """
+ rows = conn.execute(
+ "SELECT id FROM workspaces WHERE owner_id = ?"
+ " UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
+ (user_id, user_id),
+ ).fetchall()
+ return [r[0] for r in rows]
+
+
+def live_collection_ids(conn, workspace_id: int) -> list[int]:
+ """IDs des collections rattachées au workspace qui sont encore vivantes.
+
+ Une collection est exclue dès que sa page hôte a disparu :
+
+ * ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
+ * une page ``content_format='collection'`` qui pointe encore vers elle
+ mais est à la corbeille.
+
+ Les collections sans page hôte (databases de workspace créées via
+ ``/db/api``) restent toujours visibles.
+
+ Le rattachement passe par le workspace **effectif** : une base créée depuis
+ un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
+ """
+ rows = conn.execute(
+ f"""
+ SELECT c.id
+ FROM collections c
+ WHERE {effective_workspace_sql("c")} = ?
+ AND (c.parent_page_id IS NULL
+ OR EXISTS (SELECT 1 FROM pages p
+ WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
+ AND NOT EXISTS (SELECT 1 FROM pages p
+ WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
+ ORDER BY c.name
+ """,
+ (workspace_id,),
+ ).fetchall()
+ return [r["id"] for r in rows]
+
+
+def live_row_ids(conn, collection_id: int) -> list[int]:
+ """IDs des lignes racines d'une collection dont la page contenu vit.
+
+ La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
+ : son absence ne dit rien, mais son passage à la corbeille signifie que
+ l'utilisateur a supprimé le document → la ligne sort de My Tasks.
+ """
+ rows = conn.execute(
+ """
+ SELECT cp.id
+ FROM collection_pages cp
+ WHERE cp.collection_id = ?
+ AND cp.parent_id IS NULL
+ AND NOT EXISTS (SELECT 1 FROM pages p
+ WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
+ ORDER BY cp.position
+ """,
+ (collection_id,),
+ ).fetchall()
+ return [r["id"] for r in rows]
+
+
+def live_document_count(conn, workspace_id: int) -> int:
+ """Nombre de documents visibles du workspace (mêmes règles que l'arbre
+ de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
+ return conn.execute(
+ "SELECT COUNT(*) FROM pages "
+ "WHERE workspace_id = ? AND parent_id IS NULL "
+ "AND deleted_at IS NULL AND collection_row_id IS NULL",
+ (workspace_id,),
+ ).fetchone()[0]
+
+
+def collections_hosted_by_pages(conn, page_ids) -> list[int]:
+ """IDs des collections dont l'une de ces pages est la page hôte.
+
+ Les deux relations sont couvertes : ``collections.parent_page_id`` (page
+ convertie en base, base inline) et ``pages.collection_id`` (page hôte).
+ Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
+ aucune collection et ne remontent donc rien.
+
+ À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
+ relation ``pages.collection_id``.
+ """
+ ids = [int(p) for p in page_ids if p is not None]
+ if not ids:
+ return []
+ qs = ",".join("?" * len(ids))
+ rows = conn.execute(
+ f"""
+ SELECT id FROM collections WHERE parent_page_id IN ({qs})
+ UNION
+ SELECT collection_id FROM pages
+ WHERE id IN ({qs}) AND collection_id IS NOT NULL
+ """,
+ [*ids, *ids],
+ ).fetchall()
+ return [r["id"] for r in rows]
+
+
+def collections_hosted_by_page(conn, page_id: int) -> list[int]:
+ """``collections_hosted_by_pages`` pour une seule page."""
+ return collections_hosted_by_pages(conn, [page_id])
+
+
+def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
+ """IDs de **toutes** les collections du workspace (sans filtre de
+ vivacité) — utilisé lors de la suppression du workspace."""
+ rows = conn.execute(
+ "SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
+ ).fetchall()
+ return [r["id"] for r in rows]
+
+
+# ── Écriture ─────────────────────────────────────────────────────────────
+
+def delete_collections(conn, collection_ids) -> int:
+ """Supprime définitivement des collections et tout ce qu'elles portent.
+
+ Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
+ vues liées) puis supprime : le reste part en ``CASCADE``.
+
+ Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
+ transaction. Retourne le nombre de collections supprimées.
+ """
+ ids = [int(i) for i in collection_ids if i is not None]
+ if not ids:
+ return 0
+ qs = ",".join("?" * len(ids))
+
+ # 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
+ # contenu EST la base redevient une page blocs normale.
+ conn.execute(
+ f"""UPDATE pages
+ SET collection_id = NULL,
+ content_format = CASE WHEN content_format = 'collection'
+ THEN 'blocks' ELSE content_format END
+ WHERE collection_id IN ({qs})""",
+ ids,
+ )
+ # 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
+ # liée perd sa source (elle n'a plus rien à afficher) mais survit.
+ conn.execute(
+ f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
+ ids,
+ )
+ # 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
+ # suivent en CASCADE.
+ cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
+ return cur.rowcount or 0
+
+
+def delete_collections_hosted_by_pages(conn, page_ids) -> int:
+ """Supprime les databases portées par des pages définitivement supprimées
+ (corbeille purgée, suppression définitive, workspace effacé)."""
+ return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
diff --git a/app/services/skill_gallery.py b/app/services/skill_gallery.py
index 74393e4..0141aa4 100644
--- a/app/services/skill_gallery.py
+++ b/app/services/skill_gallery.py
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
+
+ # ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
+
+ "veille-documentaire": {
+ "name": "Veille documentaire",
+ "icon": "🔎",
+ "description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
+ "prompt_template": (
+ "Fais une veille documentaire du workspace :\n"
+ "1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
+ "(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
+ "2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
+ "statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
+ "3. Crée un document « Veille — » (create_document) en 3 sections : "
+ "À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
+ "Cohérences (points de vue contradictoires entre documents).\n"
+ "4. Ne modifie aucun document : tu proposes, l'humain décide."
+ ),
+ "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
+ },
+ "chasse-decisions": {
+ "name": "Chasse aux décisions",
+ "icon": "⚖️",
+ "description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
+ "prompt_template": (
+ "Construis le registre des décisions du workspace :\n"
+ "1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
+ "« choix de », « remplacement de »).\n"
+ "2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
+ "les options écartées.\n"
+ "3. Crée un document « Registre des décisions » (create_document) en tableau : "
+ "Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
+ "4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
+ "plutôt que de combler les trous."
+ ),
+ "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
+ },
+ "onboarding-nouveau": {
+ "name": "Onboarding nouveau",
+ "icon": "🧭",
+ "description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
+ "prompt_template": (
+ "Prépare l'onboarding d'une nouvelle personne :\n"
+ "1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
+ "glossaire (search_workspace).\n"
+ "2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
+ "Puis plus tard.\n"
+ "3. Crée un document « Onboarding — » (create_document) : pour chaque étape, "
+ "le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
+ "4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
+ ),
+ "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
+ },
+ "audit-base": {
+ "name": "Audit de base",
+ "icon": "🧹",
+ "description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
+ "prompt_template": (
+ "Audite la collection demandée :\n"
+ "1. Lis son schéma et ses lignes (read_collection).\n"
+ "2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
+ "valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
+ "3. Crée un document « Audit — » (create_document) avec, par anomalie : "
+ "l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
+ "4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
+ ),
+ "allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
+ },
+ "sprint-review": {
+ "name": "Revue de sprint",
+ "icon": "🏁",
+ "description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
+ "prompt_template": (
+ "Prépare la revue de sprint à partir des issues Gitea :\n"
+ "1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
+ "2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
+ "3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
+ "changent la priorisation.\n"
+ "4. Crée un document « Revue de sprint —
» (create_document) : Accompli · En retard · "
+ "Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
+ "5. Ne ferme ni ne modifie aucune issue."
+ ),
+ "allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
+ "create_document", "write_blocks"],
+ },
+
+ # ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
+
+ "recherche-marche": {
+ "name": "Recherche marché",
+ "icon": "🌍",
+ "description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
+ "prompt_template": (
+ "Produis une étude de marché sourcée sur « » :\n"
+ "1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « alternatives », "
+ "« prix tarifs 2026 ».\n"
+ "2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
+ "3. Crée un document « Marché — » (create_document) : acteurs et positionnement, "
+ "grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
+ "4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
+ "Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
+ "5. Indique la date de la recherche : ces données périment vite."
+ ),
+ "allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
+ },
+ "veille-techno": {
+ "name": "Veille technologique",
+ "icon": "🛰️",
+ "description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
+ "prompt_template": (
+ "Fais une veille technologique sur « » :\n"
+ "1. Web : dernières annonces et sorties de version (web_search).\n"
+ "2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
+ "3. Approfondis les annonces importantes (fetch_url).\n"
+ "4. Crée un document « Veille — — » (create_document) : "
+ "Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
+ "surveiller / ignorer).\n"
+ "5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
+ ),
+ "allowed_tools": ["web_search", "fetch_url", "search_code",
+ "create_document", "write_blocks"],
+ },
+ "debug-web": {
+ "name": "Debug web",
+ "icon": "🐛",
+ "description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
+ "prompt_template": (
+ "Diagnostique le problème suivant : .\n"
+ "1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
+ "+ bibliothèque + version.\n"
+ "2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
+ "3. Crée un document « Debug — » (create_document) : Cause probable (classée par "
+ "probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
+ "4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
+ ),
+ "allowed_tools": ["web_search", "search_code", "fetch_url",
+ "create_document", "write_blocks"],
+ },
+
+ # ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
+
+ "triage-incident": {
+ "name": "Triage d'incident",
+ "icon": "🚨",
+ "description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
+ "prompt_template": (
+ "Traite l'incident suivant : .\n"
+ "1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
+ "erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
+ "2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
+ "et les runbooks existants.\n"
+ "3. Crée un document « Incident — » (create_document) : Symptôme · Impact · "
+ "Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
+ "4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
+ "5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
+ ),
+ "allowed_tools": ["search_workspace", "read_document", "web_search",
+ "create_document", "write_blocks"],
+ },
+ "nettoyage-base": {
+ "name": "Nettoyage de base",
+ "icon": "🧽",
+ "description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
+ "prompt_template": (
+ "Prépare le nettoyage de la collection « » :\n"
+ "1. Lis son schéma et ses lignes (read_collection).\n"
+ "2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
+ "valeurs parasites à normaliser, propriétés obsolètes.\n"
+ "3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
+ "concernées et 3 exemples concrets.\n"
+ "4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
+ "Le plan est le livrable.\n"
+ "5. Crée le document « Plan de nettoyage — » (create_document) avec le plan, "
+ "puis demande la validation."
+ ),
+ "allowed_tools": ["search_workspace", "read_collection", "read_page",
+ "create_document", "write_blocks"],
+ },
+ "briefing-quotidien": {
+ "name": "Briefing quotidien",
+ "icon": "🌅",
+ "description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
+ "prompt_template": (
+ "Prépare le briefing du jour :\n"
+ "1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
+ "search_workspace) — uniquement des faits datés.\n"
+ "2. Décisions en attente : documents de décision sans statut clair.\n"
+ "3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
+ "4. Crée un document « Briefing — » (create_document) en moins d'une page : "
+ "Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
+ "5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
+ ),
+ "allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
+ "create_document", "write_blocks"],
+ },
}
diff --git a/app/services/sync_engine.py b/app/services/sync_engine.py
index 040e199..f8363d6 100644
--- a/app/services/sync_engine.py
+++ b/app/services/sync_engine.py
@@ -22,6 +22,7 @@ import logging
import time
from app.db import get_conn
+from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -381,7 +382,7 @@ class SyncEngine:
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
- conn.execute("DELETE FROM collections WHERE id=?", (cid,))
+ delete_collections(conn, [cid])
conn.commit()
return {"collection_id": cid, "server_version": None}
diff --git a/app/services/task_databases.py b/app/services/task_databases.py
new file mode 100644
index 0000000..7c1af43
--- /dev/null
+++ b/app/services/task_databases.py
@@ -0,0 +1,438 @@
+"""FlowDeck — My Tasks : sources de tâches et normalisation.
+
+Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
+explicite** (``collections.is_task``) et le mapping explicite de ses trois
+propriétés requises :
+
+=========================== ==========================================
+Rôle Colonne sur ``collections``
+=========================== ==========================================
+``assignee`` (Assigné à) ``task_assignee_prop``
+``status`` (Statut) ``task_status_prop``
+``due`` (Échéance) ``task_due_prop``
+=========================== ==========================================
+
+Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
+vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
+``collect_tasks`` — donc une même tâche ne peut pas être rendue
+différemment selon l'appelant.
+
+Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
+pas seulement le workspace actif — le tableau de bord est un poste de
+travail transverse.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import unicodedata
+from datetime import UTC, date, datetime, timedelta
+
+from app.services.collection_lifecycle import (
+ effective_workspace_sql,
+ live_collection_ids,
+ live_row_ids,
+ user_workspace_ids,
+)
+
+logger = logging.getLogger(__name__)
+
+#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
+MAX_TASK_SOURCES = 10
+
+#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
+TASK_ROLES = ("assignee", "status", "due")
+
+#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
+#: sinon : une colonne Date n'a pas de sens comme Assigné à).
+ROLE_TYPES = {
+ "assignee": ("person",),
+ "status": ("status", "select"),
+ "due": ("date",),
+}
+
+ROLE_LABELS = {
+ "assignee": "Assigné à",
+ "status": "Statut",
+ "due": "Date d'échéance",
+}
+
+#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
+#: sans accents). On compare des formes normalisées et non des libellés exacts :
+#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
+#: reconnus, quelle que soit la casse et l'accentuation.
+#:
+#: On matche le **premier mot** et non un simple préfixe : « Clôture »
+#: commence comme « clos » mais n'est pas un statut terminé.
+DONE_WORDS = frozenset({
+ # anglais
+ "done", "complete", "completed", "closed", "closed?", "terminated", "finished",
+ # français (masculin / féminin, singulier / pluriel)
+ "clos", "close", "termine", "terminee", "termines", "terminees",
+ "annule", "annulee", "annules", "annulees", "cancelled", "canceled",
+ "resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
+})
+
+
+def _normalize(value) -> str:
+ """Minuscules, accents retirés, espaces compactés."""
+ text = unicodedata.normalize("NFKD", str(value or ""))
+ text = "".join(c for c in text if not unicodedata.combining(c))
+ return " ".join(text.lower().split())
+
+
+def is_done(status: str) -> bool:
+ """Le statut vaut-il « terminé » ?
+
+ Le premier mot suffit : « Terminée », « terminated », « Complete! » et
+ « done (closed) » sont reconnus ; « Clôture » ne l'est pas.
+ """
+ norm = _normalize(status)
+ if not norm:
+ return False
+ first = norm.split()[0] if norm.split() else ""
+ return first.strip("!?.,:;()") in DONE_WORDS
+
+
+def user_today(conn, user_id: int) -> date:
+ """« Aujourd'hui » dans le fuseau de l'utilisateur.
+
+ Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
+ l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
+ sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
+ l'inverse) dès que l'utilisateur est loin d'UTC.
+ """
+ row = conn.execute(
+ "SELECT timezone FROM users WHERE id=?", (user_id,)
+ ).fetchone()
+ tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
+ if tz_name:
+ try:
+ from zoneinfo import ZoneInfo
+
+ return datetime.now(ZoneInfo(tz_name)).date()
+ except Exception: # fuseau inconnu / base tz absente → UTC
+ logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
+ return datetime.now(UTC).date()
+
+
+def task_source_columns() -> str:
+ """Colonnes de ``collections`` lues par les requêtes de sources.
+
+ Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
+ base antérieure elles vaudront NULL de toute façon.
+
+ Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
+ qualification, ``id`` est ambigu.
+ """
+ return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
+ "c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
+
+
+def list_task_sources(conn, user_id: int) -> list[dict]:
+ """Bases de tâches configurées par l'utilisateur, tous workspaces
+ confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
+ ``missing_roles`` pour piloter l'invite de conversion.
+
+ Deux exclusions :
+
+ * une base dont la page hôte est à la corbeille (ou supprimée) est
+ **retirée** : sans ce filtre, une base portée par un document supprimé
+ continuerait d'alimenter My Tasks — le bug d'origine ;
+ * une base dont le workspace n'est pas accessible à l'utilisateur n'est
+ pas listée (une base sans workspace rattachable n'appartient à personne).
+
+ Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
+ celui de la page hôte) : une base créée depuis un document a
+ ``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
+ My Tasks alors qu'elle venait d'être convertie.
+ """
+ ws_ids = user_workspace_ids(conn, user_id)
+ if not ws_ids:
+ return []
+ qs = ",".join("?" * len(ws_ids))
+ ws_sql = effective_workspace_sql("c")
+ rows = conn.execute(
+ f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
+ (SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
+ AS workspace_name
+ FROM collections c
+ WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
+ ORDER BY c.name""",
+ [*ws_ids],
+ ).fetchall()
+
+ # Un workspace peut apparaître plusieurs fois : on ne recalcule le
+ # jeu de bases vivantes qu'une fois par workspace.
+ live_by_ws: dict[int, set[int]] = {}
+ sources = []
+ for r in rows:
+ d = dict(r)
+ ws = d.get("ws_effective")
+ if ws not in live_by_ws:
+ live_by_ws[ws] = set(live_collection_ids(conn, ws))
+ if d["id"] not in live_by_ws[ws]:
+ logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
+ continue
+
+ missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
+ d["configured"] = not missing
+ d["missing_roles"] = missing
+ d["status_options"] = _status_options(conn, d.get("task_status_prop"))
+ sources.append(d)
+ return sources
+
+
+def _status_options(conn, prop_id) -> list[str]:
+ """Libellés du statut de la base source (colonnes du Kanban)."""
+ if not prop_id:
+ return []
+ row = conn.execute(
+ "SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
+ ).fetchone()
+ if not row:
+ return []
+ try:
+ opts = json.loads(row["options_json"] or "[]")
+ except (json.JSONDecodeError, TypeError):
+ return []
+ return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
+ if isinstance(o, str) or isinstance(o, dict)]
+
+
+def source_state(conn, collection_id: int) -> dict:
+ """État de conversion d'une base pour la modale « convertir en base de
+ tâches » : mapping courant + colonnes candidates pour chaque rôle."""
+ coll = conn.execute(
+ f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
+ (collection_id,),
+ ).fetchone()
+ if coll is None:
+ return {"exists": False}
+
+ d = dict(coll)
+ props = conn.execute(
+ "SELECT id, name, prop_type, options_json, position FROM collection_properties "
+ "WHERE collection_id=? ORDER BY position, id",
+ (collection_id,),
+ ).fetchall()
+
+ missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
+ used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
+
+ return {
+ "exists": True,
+ "collection_id": collection_id,
+ "name": d.get("name"),
+ "is_task": bool(d.get("is_task")),
+ "mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
+ "missing_roles": missing,
+ "configured": not missing,
+ # Par rôle : les colonnes compatibles (une colonne déjà affectée à un
+ # autre rôle est exclue — Notion n'autorise pas le double emploi).
+ "candidates": {
+ role: [
+ {"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
+ for p in props
+ if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
+ ]
+ for role in TASK_ROLES
+ },
+ "all_properties": [
+ {"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
+ for p in props
+ ],
+ "suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
+ "max_sources": MAX_TASK_SOURCES,
+ "sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
+ }
+
+
+def _sources_count(conn, workspace_id, exclude_id=None) -> int:
+ """Nombre de bases déjà connectées (pour la limite de 10)."""
+ row = conn.execute(
+ "SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
+ (workspace_id,),
+ ).fetchone()
+ n = row[0] if row else 0
+ if exclude_id is not None:
+ row = conn.execute(
+ "SELECT is_task FROM collections WHERE id=?", (exclude_id,)
+ ).fetchone()
+ if row and row[0]:
+ n -= 1
+ return max(n, 0)
+
+
+def _row_value(raw: dict, prop_id):
+ """Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
+ lignes historicisées écrites avant le passage aux clés indexées)."""
+ if prop_id is None:
+ return None
+ if prop_id in raw:
+ return raw[prop_id]
+ return raw.get(str(prop_id))
+
+
+def _person_values(value) -> list:
+ """Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
+ chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return [value]
+ if isinstance(value, list):
+ out: list = []
+ for v in value:
+ out.extend(_person_values(v))
+ return out
+ if isinstance(value, str) and value.strip():
+ return [value]
+ return []
+
+
+def _person_label(v) -> str:
+ if isinstance(v, dict):
+ return str(v.get("login") or v.get("full_name") or "")
+ return str(v)
+
+
+def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
+ """L'assigné est-il l'utilisateur courant ?
+
+ Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
+ priorité (stable), puis le login en repli pour les lignes historicisées
+ qui ne stockent qu'un nom.
+ """
+ if not login:
+ return False
+ for v in values:
+ if isinstance(v, dict):
+ if v.get("id") is not None and str(v["id"]) == str(user_id):
+ return True
+ if _person_label(v).strip().lower() == login.strip().lower():
+ return True
+ return False
+
+
+def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
+ login: str = "") -> list[dict]:
+ """Tâches normalisées de toutes les bases connectées.
+
+ Chaque entrée porte sa source (``collection_id``/``collection_name``) et
+ les identifiants des propriétés à écrire pour une édition en direct
+ (``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
+ deviner quoi écrire.
+ """
+ tasks: list[dict] = []
+ today = user_today(conn, user_id)
+
+ for src in list_task_sources(conn, user_id):
+ if not src["configured"]:
+ # Base connectée mais mapping incomplet : elle n'alimente pas
+ # My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
+ continue
+ status_prop = src["task_status_prop"]
+ due_prop = src["task_due_prop"]
+ assignee_prop = src["task_assignee_prop"]
+
+ for row_id in live_row_ids(conn, src["id"]):
+ row = conn.execute(
+ "SELECT id, title, icon, created_at, property_values_json "
+ "FROM collection_pages WHERE id=?",
+ (row_id,),
+ ).fetchone()
+ if row is None:
+ continue
+ try:
+ raw = json.loads(row["property_values_json"] or "{}")
+ except (json.JSONDecodeError, TypeError):
+ raw = {}
+ if not isinstance(raw, dict):
+ raw = {}
+
+ persons = _person_values(_row_value(raw, assignee_prop))
+ assignees = [_person_label(p) for p in persons if _person_label(p)]
+ if mine_only and persons and not _assigned_to_me(persons, user_id, login):
+ continue
+
+ status = _row_value(raw, status_prop)
+ if isinstance(status, list):
+ status = status[0] if status else ""
+ status = str(status or "")
+
+ due_raw = _row_value(raw, due_prop)
+ due = str(due_raw or "")[:10]
+
+ due_state = ""
+ if due:
+ try:
+ d = date.fromisoformat(due)
+ except ValueError:
+ d = None
+ if d:
+ due_state = ("overdue" if d < today
+ else "today" if d == today
+ else "upcoming")
+
+ tasks.append({
+ "id": row["id"],
+ "title": row["title"] or "Untitled",
+ "icon": row["icon"] or "📄",
+ "created_at": row["created_at"],
+ "collection_id": src["id"],
+ "collection_name": src["name"],
+ "collection_icon": src["icon"] or "📋",
+ "workspace_id": src["workspace_id"],
+ "workspace_name": src["workspace_name"],
+ "assignees": assignees,
+ "status": status,
+ "status_options": src["status_options"],
+ "done": is_done(status),
+ "due": due,
+ "due_state": due_state,
+ "status_prop": status_prop,
+ "due_prop": due_prop,
+ "assignee_prop": assignee_prop,
+ })
+
+ return tasks
+
+
+def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
+ sort: str = "due", today: date | None = None) -> list[dict]:
+ """Filtres et tri globaux de My Tasks (portés par l'URL).
+
+ ``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
+ doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
+ « cette semaine » ne cibleront pas le même jour que `due_state`.
+ """
+ out = tasks
+ if hide_done:
+ out = [t for t in out if not t["done"]]
+ if due and due != "all":
+ if due == "today":
+ out = [t for t in out if t["due_state"] == "today"]
+ elif due == "overdue":
+ out = [t for t in out if t["due_state"] in ("today", "overdue")]
+ elif due == "week":
+ # « Cette semaine » est **borné** à J+7 : sans borne, une tâche
+ # datée dans six mois apparaîtrait sous « Cette semaine ».
+ ref = today or datetime.now(UTC).date()
+ limit = (ref + timedelta(days=7)).isoformat()
+ out = [t for t in out
+ if t["due"] and (t["due_state"] in ("today", "overdue")
+ or t["due"] <= limit)]
+
+ if sort == "status":
+ return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
+ (t["due"] or "9999")))
+ if sort == "source":
+ return sorted(out, key=lambda t: (t["collection_name"].lower(),
+ (t["due"] or "9999"), t["title"].lower()))
+ if sort == "created":
+ return sorted(out, key=lambda t: str(t.get("created_at") or ""))
+ # "due" : les tâches datées d'abord (les plus urgentes en tête), puis
+ # les tâches sans date — comme une liste de rappels.
+ return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
+ t["done"], t["title"].lower()))
diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py
index ac60073..756f838 100644
--- a/app/services/tool_registry.py
+++ b/app/services/tool_registry.py
@@ -15,12 +15,14 @@ from __future__ import annotations
import json
import logging
+import re
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
+from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
- conn.execute("DELETE FROM collections WHERE id=?", (cid,))
+ delete_collections(conn, [cid])
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
@@ -877,6 +879,197 @@ class CreateGiteaIssue(Tool):
)
+# ══════════════════════════ Web tools ══════════════════════════
+# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
+# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
+# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
+# re-vérifiée à chaque saut de redirection.
+
+
+class WebSearch(Tool):
+ name = "web_search"
+ description = (
+ "Recherche sur le web (actualités, docs, libs, comparatifs) et "
+ "renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
+ "répondre sur un fait récent ou technique, puis CITE les URL obtenues."
+ )
+ parameters = {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "requête de recherche"},
+ "num_results": {"type": "integer",
+ "description": "nombre de résultats (1-10, défaut 5)"},
+ },
+ "required": ["query"],
+ }
+
+ async def execute(self, args, *, user_id=None) -> ToolResult:
+ from app.services.web_search import available_providers, search_web
+
+ query = str(args.get("query") or "").strip()
+ if not query:
+ return ToolResult(status="error", tool=self.name, message="query vide")
+ try:
+ num = max(1, min(int(args.get("num_results") or 5), 10))
+ except (TypeError, ValueError):
+ num = 5
+ try:
+ results, provider = await search_web(query, num)
+ except Exception as exc: # noqa: BLE001
+ return ToolResult(status="error", tool=self.name,
+ message=f"Recherche web indisponible: {exc}")
+ if not results:
+ hint = ""
+ if not available_providers().get(provider, True):
+ hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
+ return ToolResult(status="error", tool=self.name,
+ message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
+ return ToolResult(
+ status="success", tool=self.name, target_type="web_search",
+ data={"query": query, "provider": provider, "results": results,
+ "count": len(results)},
+ message=f"{len(results)} résultat(s) via {provider}",
+ )
+
+
+class FetchUrl(Tool):
+ name = "fetch_url"
+ description = (
+ "Télécharge une page web et la convertit en Markdown (titres, listes, "
+ "code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
+ "web_search pour approfondir un résultat. Les URLs internes / privées "
+ "sont refusées."
+ )
+ parameters = {
+ "type": "object",
+ "properties": {
+ "url": {"type": "string", "description": "URL http(s) de la page"},
+ "max_chars": {"type": "integer",
+ "description": "longueur max du markdown renvoyé (défaut 20000)"},
+ },
+ "required": ["url"],
+ }
+
+ async def execute(self, args, *, user_id=None) -> ToolResult:
+ from app.config import settings
+ from app.services.http_client import shared_client
+ from app.services.importers.html_notes import _html_to_markdown
+ from app.services.importers.url_fetch import _validate_url
+ from app.services.og_fetcher import parse_og
+
+ raw = str(args.get("url") or "").strip()
+ if not raw:
+ return ToolResult(status="error", tool=self.name, message="url vide")
+ # « example.com/page » → https://example.com/page, mais un schéma
+ # explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
+ # garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
+ raw = "https://" + raw
+ try:
+ url = _validate_url(raw)
+ except ValueError as exc:
+ return ToolResult(status="error", tool=self.name, message=str(exc))
+
+ try:
+ max_chars = max(500, min(int(args.get("max_chars")
+ or settings.web_fetch_max_chars), 60000))
+ except (TypeError, ValueError):
+ max_chars = int(settings.web_fetch_max_chars or 20000)
+
+ current = url
+ resp = None
+ try:
+ async with shared_client(timeout=15.0, follow_redirects=False,
+ headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
+ for _ in range(6):
+ resp = await client.get(current)
+ if resp.status_code in (301, 302, 303, 307, 308):
+ loc = resp.headers.get("location")
+ if not loc:
+ raise ValueError("redirection sans cible")
+ from urllib.parse import urljoin
+
+ current = urljoin(current, loc)
+ # re-validation : une URL publique peut rediriger vers 169.254.x
+ current = _validate_url(current)
+ continue
+ resp.raise_for_status()
+ break
+ else:
+ raise ValueError("trop de redirections")
+ except ValueError as exc:
+ return ToolResult(status="error", tool=self.name, message=str(exc))
+ except Exception as exc: # noqa: BLE001
+ return ToolResult(status="error", tool=self.name,
+ message=f"Page injoignable: {exc}")
+ if resp is None:
+ return ToolResult(status="error", tool=self.name, message="Aucune réponse")
+
+ ctype = (resp.headers.get("content-type") or "").lower()
+ body = resp.text
+ if "html" not in ctype and "xhtml" not in ctype:
+ # JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
+ markdown = body
+ meta: dict = {}
+ else:
+ meta = parse_og(body, current)
+ markdown = _html_to_markdown(body)
+
+ truncated = len(markdown) > max_chars
+ if truncated:
+ markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
+
+ title = (meta.get("title") or "").strip() or current
+ return ToolResult(
+ status="success", tool=self.name, target_type="web_page", target_id=current,
+ data={"url": current, "title": title[:200], "markdown": markdown,
+ "truncated": truncated, "chars": len(markdown)},
+ message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
+ )
+
+
+class SearchCode(Tool):
+ name = "search_code"
+ description = (
+ "Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
+ "public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
+ "trouver une implémentation de référence, une lib ou l'historique d'un bug."
+ )
+ parameters = {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
+ "kind": {"type": "string",
+ "description": "repositories | code | issues (défaut repositories)"},
+ "limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
+ },
+ "required": ["query"],
+ }
+
+ async def execute(self, args, *, user_id=None) -> ToolResult:
+ from app.services.web_search import search_github
+
+ query = str(args.get("query") or "").strip()
+ if not query:
+ return ToolResult(status="error", tool=self.name, message="query vide")
+ kind = str(args.get("kind") or "repositories")
+ try:
+ limit = max(1, min(int(args.get("limit") or 5), 10))
+ except (TypeError, ValueError):
+ limit = 5
+ try:
+ results = await search_github(query, kind, limit)
+ except Exception as exc: # noqa: BLE001
+ return ToolResult(status="error", tool=self.name,
+ message=f"Recherche GitHub impossible: {exc}")
+ return ToolResult(
+ status="success", tool=self.name, target_type="github",
+ target_id=f"{kind}:{query}",
+ data={"query": query, "kind": kind, "results": results, "count": len(results)},
+ message=f"{len(results)} résultat(s) GitHub ({kind})",
+ )
+
+
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
@@ -887,6 +1080,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
+ WebSearch, FetchUrl, SearchCode,
]
diff --git a/app/services/trash.py b/app/services/trash.py
index 5b96565..739489f 100644
--- a/app/services/trash.py
+++ b/app/services/trash.py
@@ -13,6 +13,10 @@ import re
from datetime import UTC, datetime, timedelta
from app.db import get_conn
+from app.services.collection_lifecycle import (
+ collections_hosted_by_page,
+ delete_collections,
+)
logger = logging.getLogger(__name__)
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
+ # A purged host page takes its database with it, otherwise the
+ # workspace kept advertising tasks whose document is gone.
+ collections = collections_hosted_by_page(conn, page_id)
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
+ delete_collections(conn, collections)
purged.append(page_id)
conn.commit()
if purged:
diff --git a/app/services/web_search.py b/app/services/web_search.py
new file mode 100644
index 0000000..342a872
--- /dev/null
+++ b/app/services/web_search.py
@@ -0,0 +1,295 @@
+"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
+
+Alimente les deux tools réseau qui manquaient au registre : la recherche
+**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
+
+Choix de conception :
+
+* **Provider configurable** (``settings.web_search_provider``) : Exa
+ (recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
+ repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
+ il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
+ garder le skill utilisable sur une instance sans compte externe.
+* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
+ pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
+ ``app.services.importers.url_fetch`` / ``og_fetcher``.
+* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
+ que l'IA puisse citer au lieu d'inventer.
+
+La couche réseau est injectable (``transport``) pour que les tests n'aient
+jamais besoin du réseau.
+"""
+from __future__ import annotations
+
+import html as htmlmod
+import logging
+import re
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+from app.config import settings
+from app.services.http_client import shared_client
+
+logger = logging.getLogger(__name__)
+
+USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
+
+#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
+#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
+_BLOCKED_RESULT_HOSTS = frozenset({
+ "duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
+})
+
+EXA_ENDPOINT = "https://api.exa.ai/search"
+DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
+GITHUB_API = "https://api.github.com"
+
+MAX_RESULTS = 10
+_SNIPPET_CHARS = 300
+
+
+def _clean(text: str | None) -> str:
+ return re.sub(r"\s+", " ", (text or "")).strip()
+
+
+def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
+ text = _clean(text)
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
+
+
+def _is_resultable(url: str) -> bool:
+ """Ignore les redirections vers le moteur lui-même et les URLs non http."""
+ parsed = urlparse(url or "")
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
+ return False
+ return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
+
+
+# ══════════════════════ Exa ══════════════════════
+
+
+async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
+ key = (settings.exa_api_key or "").strip()
+ if not key:
+ return []
+ payload = {
+ "query": query,
+ "numResults": max(1, min(num_results, MAX_RESULTS)),
+ "contents": {"text": {"maxCharacters": 1200}},
+ }
+ kwargs: dict[str, Any] = {"timeout": 15.0}
+ if transport is not None:
+ kwargs["transport"] = transport
+ async with shared_client(**kwargs) as client:
+ resp = await client.post(
+ EXA_ENDPOINT,
+ json=payload,
+ headers={"x-api-key": key, "Content-Type": "application/json",
+ "User-Agent": USER_AGENT},
+ )
+ if resp.status_code != 200:
+ raise RuntimeError(f"Exa HTTP {resp.status_code}")
+ results = []
+ for item in (resp.json() or {}).get("results", []):
+ url = item.get("url") or ""
+ if not _is_resultable(url):
+ continue
+ results.append({
+ "title": _clip(item.get("title") or url, 160),
+ "url": url,
+ "snippet": _clip(item.get("text") or item.get("summary") or ""),
+ "published": (item.get("publishedDate") or "")[:10],
+ "source": "exa",
+ })
+ return results[:MAX_RESULTS]
+
+
+# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
+
+_DDG_ROW_RE = re.compile(
+ r"]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P[^\"]+)\"[^>]*>(?P.*?)",
+ re.I | re.S,
+)
+_DDG_SNIPPET_RE = re.compile(
+ r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P.*?)", re.I | re.S
+)
+_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _unwrap_ddg(href: str) -> str:
+ """DuckDuckGo emballe les résultats dans ``/l/?uddg=``."""
+ if not href:
+ return ""
+ parsed = urlparse(htmlmod.unescape(href))
+ if "uddg" in (parsed.query or ""):
+ values = parse_qs(parsed.query).get("uddg")
+ if values:
+ return values[0]
+ return href
+
+
+async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
+ kwargs: dict[str, Any] = {"timeout": 12.0}
+ if transport is not None:
+ kwargs["transport"] = transport
+ async with shared_client(**kwargs) as client:
+ resp = await client.post(
+ DDG_ENDPOINT,
+ data={"q": query, "kl": "wt-wt"},
+ headers={"User-Agent": USER_AGENT,
+ "Content-Type": "application/x-www-form-urlencoded"},
+ )
+ if resp.status_code != 200:
+ raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
+ body = resp.text
+ links = list(_DDG_ROW_RE.finditer(body))
+ snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
+ results = []
+ for idx, match in enumerate(links):
+ url = _unwrap_ddg(match.group("href"))
+ if not _is_resultable(url):
+ continue
+ results.append({
+ "title": _clean(_TAG_RE.sub("", match.group("title"))),
+ "url": url,
+ "snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
+ "published": "",
+ "source": "duckduckgo",
+ })
+ if len(results) >= min(num_results, MAX_RESULTS):
+ break
+ return results
+
+
+# ══════════════════════ API publique ══════════════════════
+
+
+async def search_web(query: str, num_results: int = 5,
+ transport=None) -> tuple[list[dict], str]:
+ """Recherche web → ``(results, provider)``.
+
+ Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
+ une liste vide — c'est au tool de traduire ça en message actionnable pour
+ le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
+ """
+ q = (query or "").strip()
+ if not q:
+ return [], ""
+
+ provider = (settings.web_search_provider or "exa").strip().lower()
+ order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
+
+ errors: list[str] = []
+ for name in order:
+ try:
+ if name == "exa":
+ results = await _search_exa(q, num_results, transport)
+ else:
+ results = await _search_duckduckgo(q, num_results, transport)
+ except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
+ logger.info("web_search provider %s failed: %s", name, exc)
+ errors.append(f"{name}: {exc}")
+ continue
+ if results:
+ return results, name
+
+ if errors:
+ logger.warning("web_search sans résultat — %s", "; ".join(errors))
+ return [], order[0]
+ return [], order[0]
+
+
+def available_providers() -> dict[str, bool]:
+ """Quel provider est réellement utilisable (affiché dans les settings)."""
+ return {
+ "exa": bool((settings.exa_api_key or "").strip()),
+ "duckduckgo": True,
+ "github": True, # sans PAT : 10 req/min suffisent pour un usage agent
+ }
+
+
+# ══════════════════════ GitHub ══════════════════════
+
+_GH_KINDS = {
+ "repositories": "repos",
+ "repos": "repos",
+ "code": "code",
+ "issues": "issues",
+}
+
+
+def _gh_headers() -> dict[str, str]:
+ headers = {"Accept": "application/vnd.github+json",
+ "User-Agent": USER_AGENT,
+ "X-GitHub-Api-Version": "2022-11-28"}
+ token = (settings.github_token or "").strip()
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+
+def _gh_item_name(item: dict, kind: str) -> str:
+ if kind == "repos":
+ return item.get("full_name") or item.get("name") or ""
+ if kind == "code":
+ return item.get("path") or ""
+ return item.get("title") or f"#{item.get('number')}"
+
+
+def _gh_item_url(item: dict, kind: str) -> str:
+ if kind == "code":
+ # L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
+ repo = (item.get("repository") or {}).get("full_name", "")
+ return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
+ return item.get("html_url") or ""
+
+
+def _gh_item_snippet(item: dict, kind: str) -> str:
+ if kind == "code":
+ return ""
+ if kind == "repos":
+ return _clip(item.get("description") or "")
+ body = _clean(item.get("body") or "")
+ return _clip(body) if body else _clip(item.get("title") or "")
+
+
+async def search_github(query: str, kind: str = "repositories", limit: int = 5,
+ transport=None) -> list[dict]:
+ """Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
+
+ Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
+ """
+ q = (query or "").strip()
+ if not q:
+ return []
+ k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
+ per_page = max(1, min(int(limit or 5), MAX_RESULTS))
+
+ kwargs: dict[str, Any] = {"timeout": 15.0}
+ if transport is not None:
+ kwargs["transport"] = transport
+ async with shared_client(**kwargs) as client:
+ resp = await client.get(
+ f"{GITHUB_API}/search/{k}",
+ params={"q": q, "per_page": per_page},
+ headers=_gh_headers(),
+ )
+ if resp.status_code == 403:
+ raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
+ if resp.status_code == 422:
+ raise RuntimeError("GitHub: requête de recherche invalide (422)")
+ if resp.status_code != 200:
+ raise RuntimeError(f"GitHub HTTP {resp.status_code}")
+
+ items = (resp.json() or {}).get("items", [])
+ results = []
+ for item in items[:per_page]:
+ results.append({
+ "title": _gh_item_name(item, k),
+ "url": _gh_item_url(item, k),
+ "snippet": _gh_item_snippet(item, k),
+ "stars": item.get("stargazers_count") if k == "repos" else None,
+ "state": item.get("state") if k == "issues" else "",
+ "source": f"github/{k}",
+ })
+ return results
diff --git a/app/templates/_database_table.html b/app/templates/_database_table.html
index 8e41ab7..bbfb4a5 100644
--- a/app/templates/_database_table.html
+++ b/app/templates/_database_table.html
@@ -125,13 +125,21 @@