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 :

+
    +
  1. 1Ouvrez une base de données depuis +la liste de vos bases.
  2. +
  3. 2Dans la barre de la base, cliquez sur +« Convertir en base de tâches » — il se trouve à gauche du bouton +New.
  4. +
  5. 3Reliez les colonnes Assigné à, +Statut et Échéance, puis validez.
  6. +
+

Jusqu'à {data["max_sources"]} bases +peuvent alimenter ce tableau de bord.

+Ouvrir mes bases +
""" + + +def _render_no_workspace() -> str: + return """
+
📋
+

My Tasks

+

Aucun workspace. Créez un workspace, puis une base de tâches : elle +alimentera ce tableau de bord.

+Créer un workspace +
""" + + + +# ── 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.*?)</a>", + re.I | re.S, +) +_DDG_SNIPPET_RE = re.compile( + r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S +) +_TAG_RE = re.compile(r"<[^>]+>") + + +def _unwrap_ddg(href: str) -> str: + """DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``.""" + 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 @@ <div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div> </div> - <!-- Side Peek Panel for Database Pages --> - <div id="db-side-peek" class="db-side-peek" style="display:none"> - <div class="db-side-peek-resize" id="db-side-peek-resize"></div> - <div class="db-side-peek-header"> - <button id="db-side-peek-close" class="db-side-peek-close">×</button> - <button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button> - </div> - <iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe> + <!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) --> + <div id="db-side-peek" class="db-side-peek"> + <div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div> + <div class="db-side-peek-header"> + <button id="db-side-peek-close" title="Close"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> + </button> + <div class="db-side-peek-title" id="db-side-peek-title"></div> + <button id="db-side-peek-full" title="Open full page"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> + </button> + </div> + <div class="db-side-peek-body"> + <div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div> + <iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe> + </div> </div> </div> diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index 52619d4..f847a41 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -107,4 +107,6 @@ .db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px} </style> <script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script> +<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}"> <script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script> +<script data-cfasync="false" src="/static/js/task_db_link.js?v={{ asset_version }}" defer></script> diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 3ad7a23..9208fbf 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -2,12 +2,19 @@ <style> #fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s} #fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)} -#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)} +#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:var(--fd-agent-width,470px);max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)} #fd-agent-panel.open{transform:translateX(0)} #fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px} #fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px} #fd-agent-panel ::-webkit-scrollbar-track{background:transparent} +/* Resize handle (left edge) */ +.fd-ap-resize{position:absolute;top:0;bottom:0;left:0;width:6px;cursor:col-resize;z-index:5} +.fd-ap-resize::before{content:"";position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;border-radius:2px;transition:background .12s ease} +.fd-ap-resize:hover::before,body.fd-ap-resizing .fd-ap-resize::before{background:var(--accent,#2383E2)} +body.fd-ap-resizing{cursor:col-resize!important;user-select:none} +body.fd-ap-resizing *{cursor:col-resize!important} + /* Header */ .fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px} .fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)} @@ -189,7 +196,7 @@ .fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px} .fd-agent-msg-markdown li{margin:2px 0} .fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em} -.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0} +.fd-agent-msg-markdown pre{background:var(--bg-code,var(--bg-tertiary,#1f1f1f));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0;color:var(--text,#ececec)} .fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5} .fd-md-table-wrap{overflow-x:auto;margin:8px 0} .fd-md-table{border-collapse:collapse;width:100%;font-size:13px} @@ -211,6 +218,7 @@ {# ── Panel ── #} <div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck"> + <div class="fd-ap-resize" x-ref="resizeHandle" role="separator" aria-orientation="vertical" aria-label="Redimensionner le panneau" tabindex="0" title="Glisser pour redimensionner — double-clic pour réinitialiser"></div> <div class="fd-ap-head"> <span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span> <div class="fd-ap-head-main"> diff --git a/app/templates/base.html b/app/templates/base.html index 0af8e88..b9d7833 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,6 +13,11 @@ <link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}"> <link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}"> <link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}"> + <!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone + swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par + htmx lors d'une navigation partielle : la page revenait alors sans + aucune feuille de style (composants blancs en thème sombre). --> + <link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}"> <link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11"> <style> /* ── Mobile responsive (v4.0.2) ── */ @@ -241,24 +246,38 @@ </div> </div> - <!-- Navigation icons row --> - <div class="sidebar-nav-row"> - <a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home"> + <!-- Navigation tabs row — collapsible pills: the active tab expands with + its label, the others collapse to their icon. Selecting a tab replaces + the whole sidebar content (Home tree / Chat history / Meetings / Inbox). --> + <div class="sidebar-nav-row" role="tablist" aria-label="Sidebar sections"> + <a href="{% if local_ws_id %}/local-workspace?ws={{ local_ws_id }}{% elif local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn nav-tab-btn" data-tab="home" :class="{ active: sidebarTab === 'home' }" @click="setSidebarTab('home')" :aria-selected="sidebarTab === 'home'" role="tab" title="Home"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> - Home + <span class="nav-tab-label" :style="sidebarTab === 'home' ? 'display:inline-block' : 'display:none'">Home</span> </a> - <button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()"> + <button type="button" class="nav-icon-btn nav-tab-btn" data-tab="chat" :class="{ active: sidebarTab === 'chat' }" @click="setSidebarTab('chat')" :aria-selected="sidebarTab === 'chat'" role="tab" title="Chat (Ctrl+J)"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg> + <span class="nav-tab-label" :style="sidebarTab === 'chat' ? 'display:inline-block' : 'display:none'">Chat</span> + <span class="nav-tab-badge" x-show="chatHistory.length" x-cloak x-text="chatHistory.length" :style="sidebarTab === 'chat' ? 'display:none' : ''"></span> </button> - <button class="nav-icon-btn" title="Inbox"> + <button type="button" class="nav-icon-btn nav-tab-btn" data-tab="meetings" :class="{ active: sidebarTab === 'meetings' }" @click="setSidebarTab('meetings')" :aria-selected="sidebarTab === 'meetings'" role="tab" title="Meetings"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> + <span class="nav-tab-label" :style="sidebarTab === 'meetings' ? 'display:inline-block' : 'display:none'">Meeting</span> + <span class="nav-tab-badge" x-show="meetingToday.length" x-cloak x-text="meetingToday.length" :style="sidebarTab === 'meetings' ? 'display:none' : ''"></span> + </button> + <button type="button" class="nav-icon-btn nav-tab-btn" data-tab="inbox" :class="{ active: sidebarTab === 'inbox' }" @click="setSidebarTab('inbox')" :aria-selected="sidebarTab === 'inbox'" role="tab" title="Inbox"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg> + <span class="nav-tab-label" :style="sidebarTab === 'inbox' ? 'display:inline-block' : 'display:none'">Inbox</span> + <span class="nav-tab-badge nav-tab-badge-accent" x-show="inboxUnread > 0" x-cloak x-text="inboxUnread > 99 ? '99+' : inboxUnread" :style="sidebarTab === 'inbox' ? 'display:none' : ''"></span> </button> - <button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;"> + <button type="button" class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> </button> </div> <div class="sidebar-scroll"> + + <!-- ── TAB: Home (workspace tree, recents, favorites, libraries…) ── --> + <div class="sidebar-tab-panel" x-show="sidebarTab === 'home'" x-cloak> <!-- Workspace / Private --> <div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> <div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')"> @@ -571,6 +590,123 @@ </a> </div> + </div><!-- /sidebar-tab-panel home --> + + <!-- ── TAB: Chat (agent shortcuts + conversation history) ── --> + <div class="sidebar-tab-panel" x-show="sidebarTab === 'chat'" x-cloak> + <div class="sb-panel-header"> + <span class="sb-panel-title">Chat</span> + <button type="button" class="sb-panel-action" @click="chatNewConversation()" title="New conversation">{{ fd_icon("plus",14) }}</button> + </div> + + <!-- Circular agent shortcuts --> + <div class="sb-agent-row" x-show="agentList.length"> + <template x-for="a in agentList.slice(0, 8)" :key="a.id"> + <button type="button" class="sb-agent-avatar" :title="a.name" @click="agentOpen(a.id)"> + <span x-text="a.icon || '\u{1F916}'"></span> + </button> + </template> + </div> + + <div class="sidebar-subgroup"> + <div class="sidebar-subgroup-label">Past 30 days</div> + <ul class="sidebar-items"> + <template x-for="c in chatRecent" :key="c.id"> + <li class="sidebar-item" @click="chatOpenConversation(c.id)"> + <span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span> + <span class="page-name" x-text="c.title || 'New conversation'"></span> + </li> + </template> + <li class="sidebar-item empty-hint" x-show="!chatLoading && !chatRecent.length"> + <span class="page-name text-dim">No recent conversations</span> + </li> + </ul> + </div> + + <div class="sidebar-subgroup" x-show="chatOlder.length"> + <div class="sidebar-subgroup-label">Older</div> + <ul class="sidebar-items"> + <template x-for="c in chatOlder" :key="c.id"> + <li class="sidebar-item" @click="chatOpenConversation(c.id)"> + <span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span> + <span class="page-name" x-text="c.title || 'New conversation'"></span> + </li> + </template> + </ul> + </div> + + <div class="sidebar-item empty-hint" x-show="chatLoading" style="padding:8px;"> + <span class="page-name text-dim">Loading…</span> + </div> + </div> + + <!-- ── TAB: Meeting (upcoming events + quick actions) ── --> + <div class="sidebar-tab-panel" x-show="sidebarTab === 'meetings'" x-cloak> + <div class="sb-panel-header"> + <span class="sb-panel-title">Meeting</span> + <button type="button" class="sb-panel-action" @click="loadMeetings(true)" title="Refresh">{{ fd_icon("refresh",14) }}</button> + </div> + + <div class="sb-filter-row"> + <button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button> + <button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button> + </div> + + <div class="sb-quick-actions"> + <button type="button" class="sb-quick-btn" @click="meetingTranscribe()">{{ fd_icon("upload",14) }} Transcribe a podcast</button> + <button type="button" class="sb-quick-btn" @click="meetingNote()">{{ fd_icon("sparkles",14) }} New AI meeting note</button> + <input type="file" x-ref="meetingAudio" accept="audio/*" class="sb-hidden-input" @change="meetingUpload($event)"> + </div> + + <ul class="sidebar-items"> + <template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id"> + <li class="sidebar-item" @click="meetingOpen(ev)"> + <span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span> + <span class="page-name" x-text="ev.title"></span> + <span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span> + </li> + </template> + <li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length"> + <span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span> + </li> + <li class="sidebar-item empty-hint" x-show="meetingsLoading"> + <span class="page-name text-dim">Loading…</span> + </li> + </ul> + </div> + + <!-- ── TAB: Inbox (notifications) ── --> + <div class="sidebar-tab-panel" x-show="sidebarTab === 'inbox'" x-cloak> + <div class="sb-panel-header"> + <span class="sb-panel-title">Inbox <span class="sb-panel-count" x-show="inboxUnread > 0" x-text="inboxUnread"></span></span> + <div style="display:flex;gap:4px;"> + <button type="button" class="sb-panel-action" x-show="inboxUnread > 0" @click="inboxMarkAllRead()" title="Mark all read">{{ fd_icon("check-square",14) }}</button> + <button type="button" class="sb-panel-action" @click="inboxFilter = (inboxFilter === 'all' ? 'unread' : 'all')" + :title="inboxFilter === 'all' ? 'Show unread only' : 'Show all'" x-text="inboxFilter === 'all' ? 'All' : 'Unread'"></button> + <button type="button" class="sb-panel-action" @click="loadInbox()" title="Refresh">{{ fd_icon("refresh",14) }}</button> + </div> + </div> + + <ul class="sidebar-items"> + <template x-for="n in inboxVisible" :key="n.id"> + <li class="sidebar-item" :class="{ 'sb-item-unread': !n.is_read }" @click="inboxOpen(n)"> + <span class="page-icon page-icon-svg">{{ fd_icon("bell",14) }}</span> + <span style="flex:1;min-width:0;"> + <span class="page-name" style="display:block;" x-text="n.title"></span> + <span class="sb-item-sub" x-text="n.message"></span> + </span> + </li> + </template> + <li class="sidebar-item empty-hint" x-show="!inboxLoading && !inboxVisible.length" style="flex-direction:column;align-items:center;gap:4px;padding:22px 8px;"> + <span style="font-size:13px;font-weight:600;color:var(--text-secondary);">Inbox Zero</span> + <span class="page-name text-dim">You’re all caught up</span> + </li> + <li class="sidebar-item empty-hint" x-show="inboxLoading"> + <span class="page-name text-dim">Loading…</span> + </li> + </ul> + </div> + <!-- Footer --> <div class="sidebar-footer"> <div style="display:flex; gap:6px;"> @@ -1128,6 +1264,15 @@ Alpine.data('appState', appState); }); + /** Parse a SQL/ISO timestamp ("2026-10-04 12:00:00") into a ms epoch. + Shared by the sidebar Chat tab (grouping by recency). */ + window.fdTime = function(value) { + if (!value) return 0; + var t = new Date(String(value).replace(' ', 'T')); + if (isNaN(t.getTime())) t = new Date(value); + return isNaN(t.getTime()) ? 0 : t.getTime(); + }; + function appState() { return { init() { @@ -1135,6 +1280,8 @@ this.loadSidebarConfig(); this.loadAgents(); this.loadTeamspaces(); + // Badge counts on the collapsed tabs (lazy fetch, no blocking). + this.loadInbox(); this.initSidebarWidth(); document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar()); this.startClipAutoRefresh(); @@ -1240,6 +1387,176 @@ giteaRepo: '{{ gitea_repo|default("") }}', giteaTree: [], // loaded client-side for sidebar currentView: 'home', + + // ── Collapsible sidebar tabs (Home / Chat / Meeting / Inbox) ── + // The active tab renders as a pill with its label, the others collapse + // to an icon; selecting one replaces the whole sidebar content. + sidebarTab: (function() { + try { + var saved = localStorage.getItem('fd_sidebar_tab'); + return ['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0 ? saved : 'home'; + } catch (e) { return 'home'; } + })(), + setSidebarTab(tab) { + this.sidebarTab = tab; + this.currentView = tab; + try { localStorage.setItem('fd_sidebar_tab', tab); } catch (e) {} + if (tab === 'chat') this.loadChatHistory(); + else if (tab === 'meetings') this.loadMeetings(); + else if (tab === 'inbox') this.loadInbox(); + }, + + // ── Chat tab ── + chatHistory: [], + chatLoading: false, + get chatRecent() { + var cut = Date.now() - 30 * 86400000; + return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) >= cut; }); + }, + get chatOlder() { + var cut = Date.now() - 30 * 86400000; + return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) < cut; }); + }, + loadChatHistory() { + var self = this; + this.chatLoading = true; + return fetch('/api/agent/conversations', { credentials: 'same-origin' }) + .then(function(r) { return r.json(); }) + .then(function(d) { + self.chatHistory = (d && d.conversations) || []; + self.chatLoading = false; + }) + .catch(function() { self.chatLoading = false; }); + }, + chatOpenConversation(id) { + if (window.fdAgent && window.fdAgent.openConversation) window.fdAgent.openConversation(id); + else this.agentOpen(); + }, + chatNewConversation() { + if (window.fdAgent && window.fdAgent.open) window.fdAgent.open(); + else this.agentNew(); + }, + + // ── Meeting tab ── + meetings: [], + meetingsLoading: false, + meetingFilter: 'upcoming', + get meetingVisible() { + if (this.meetingFilter === 'today') { + return this.meetings.filter(function(e) { return e.today; }); + } + return this.meetings.filter(function(e) { return !e.today; }); + }, + get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); }, + loadMeetings(force) { + var self = this; + if (!force && this.meetings.length) return Promise.resolve(); + this.meetingsLoading = true; + return fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : { events: [] }; }) + .then(function(d) { + self.meetings = (d && d.events) || []; + self.meetingsLoading = false; + }) + .catch(function() { self.meetings = []; self.meetingsLoading = false; }); + }, + meetingOpen(ev) { + var target = ev.url || ('/db/' + ev.collection_id); + if (window.fdNavigate) window.fdNavigate(target); + else window.location.href = target; + }, + meetingTranscribe() { + var input = this.$refs && this.$refs.meetingAudio; + if (input) input.click(); + }, + meetingNote() { + if (window.fdAgent && window.fdAgent.ask) { + window.fdAgent.ask('/meeting-note'); + } else if (window.fdAgent && window.fdAgent.open) { + window.fdAgent.open(); + } + }, + meetingUpload(ev) { + var self = this; + var input = ev.target; + var file = input && input.files && input.files[0]; + if (!file) return; + var title = 'Podcast ' + new Date().toISOString().slice(0, 10); + // A transcript row is attached to a page: create the meeting page first, + // then upload the audio against it. + fetch('/api/local-workspace/items', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, + body: JSON.stringify({ name: title, type: 'page' }) + }) + .then(function(r) { if (!r.ok) throw new Error('page'); return r.json(); }) + .then(function(page) { + var fd = new FormData(); + fd.append('audio', file); + fd.append('page_id', String(page.id)); + return fetch('/api/v2/meetings/transcribe', { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRF-Token': self.getCsrfToken() }, + body: fd + }).then(function(r) { + if (!r.ok) throw new Error('transcribe'); + return r.json(); + }).then(function() { return page; }); + }) + .then(function(page) { + self.toast('Audio uploaded — transcription attached to "' + title + '"', 'success'); + window.location.href = '/pages/' + page.id; + }) + .catch(function() { self.toast('Upload failed', 'error'); }) + .then(function() { if (input) input.value = ''; }); + }, + + // ── Inbox tab ── + inboxItems: [], + inboxUnread: 0, + inboxLoading: false, + inboxFilter: 'all', + get inboxVisible() { + if (this.inboxFilter === 'unread') { + return this.inboxItems.filter(function(n) { return !n.is_read; }); + } + return this.inboxItems; + }, + loadInbox() { + var self = this; + this.inboxLoading = true; + return fetch('/api/notifications?limit=50', { credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : { notifications: [], unread: 0 }; }) + .then(function(d) { + self.inboxItems = (d && d.notifications) || []; + self.inboxUnread = (d && d.unread) || 0; + self.inboxLoading = false; + }) + .catch(function() { self.inboxLoading = false; }); + }, + inboxOpen(n) { + if (!n.is_read) { + var self = this; + fetch('/api/notifications/read', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, + body: JSON.stringify({ id: n.id }) + }).then(function() { return self.loadInbox(); }); + } + if (n.url) window.location.href = n.url; + }, + inboxMarkAllRead() { + var self = this; + fetch('/api/notifications/read', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, + body: JSON.stringify({}) + }).then(function() { return self.loadInbox(); }); + }, contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' }, contextActions: {}, showNewPageMenu: false, diff --git a/app/templates/library.html b/app/templates/library.html index 295575b..7aa017b 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -18,7 +18,7 @@ <style> /* ── Library layout ── */ -.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;} +/* .lib-container est défini dans app.css (largeur standard des pages) */ .lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;} .lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;} .lib-header-actions{display:flex;align-items:center;gap:8px;} @@ -408,7 +408,7 @@ <div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()"> <div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div> <template x-if="peekItem"> - <div style="display:flex;flex-direction:column;height:100%;"> + <div style="flex-shrink:0;"> <div class="peek-header"> <button @click="closePeek()" title="Close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> @@ -445,11 +445,17 @@ </div> </div> </div> - <div class="peek-body" style="flex:1;display:flex;flex-direction:column;"> - <iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe> - </div> </div> </template> + <!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en + permanence, ce qui permet de piloter `src` en JS (le build CSP + d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide). + Charger `/pages/{id}` sans `?embed=1` : c'est exactement l'editeur + plein ecran, le peek n'est plus une variante allegee. --> + <div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;"> + <div class="peek-loading" id="lib-peek-loading">Chargement…</div> + <iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe> + </div> </div> </div><!-- /x-data --> diff --git a/app/templates/trash.html b/app/templates/trash.html index 8d360e9..322e120 100644 --- a/app/templates/trash.html +++ b/app/templates/trash.html @@ -11,7 +11,7 @@ </div> </div> -<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;"> +<div x-data="trashData()" class="lib-container"> <!-- Toolbar : recherche + tri + emplacement + purge --> <div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;"> <div style="position:relative; flex:1;"> diff --git a/app/templating.py b/app/templating.py index 1148830..27fd934 100644 --- a/app/templating.py +++ b/app/templating.py @@ -6,10 +6,46 @@ les `|safe` du codebase étaient des no-op. """ from __future__ import annotations +import logging import pathlib from contextvars import ContextVar -from jinja2 import Environment, FileSystemLoader, select_autoescape +from jinja2 import Environment, FileSystemLoader, Undefined, select_autoescape + +logger = logging.getLogger(__name__) + + +class LoggingUndefined(Undefined): + """Undefined qui se rend comme une chaîne vide mais **journalise**. + + v7.46.0 : avec l'`Undefined` par défaut, une variable mal orthographiée + (ou absente du contexte de la route) rendait `""` sans aucune trace — c'est + exactement ce qui a rendu le bug du bouton Home invisible (``local_workspaces`` + absent ⇒ `/workspaces`` en silence). ``StrictUndefined`` est inutilisable ici + (40+ templates optionnelles, ex. ``embed_mode``), donc on garde le rendu et + on rend le silence visible dans les logs. + """ + + __slots__ = () + + def _warn(self) -> None: + logger.warning("Jinja: variable non définie utilisée dans un template: %r", self._undefined_name) + + def __str__(self) -> str: + self._warn() + return "" + + def __bool__(self) -> bool: + self._warn() + return False + + def __iter__(self): + self._warn() + return iter(()) + + def __len__(self) -> int: + self._warn() + return 0 # A20 : nonce de script par requête, posé par le middleware CSP, lu par les # templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP @@ -25,6 +61,7 @@ CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="") ENV = Environment( loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]), + undefined=LoggingUndefined, ) diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 0000000..766e27c --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1,9 @@ +fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0) + +- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient + loovverture et le redimensionnement inoperants -> cblage direct sur le document. +- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, + clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. +- database-table-container margin:0 (tableau colle a gauche, marge Library). +- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. +- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186). diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 3e51a4d..eb52191 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.45.6" + "version": "7.49.0" }, "paths": { "/auth/register": { @@ -1675,7 +1675,7 @@ "dashboard" ], "summary": "Local Workspace Page", - "description": "Local workspace page with file/folder tree.\n\nIf ?folder=ID is provided, shows that folder's contents with breadcrumb.", + "description": "Local workspace page with file/folder tree.\n\nIf ?folder=ID is provided, shows that folder's contents with breadcrumb.\nIf ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton\n« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était\nproduit par le sidebar mais **ignoré** par la route : le contenu affiché\nrestait celui du workspace *actif* (cookie), donc Home pouvait montrer un\nautre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient\npas à l'utilisateur est ignoré (et le workspace actif conservé).", "operationId": "local_workspace_page_local_workspace_get", "parameters": [ { @@ -1686,6 +1686,15 @@ "type": "integer", "title": "Folder" } + }, + { + "name": "ws", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "title": "Ws" + } } ], "responses": { @@ -2419,7 +2428,7 @@ "dashboard" ], "summary": "Delete Workspace", - "description": "Delete a workspace and all its pages.", + "description": "Delete a workspace, its pages and its databases.", "operationId": "delete_workspace_api_workspaces__ws_id__delete", "parameters": [ { @@ -7395,13 +7404,163 @@ } } }, + "/db/task-dbs/api": { + "get": { + "tags": [ + "collections" + ], + "summary": "List Connected Task Databases", + "description": "Bases de tâches connectées au tableau de bord My Tasks.", + "operationId": "list_connected_task_databases_db_task_dbs_api_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/db/{collection_id}/task-db/api": { + "get": { + "tags": [ + "collections" + ], + "summary": "Get Task Db State", + "description": "État de conversion + colonnes candidates pour la modale.", + "operationId": "get_task_db_state_db__collection_id__task_db_api_get", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Collection Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "collections" + ], + "summary": "Enable Task Db", + "description": "Convertit la base en base de tâches (mapping explicite des 3 rôles).\n\n``mapping`` : ``{\"assignee\": <prop_id>, \"status\": …, \"due\": …}``.\n``create_missing`` : ``{\"status\": {\"name\": \"Statut\", \"options\": [...]}}``\npour qu'une colonne absente soit créée dans la transaction.", + "operationId": "enable_task_db_db__collection_id__task_db_api_post", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Collection Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "collections" + ], + "summary": "Disable Task Db", + "description": "Déconnecte la base du tableau de bord (les tâches sont conservées).", + "operationId": "disable_task_db_db__collection_id__task_db_api_delete", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Collection Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/db": { "get": { "tags": [ "collections" ], "summary": "List Collections", - "description": "Page listing all collections in the workspace.", + "description": "Liste des bases du workspace, dans le layout de l'application.", "operationId": "list_collections_db_get", "responses": { "200": { @@ -9310,7 +9469,7 @@ "collections" ], "summary": "Toggle Task", - "description": "API: toggle is_task flag on a collection (Turn into Tasks).", + "description": "API: toggle is_task flag on a collection (Turn into Tasks).\n\nRétrocompatible : l'activation passe désormais par la même conversion que\n``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées\n(créées si besoin). Sans cela, cette route laissait une base « connectée »\nmais muette — elle n'émettait aucune tâche faute de mapping.", "operationId": "toggle_task_db__collection_id__toggle_task_api_put", "parameters": [ { @@ -9830,7 +9989,7 @@ "my-tasks" ], "summary": "My Tasks Dashboard", - "description": "My Tasks — aggregates all pages assigned to the current user across all collections.", + "description": "My Tasks — toutes vos tâches, tous workspaces confondus.", "operationId": "my_tasks_dashboard_my_tasks_get", "parameters": [ { @@ -9839,18 +9998,38 @@ "required": false, "schema": { "type": "string", - "default": "all", + "default": "table", "title": "View" } }, { - "name": "days", + "name": "due", "in": "query", "required": false, "schema": { - "type": "integer", - "default": 7, - "title": "Days" + "type": "string", + "default": "all", + "title": "Due" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "due", + "title": "Sort" + } + }, + { + "name": "hide_done", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Hide Done" } } ], @@ -9884,7 +10063,7 @@ "my-tasks" ], "summary": "My Tasks Api", - "description": "API: return my tasks as JSON.", + "description": "API: tâches normalisées + sources connectées (alimente les 3 vues).", "operationId": "my_tasks_api_my_tasks_api_get", "parameters": [ { @@ -9893,18 +10072,38 @@ "required": false, "schema": { "type": "string", - "default": "all", + "default": "table", "title": "View" } }, { - "name": "days", + "name": "due", "in": "query", "required": false, "schema": { - "type": "integer", - "default": 7, - "title": "Days" + "type": "string", + "default": "all", + "title": "Due" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "due", + "title": "Sort" + } + }, + { + "name": "hide_done", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Hide Done" } } ], @@ -9930,6 +10129,99 @@ } } }, + "/my-tasks/api/task": { + "post": { + "tags": [ + "my-tasks" + ], + "summary": "My Tasks Create", + "description": "Création rapide multi-destinations : la tâche part dans la base choisie.\n\nElle est **auto-assignée** : sans colonne « Assigné à » renseignée, une\ntâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait\nillisible pour l'utilisateur qui vient de la créer.", + "operationId": "my_tasks_create_my_tasks_api_task_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/my-tasks/api/task/{page_id}": { + "put": { + "tags": [ + "my-tasks" + ], + "summary": "My Tasks Update", + "description": "Édition en direct du statut / de l'échéance / du titre.\n\nL'écriture passe par le **mapping enregistré de la base source** (et non\npar une devinette « première colonne de type X »), puis la tâche est relue\net renvoyée normalisée : le front n'a rien à recalculer.", + "operationId": "my_tasks_update_my_tasks_api_task__page_id__put", + "parameters": [ + { + "name": "page_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Page Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/workspace/{ws_id}/members": { "get": { "tags": [ @@ -10361,6 +10653,7 @@ "workspace" ], "summary": "List Favorites", + "description": "List the caller's favorites.\n\nv7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table\n``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``\nréférence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)\nlevait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.", "operationId": "list_favorites_workspace_favorites_get", "responses": { "200": { @@ -11579,7 +11872,7 @@ "/workspace/public/{collection_id}": { "get": { "tags": [ - "workspace" + "workspace-public" ], "summary": "Public View", "description": "Simple public read-only view — no auth required.\n\nA18 : les bases ``restricted``/``private`` (``permission_type``) restent\nmasquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké\nsur le titre de la base ou d'une ligne).", @@ -27600,6 +27893,48 @@ } } }, + "/api/v2/meetings/upcoming": { + "get": { + "tags": [ + "calendar-meetings" + ], + "summary": "Upcoming Meetings", + "description": "Upcoming calendar events for the sidebar Meeting tab.\n\nReads every calendar collection linked to the current user, extracts the\ndate property of each linked row and returns the ones falling in the next\n``days`` days (today included), sorted chronologically. Rows imported from\nGoogle/CalDAV carry an ``external_event_id`` and are flagged ``synced``.", + "operationId": "upcoming_meetings_api_v2_meetings_upcoming_get", + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "title": "Days" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v2/meetings/transcribe": { "post": { "tags": [ diff --git a/e2e/sidebar_tabs.spec.js b/e2e/sidebar_tabs.spec.js new file mode 100644 index 0000000..6822cb7 --- /dev/null +++ b/e2e/sidebar_tabs.spec.js @@ -0,0 +1,182 @@ +const { test, expect } = require('@playwright/test'); + +/** + * Sidebar — navigation par onglets compressibles (Home / Chat / Meeting / Inbox). + * + * - ordre exact des onglets ; + * - l'onglet actif est une pilule avec son libellé, les autres sont réduits à + * l'icône (largeur mesurée) ; + * - chaque sélection remplace le contenu de la barre latérale ; + * - les panneaux Chat / Meeting / Inbox interrogent leurs vraies API. + */ +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +test.use({ serviceWorkers: 'block' }); + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + try { + await page.waitForURL('**/workspaces', { timeout: 8000 }); + } catch (e) { + // Compte absent de l'instance (harnais de test documenté du repo, comme + // smoke.spec.js) : on le crée puis on rejoue la connexion. + const resp = await page.request.post(`${FD_BASE}/auth/register`, { + data: { email: USER, password: PASS, name: 'E2E' }, + }); + if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`); + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + await page.waitForURL('**/workspaces', { timeout: 15000 }); + } +} + +const panelText = (page, tab) => + page.evaluate( + (t) => { + const el = document.querySelector(`.sidebar-tab-panel[x-show*="'${t}'"]`); + if (!el) return null; + const hidden = el.offsetParent === null; + return { hidden, text: (el.innerText || '').trim().slice(0, 200) }; + }, + tab + ); + +test('les quatre onglets se succèdent et le contenu bascule', async ({ page }) => { + await login(page); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.sidebar-nav-row'); + + // ordre des onglets + const order = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => + els.map((e) => e.getAttribute('data-tab')) + ); + expect(order).toEqual(['home', 'chat', 'meetings', 'inbox']); + + // Home actif par défaut : pilule (libellé visible), les autres réduite à l'icône + let labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => + els.map((e) => ({ + tab: e.getAttribute('data-tab'), + active: e.classList.contains('active'), + labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, + width: Math.round(e.getBoundingClientRect().width), + })) + ); + expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true); + labels.filter((l) => l.tab !== 'home').forEach((l) => { + expect(l.active).toBe(false); + expect(l.labelVisible).toBe(false); + }); + const homeWidth = labels.find((l) => l.tab === 'home').width; + const chatWidth = labels.find((l) => l.tab === 'chat').width; + expect(homeWidth).toBeGreaterThan(chatWidth + 15); + + // Chat : le panneau Home disparaît, celui de Chat est visible + await page.click('.sidebar-nav-row [data-tab="chat"]'); + await page.waitForTimeout(400); + expect((await panelText(page, 'home')).hidden).toBe(true); + const chat = await panelText(page, 'chat'); + expect(chat.hidden).toBe(false); + expect(chat.text).toMatch(/CHAT/i); + + labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => + els.map((e) => ({ + tab: e.getAttribute('data-tab'), + active: e.classList.contains('active'), + labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, + })) + ); + expect(labels.find((l) => l.tab === 'chat')).toEqual({ + tab: 'chat', + active: true, + labelVisible: true, + }); + + // Meeting + await page.click('.sidebar-nav-row [data-tab="meetings"]'); + await page.waitForTimeout(400); + const meetings = await panelText(page, 'meetings'); + expect(meetings.hidden).toBe(false); + expect(meetings.text).toMatch(/MEETING/i); + expect(await page.locator('.sb-quick-btn').count()).toBe(2); + + // Inbox + await page.click('.sidebar-nav-row [data-tab="inbox"]'); + await page.waitForTimeout(400); + const inbox = await panelText(page, 'inbox'); + expect(inbox.hidden).toBe(false); + expect(inbox.text).toMatch(/INBOX/i); + + // retour Home : l'arbre du workspace est de nouveau là, et UNIQUEMENT Home + // porte son libellé (régression : clic Home → texte sur tous les boutons) + await page.click('.sidebar-nav-row [data-tab="home"]'); + await page.waitForTimeout(300); + expect((await panelText(page, 'home')).hidden).toBe(false); + await expect(page.locator('#sidebar-workspace-items, .sidebar-static-links').first()).toBeVisible(); + labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => + els.map((e) => ({ + tab: e.getAttribute('data-tab'), + labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, + })) + ); + expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true); + labels.filter((l) => l.tab !== 'home').forEach((l) => { + expect(l.labelVisible).toBe(false); + }); +}); + +test('le libellé suit l’onglet actif même avec une CSS périmée (cache)', async ({ page }) => { + // ?v={{ asset_version }} = fichier VERSION : une feuille de style peut donc + // être servie depuis le cache du navigateur. On rejoue ce cas en neutralisant + // les règles .nav-tab-label de la feuille courante. + await page.route('**/static/css/app.css*', async (route) => { + const res = await route.fetch(); + let body = await res.text(); + body = body.replace(/\.nav-tab-btn \.nav-tab-label \{[^}]*\}/g, ''); + body = body.replace(/\.nav-tab-btn\.active \.nav-tab-label \{[^}]*\}/g, ''); + body = body.replace(/\.nav-tab-btn\.active \.nav-tab-badge \{[^}]*\}/g, ''); + await route.fulfill({ response: res, body }); + }); + + await login(page); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.sidebar-nav-row'); + const visible = async () => + page.$$eval('.sidebar-nav-row [data-tab]', (els) => + els.map((e) => ({ + tab: e.getAttribute('data-tab'), + labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, + })) + ); + + // CSS sans les règles de libellé → seul l'onglet actif garde son texte + for (const active of ['home', 'chat', 'meetings', 'inbox']) { + await page.click(`.sidebar-nav-row [data-tab="${active}"]`); + await page.waitForTimeout(250); + const state = await visible(); + expect(state.filter((s) => s.labelVisible).map((s) => s.tab)).toEqual([active]); + } +}); + +test('le panneau Meeting appelle /api/v2/meetings/upcoming', async ({ page }) => { + const calls = []; + page.on('request', (r) => { + if (r.url().includes('/api/v2/meetings/upcoming')) calls.push(r.url()); + }); + await login(page); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.click('.sidebar-nav-row [data-tab="meetings"]'); + await page.waitForResponse((r) => r.url().includes('/api/v2/meetings/upcoming'), { timeout: 10000 }); + expect(calls.length).toBeGreaterThan(0); + const status = await page.evaluate(async () => { + const r = await fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' }); + return r.status; + }); + expect(status).toBe(200); +}); \ No newline at end of file diff --git a/static/css/app.css b/static/css/app.css index 6e23f9a..223c6dd 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -433,9 +433,12 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; } padding: 6px 6px; margin: 0 2px; } .nav-icon-btn { + position: relative; display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 6px; color: var(--text-secondary); transition: background 0.15s, color 0.15s; + border: none; background: none; cursor: pointer; font-family: inherit; + text-decoration: none; } .nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); } .nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); } @@ -447,6 +450,91 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; } /* Collapse button on the right */ .nav-icon-btn.collapse-btn { margin-left: auto; } +/* ── Collapsible tabs (Home / Chat / Meeting / Inbox) ── + The active tab expands into a pill showing icon + label, the inactive ones + collapse to the bare icon so the row stays compact. */ +.nav-tab-btn { + gap: 0; + padding: 6px; + min-width: 30px; + justify-content: center; + border-radius: 15px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} +.nav-tab-btn .nav-tab-label { + display: none; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; +} +.nav-tab-btn.active { + background: var(--bg-active); + color: var(--text-primary); + gap: 7px; + padding: 6px 12px 6px 10px; +} +.nav-tab-btn.active .nav-tab-label { display: inline-block; } +.nav-tab-badge { + position: absolute; + top: -1px; right: -1px; + min-width: 15px; height: 15px; + padding: 0 4px; + border-radius: 8px; + background: var(--text-dim); + color: var(--bg-primary); + font-size: 10px; font-weight: 700; line-height: 15px; + text-align: center; +} +.nav-tab-badge-accent { background: #E03E3E; color: #fff; } +.nav-tab-btn.active .nav-tab-badge { display: none; } + +/* Tab panels — one replaces the whole sidebar content at a time */ +.sidebar-tab-panel { display: flex; flex-direction: column; gap: 2px; } +.sb-panel-header { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px 4px; +} +.sb-panel-title { font-size: 12px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; } +.sb-panel-count { color: var(--text-secondary); font-weight: 600; } +.sb-panel-action { + display: inline-flex; align-items: center; justify-content: center; + min-width: 22px; height: 22px; padding: 0 5px; + border: none; border-radius: 5px; background: none; + color: var(--text-secondary); font-size: 11px; cursor: pointer; +} +.sb-panel-action:hover { background: var(--bg-hover); color: var(--text-primary); } +.sb-filter-row { display: flex; gap: 4px; padding: 2px 8px 6px; } +.sb-filter-chip { + padding: 3px 10px; border: 1px solid var(--border); border-radius: 12px; + background: none; color: var(--text-secondary); + font-size: 11px; cursor: pointer; +} +.sb-filter-chip.active { background: var(--bg-active); color: var(--text-primary); border-color: transparent; } +.sb-quick-actions { display: flex; flex-direction: column; gap: 2px; padding: 2px 6px 8px; } +.sb-quick-btn { + display: flex; align-items: center; gap: 8px; + padding: 5px 8px; border: none; border-radius: 6px; background: none; + color: var(--text-secondary); font-size: 12px; text-align: left; cursor: pointer; +} +.sb-quick-btn:hover { background: var(--bg-hover); color: var(--text-primary); } +.sb-hidden-input { display: none; } +.sb-agent-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; } +.sb-agent-avatar { + width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--border); + background: var(--bg-hover); cursor: pointer; font-size: 15px; line-height: 1; + display: flex; align-items: center; justify-content: center; +} +.sb-agent-avatar:hover { border-color: var(--accent); } +.sb-item-meta { margin-left: auto; font-size: 11px; color: var(--text-dim); flex-shrink: 0; } +.sb-item-sub { + display: block; font-size: 11px; color: var(--text-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sb-item-unread { background: rgba(35, 131, 226, .10); } + /* Empty hint */ .sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; } .sidebar-item.empty-hint:hover { background: none; } @@ -1042,6 +1130,11 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; } min-width: 0; } +/* Conteneur standard des pages listes (Library, Trash, …) — largeur unique + 1100px, centrée. Défini ici (global) et non dans le <style> de + library.html, sinon /trash rendait la classe sans style = pleine largeur. */ +.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;} + /* Topbar */ .topbar { display: flex; @@ -3779,11 +3872,14 @@ button.breadcrumb-current { max-width: 260px; } Database Table View (Notion-style) ═══════════════════════════════════════════════════════════ */ +/* Base = viewport du contenu ; les vues tabulaires larges (bases de + données) s'alignent à gauche, au ras de la marge, à la même largeur + que Library (1100px). */ .database-table-container { width: 100%; - max-width: 100%; - padding: 0 96px; - margin-top: 24px; + max-width: 1100px; + margin: 0; + padding: 0 24px; box-sizing: border-box; } @@ -4297,6 +4393,7 @@ button.breadcrumb-current { max-width: 260px; } /* ── Responsive ── */ @media (max-width: 768px) { .database-table-container { padding: 0 16px; } + .db-side-peek { width: 100vw; max-width: 100vw; } .db-th-property { min-width: 120px; } .db-td-cell { min-width: 120px; } } @@ -4304,22 +4401,27 @@ button.breadcrumb-current { max-width: 260px; } /* ── Database Side Peek Panel ── */ .db-side-peek { position: fixed; top: 0; right: 0; - width: 560px; height: 100vh; - background: var(--bg-primary); - box-shadow: -4px 0 20px rgba(0,0,0,0.12); - z-index: 900; + width: 560px; max-width: 90vw; height: 100vh; + background: var(--bg-modal); + border-left: 1px solid var(--border); + box-shadow: -8px 0 32px rgba(0,0,0,0.25); + z-index: 500; display: flex; flex-direction: column; + transform: translateX(100%); + transition: transform 0.2s cubic-bezier(0.16,1,0.3,1); } -.db-side-peek-resize { - position: absolute; top: 0; left: 0; - width: 6px; height: 100%; - cursor: col-resize; - z-index: 2; +.db-side-peek.open { transform: translateX(0); } +/* Poignée partagée des side peeks (bases) — les autres peeks portent la + même géométrie en style en ligne. */ +.peek-resize-handle { + position: absolute; left: -4px; top: 0; + width: 8px; height: 100%; cursor: col-resize; + z-index: 501; } -.db-side-peek-resize:hover { background: var(--accent, #2383E2); } +.peek-resize-handle:hover { background: var(--accent, #2383E2); } .db-side-peek-header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; border-bottom: 1px solid var(--border); + padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; } .db-side-peek-close, .db-side-peek-full { @@ -4329,7 +4431,13 @@ button.breadcrumb-current { max-width: 260px; } font-size: 18px; color: var(--text-dim); } .db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); } -.db-side-peek iframe { flex: 1; } +.db-side-peek-body { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.db-side-peek-loading { text-align: center; padding: 40px; color: var(--text-dim); } +.db-side-peek iframe { flex: 1; min-height: 0; } + +@media (max-width: 768px) { + .db-side-peek { width: 100vw; max-width: 100vw; } +} /* ─── Simple Table block (v4.8.0) ─── */ .ftable-editor { flex: 1; min-width: 0; min-height: 0; padding: 4px 2px; } diff --git a/static/css/my_tasks.css b/static/css/my_tasks.css new file mode 100644 index 0000000..8513054 --- /dev/null +++ b/static/css/my_tasks.css @@ -0,0 +1,646 @@ +/* ══════════════════════════════════════════════════════════════════════ + * FlowDeck — My Tasks (tableau de bord transverse façon Notion) + * Préfixe `mt-`, conteneur `#my-tasks-app` (rendu par app/routers/my_tasks.py, + * comportement par static/js/my_tasks.js). + * ══════════════════════════════════════════════════════════════════════ */ + +.my-tasks-app { max-width: 1100px; margin: 0 auto; padding: 20px 24px 64px; } + +/* ── En-tête ──────────────────────────────────────────────────────────── */ +.mt-head { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 16px; +} +.mt-head h1 { + margin: 0; font-size: 24px; font-weight: 700; color: var(--text-primary); +} +.mt-count-total { font-size: 13px; color: var(--text-secondary); } + +/* ── Barre d'outils ─────────────────────────────────────────────────── */ +.mt-toolbar { + display: flex; flex-wrap: wrap; gap: 12px; align-items: center; + justify-content: space-between; margin-bottom: 10px; +} +.mt-toolbar-left, +.mt-toolbar-right { + display: flex; flex-wrap: wrap; gap: 8px; align-items: center; +} +.mt-view-tabs { display: flex; gap: 2px; align-items: center; } +.mt-view-tab { + display: flex; align-items: center; gap: 6px; + padding: 6px 12px; font-size: 13px; font-weight: 500; + color: var(--text-secondary); background: transparent; + border: none; border-radius: 6px; cursor: pointer; + text-decoration: none; white-space: nowrap; transition: all .12s; +} +.mt-view-tab:hover { color: var(--text-primary); background: var(--bg-hover); } +.mt-view-tab.active { color: var(--text-primary); background: var(--bg-active); } +.mt-select { + font: inherit; font-size: 13px; padding: 5px 8px; border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-primary); color: var(--text-primary); +} +.mt-toggle { + font-size: 13px; color: var(--text-secondary); + display: inline-flex; align-items: center; gap: 5px; cursor: pointer; +} +.mt-new-btn, +.mt-empty-btn { + font: inherit; font-size: 13px; font-weight: 600; + padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; + text-decoration: none; display: inline-block; + background: var(--accent, #2383e2); color: #fff; +} +.mt-new-btn:hover, +.mt-empty-btn:hover { filter: brightness(1.08); } + +/* ── Sources connectées ─────────────────────────────────────────────── */ +.mt-sources { + display: flex; flex-wrap: wrap; gap: 6px; align-items: center; + padding: 8px 0 14px; margin-bottom: 16px; + border-bottom: 1px solid var(--border, #eee); +} +.mt-sources-label { font-size: 12px; color: var(--text-dim, #999); margin-right: 4px; } +.mt-source-chip { + font-size: 12px; padding: 3px 9px; border-radius: 20px; text-decoration: none; + border: 1px solid var(--border, #e3e3e0); + color: var(--text, #333); background: var(--bg-primary, #fff); +} +.mt-source-chip.pending { border-style: dashed; color: var(--text-dim, #999); } +.mt-source-chip:hover { border-color: var(--accent, #2383e2); } +.mt-sources-warn { font-size: 12px; color: #c9820a; } + +/* ── Vue tableau ────────────────────────────────────────────────────────── + * Lignes en `div` (et non `<table>`) : meme choix que Library, qui le permet + * d'afficher les actions de ligne UNIQUEMENT au survol (`.hover-only`). + */ +.mt-table { width: 100%; } +.mt-thead { + display: flex; align-items: center; border-bottom: 1px solid var(--border); +} +.mt-th { + display: flex; align-items: center; padding: 6px 8px; + font-size: 12px; font-weight: 500; color: var(--text-dim); + white-space: nowrap; user-select: none; +} +.mt-row { + display: flex; align-items: center; height: 38px; + border-bottom: 1px solid var(--border); transition: background .1s; +} +.mt-row:last-child { border-bottom: none; } +.mt-row:hover { background: var(--bg-hover); } +.mt-cell { + display: flex; align-items: center; padding: 0 8px; + height: 100%; overflow: hidden; +} +.mt-cell-name { flex: 1; min-width: 0; gap: 6px; } +.mt-cell-status, +.mt-cell-due { width: 165px; flex-shrink: 0; } +.mt-cell-src { width: 210px; flex-shrink: 0; } +.mt-icon { + font-size: 15px; flex-shrink: 0; width: 20px; text-align: center; + display: inline-flex; align-items: center; justify-content: center; +} +.mt-title-text { + font-size: 13px; color: var(--text-primary); cursor: text; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; flex: 1 1 auto; +} +.mt-title-text:hover { text-decoration: underline; } +.mt-title-input { + font: inherit; width: 100%; padding: 3px 6px; border-radius: 4px; + border: 1px solid var(--accent); + background: var(--bg-primary); color: var(--text-primary); +} +.mt-status-select, +.mt-due-input { + font: inherit; font-size: 13px; padding: 4px 6px; border-radius: 5px; + border: 1px solid var(--border); + background: var(--bg-primary); color: var(--text-primary); + max-width: 100%; width: 100%; +} +.mt-due-overdue { border-color: #e05252; color: #e05252; } +.mt-due-today { border-color: #c9820a; color: #c9820a; font-weight: 600; } +.mt-chip { + font-size: 12px; padding: 3px 8px; border-radius: 5px; text-decoration: none; + background: var(--bg-hover); color: var(--text-secondary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.mt-chip:hover { color: var(--accent); } +.mt-static { font-size: 13px; color: var(--text-secondary); } + +/* ── Bouton « Open » + actions visibles au survol ────────────────────────── + * Mêmes valeurs que `.btn-open` / `.hover-only` de Library + * (`app/templates/library.html`) : même aspect, même révélation au survol. + */ +.mt-hover-only { opacity: 0; transition: opacity .12s; flex-shrink: 0; } +.mt-row:hover .mt-hover-only, +.mt-row:focus-within .mt-hover-only { opacity: 1; } +.mt-open-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; + font-size: 11px; font-weight: 500; color: var(--text-primary); + background: var(--bg-tertiary); cursor: pointer; white-space: nowrap; + transition: all .1s; flex-shrink: 0; text-decoration: none; margin-left: auto; +} +.mt-open-btn:hover { background: var(--bg-active); } +.mt-open-btn svg { width: 12px; height: 12px; } +@media (hover: none) { + /* Écrans tactiles : pas de survol, l'action doit rester visible. */ + .mt-hover-only { opacity: 1; } +} + +/* ── Side peek (panneau latéral, sur le modèle de Library) ─────────────── + * `?embed=1` charge l'éditeur du document dans l'iframe, comme le peek de + * Library : on lit la tâche sans quitter le tableau de bord. + */ +.mt-peek { + position: fixed; top: 0; right: 0; width: 560px; max-width: 90vw; + height: 100vh; z-index: 500; display: flex; flex-direction: column; + background: var(--bg-modal); border-left: 1px solid var(--border); + box-shadow: -8px 0 32px rgba(0, 0, 0, .25); + transform: translateX(100%); transition: transform .2s cubic-bezier(.16, 1, .3, 1); +} +.mt-peek.open { transform: translateX(0); } +.mt-peek-handle { + position: absolute; left: -4px; top: 0; width: 8px; height: 100%; + cursor: col-resize; z-index: 501; +} +.mt-peek-head { + display: flex; align-items: center; gap: 4px; flex-shrink: 0; + padding: 12px 16px; border-bottom: 1px solid var(--border); +} +.mt-peek-title { + flex: 1; min-width: 0; font-size: 15px; font-weight: 600; + color: var(--text-primary); display: flex; align-items: center; gap: 8px; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; +} +.mt-peek-btn { + background: transparent; border: none; color: var(--text-secondary); + cursor: pointer; padding: 6px; border-radius: 4px; + display: inline-flex; align-items: center; +} +.mt-peek-btn:hover { background: var(--bg-hover); color: var(--text-primary); } +.mt-peek-btn svg { width: 15px; height: 15px; } +.mt-peek-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.mt-peek-body iframe { flex: 1; width: 100%; border: none; min-height: 0; } +.mt-peek-loading { + padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px; +} +@media (max-width: 768px) { + .mt-peek { width: 100vw; max-width: 100vw; } +} + +/* ── Vue kanban ─────────────────────────────────────────────────────── */ +.mt-board { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; } +.mt-col { + min-width: 240px; flex: 1 0 240px; padding: 10px; border-radius: 10px; + background: var(--bg-secondary, #f7f7f5); +} +.mt-col-head { font-size: 12px; font-weight: 600; color: var(--text-dim, #777); margin-bottom: 8px; } +.mt-count { opacity: .6; font-weight: 400; } +.mt-col-body { display: flex; flex-direction: column; gap: 6px; min-height: 60px; } +.mt-card { + display: flex; flex-direction: column; gap: 4px; padding: 9px 10px; + border: 1px solid var(--border, #e3e3e0); border-radius: 7px; + background: var(--bg-primary, #fff); font-size: 13px; cursor: grab; +} +.mt-card:active { cursor: grabbing; opacity: .7; } +.mt-card-title { font-weight: 500; } +.mt-card-due, +.mt-card-src { font-size: 11px; color: var(--text-dim, #999); } +.mt-card-src { opacity: .8; } + +/* ── Vue calendrier ─────────────────────────────────────────────────── */ +.mt-cal { + border: 1px solid var(--border, #eee); border-radius: 10px; overflow: hidden; +} +.mt-cal-head { + display: flex; align-items: center; justify-content: center; gap: 12px; + padding: 10px; border-bottom: 1px solid var(--border, #eee); +} +.mt-cal-title { font-size: 14px; font-weight: 600; text-transform: capitalize; } +.mt-cal-nav { + padding: 2px 9px; border: 1px solid var(--border, #e3e3e0); border-radius: 5px; + background: var(--bg-primary, #fff); color: var(--text, #333); + font-size: 15px; cursor: pointer; +} +.mt-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); } +.mt-cal-dow { + padding: 6px 0; text-align: center; font-size: 11px; + color: var(--text-dim, #999); border-bottom: 1px solid var(--border, #eee); +} +.mt-cal-cell { + min-height: 92px; padding: 5px 6px; font-size: 12px; + border-right: 1px solid var(--border, #f0f0f0); + border-bottom: 1px solid var(--border, #f0f0f0); +} +.mt-cal-cell.out { opacity: .4; } +.mt-cal-cell.today { background: var(--bg-hover, rgba(35, 131, 226, .07)); } +.mt-cal-day { margin-bottom: 4px; font-size: 11px; color: var(--text-dim, #999); } +.mt-cal-ev:hover { background: var(--bg-hover); } +.mt-cal-ev:hover .mt-cal-open { opacity: 1; } + +/* ── Calendrier : sélecteur de vue + 4 vues supplémentaires ───────────── */ +.mt-cal-body { padding: 8px; } +.mt-cal-tabs { + display: flex; gap: 2px; padding: 6px 8px 0; + border-bottom: 1px solid var(--border); +} +.mt-cal-tab { + padding: 5px 11px; font-size: 12.5px; font-weight: 500; + color: var(--text-secondary); background: transparent; + border: none; border-bottom: 2px solid transparent; + cursor: pointer; transition: all .12s; +} +.mt-cal-tab:hover { color: var(--text-primary); } +.mt-cal-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); } + +/* Événement : titre + bouton d'ouverture révélé au survol. */ +.mt-cal-ev { + display: flex; align-items: center; gap: 4px; + margin-bottom: 3px; padding: 2px 5px; border-radius: 4px; + background: var(--bg-hover); font-size: 12px; min-width: 0; +} +.mt-cal-ev.drag { cursor: grab; } +.mt-cal-ev-text { + flex: 1; min-width: 0; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; +} +.mt-cal-open { + flex-shrink: 0; display: inline-flex; align-items: center; + padding: 1px; border: none; border-radius: 3px; cursor: pointer; + background: transparent; color: var(--text-secondary); +} +.mt-cal-open svg { width: 11px; height: 11px; display: block; } +.mt-cal-open:hover { background: var(--bg-active); color: var(--text-primary); } + +/* Vue année : 12 mini-mois. */ +.mt-cal-year { + display: grid; grid-template-columns: repeat(4, 1fr); + gap: 14px; padding: 10px; +} +.mt-cal-mini-title { + font-size: 12px; font-weight: 600; color: var(--text-secondary); + text-transform: capitalize; margin-bottom: 5px; +} +.mt-cal-mini-grid { + display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; +} +.mt-cal-mini-dow { + font-size: 9px; color: var(--text-dim); text-align: center; padding: 1px 0; +} +.mt-cal-mini-day { + font: inherit; font-size: 10.5px; padding: 3px 0; border: none; + border-radius: 4px; cursor: pointer; position: relative; + background: transparent; color: var(--text-primary); +} +.mt-cal-mini-day:hover { background: var(--bg-hover); } +.mt-cal-mini-day.out { visibility: hidden; } +.mt-cal-mini-day.today { background: var(--accent); color: #fff; } +.mt-cal-mini-day.busy::after { + content: ''; position: absolute; left: 50%; bottom: 1px; + width: 3px; height: 3px; margin-left: -1.5px; border-radius: 50%; + background: var(--accent); +} +.mt-cal-mini-day.today.busy::after { background: #fff; } + +/* Vue semaine : 7 colonnes. */ +.mt-cal-week { + display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; + background: var(--border); min-height: 180px; +} +.mt-cal-wcol { background: var(--bg-primary); padding: 6px; min-width: 0; } +.mt-cal-wcol.today { background: var(--bg-hover); } +.mt-cal-wcol-head { + font-size: 11px; font-weight: 600; color: var(--text-secondary); + margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border); +} +.mt-cal-wcol-body { display: flex; flex-direction: column; gap: 3px; } +.mt-cal-wempty { font-size: 12px; color: var(--text-dim); text-align: center; } + +/* Vue jour : liste détaillée. */ +.mt-cal-dayview { padding: 10px 12px 14px; } +.mt-cal-dayview-head { + font-size: 12px; color: var(--text-secondary); margin-bottom: 10px; +} +.mt-cal-dayview-empty { + font-size: 13px; color: var(--text-dim); padding: 26px 0; text-align: center; +} +.mt-cal-task { + display: flex; align-items: center; gap: 10px; + padding: 7px 8px; border-bottom: 1px solid var(--border); +} +.mt-cal-task:hover { background: var(--bg-hover); } +.mt-cal-task-title { + flex: 1; min-width: 0; font-size: 13px; color: var(--text-primary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.mt-cal-task-src, .mt-cal-task-status { font-size: 12px; color: var(--text-dim); } +.mt-cal-task-status { width: 130px; flex-shrink: 0; } + +/* Vue planning : colonnes = statuts, lignes = jours. */ +.mt-cal-plan { overflow-x: auto; max-height: 60vh; overflow-y: auto; } +.mt-cal-plan-row { display: flex; align-items: stretch; min-width: 520px; } +.mt-cal-plan-row.head { position: sticky; top: 0; z-index: 2; background: var(--bg-primary); } +.mt-cal-plan-gutter { + width: 44px; flex-shrink: 0; padding: 4px 6px; + font-size: 11px; color: var(--text-dim); text-align: right; + border-bottom: 1px solid var(--border); +} +.mt-cal-plan-col-title { + flex: 1; min-width: 0; padding: 4px 8px; + font-size: 11px; font-weight: 600; color: var(--text-secondary); + border-bottom: 1px solid var(--border); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.mt-cal-plan-cell { + flex: 1; min-width: 0; padding: 3px 5px; + border-bottom: 1px solid var(--border); + border-left: 1px solid var(--border); +} +.mt-cal-plan-row.today .mt-cal-plan-gutter { + color: var(--accent); font-weight: 700; +} +@media (max-width: 768px) { + .mt-cal-year { grid-template-columns: repeat(2, 1fr); } +} + +/* ── États vides ────────────────────────────────────────────────────────── + * Bloc centré dans la surface disponible : invite de conversion + * (`.my-tasks-empty`, rendu serveur) comme états vides du tableau de bord + * (`.mt-empty`, rendu client). Même gabarit pour que les deux se ressemblent. + */ +.my-tasks-empty, +.mt-empty { + display: flex; flex-direction: column; align-items: center; + text-align: center; padding: 72px 24px 80px; + max-width: 620px; margin: 0 auto; +} +.my-tasks-empty-icon, +.mt-empty-icon { + display: flex; align-items: center; justify-content: center; + width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px; + border-radius: 50%; + background: var(--bg-hover, rgba(0, 0, 0, .04)); + border: 1px solid var(--border, #e3e3e0); +} +.my-tasks-empty h2, +.mt-empty h2 { + margin: 0 0 10px; font-size: 19px; font-weight: 600; color: var(--text-primary, #333); +} +.my-tasks-empty p, +.mt-empty p { + margin: 0 0 8px; font-size: 14px; line-height: 1.6; + color: var(--text-secondary, #666); +} +.my-tasks-empty-hint, +.mt-empty-hint { + font-size: 13px; color: var(--text-dim, #888); +} +.my-tasks-empty-steps, +.mt-empty-steps { + margin: 20px 0 0; padding: 14px 18px; list-style: none; + display: inline-flex; flex-direction: column; gap: 8px; text-align: left; + border: 1px solid var(--border, #e3e3e0); + border-radius: 10px; background: var(--bg-secondary, #fafafa); + font-size: 13px; color: var(--text-secondary, #666); +} +.my-tasks-empty-steps li, +.mt-empty-steps li { display: flex; align-items: flex-start; gap: 9px; } +.my-tasks-empty-steps .n, +.mt-empty-steps .n { + flex-shrink: 0; width: 20px; height: 20px; margin-top: -1px; + display: flex; align-items: center; justify-content: center; + border-radius: 50%; background: var(--bg-primary, #fff); + border: 1px solid var(--border-strong, #d3d3d0); + font-size: 11px; font-weight: 700; color: var(--text-dim, #999); +} +.my-tasks-empty-btn, +.mt-empty-btn { + display: inline-block; margin-top: 22px; padding: 9px 20px; border-radius: 8px; + background: var(--accent, #2383e2); color: #fff; + font-size: 14px; font-weight: 600; text-decoration: none; border: none; + cursor: pointer; font-family: inherit; +} +.my-tasks-empty-btn:hover, +.mt-empty-btn:hover { background: var(--accent-hover, #1a6bc0); filter: none; } + +/* ── Modale « nouvelle tâche » (multi-destinations) ─────────────────── */ +.mt-modal-overlay { + position: fixed; inset: 0; z-index: 1000; padding-top: 12vh; + display: flex; align-items: flex-start; justify-content: center; + background: rgba(0, 0, 0, .4); +} +.mt-modal { + width: 460px; max-width: 92vw; overflow: hidden; border-radius: 12px; + background: var(--bg-primary, #fff); box-shadow: 0 18px 60px rgba(0, 0, 0, .25); +} +.mt-modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; font-size: 14px; font-weight: 600; + border-bottom: 1px solid var(--border, #eee); +} +.mt-modal-close { + border: none; background: none; color: var(--text-dim, #888); + font-size: 14px; cursor: pointer; +} +.mt-modal-body { + display: flex; flex-direction: column; gap: 12px; + max-height: 55vh; overflow-y: auto; padding: 16px; +} +.mt-field { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: var(--text-dim, #888); +} +.mt-field input { + font: inherit; font-size: 14px; padding: 7px 9px; border-radius: 6px; + border: 1px solid var(--border, #e3e3e0); + background: var(--bg-primary, #fff); color: var(--text, #333); +} +.mt-modal-group { + margin-top: 4px; font-size: 11px; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-dim, #999); +} +.mt-dest { + display: flex; align-items: center; gap: 8px; padding: 6px 8px; + border-radius: 6px; font-size: 13px; cursor: pointer; +} +.mt-dest:hover { background: var(--bg-hover, rgba(0, 0, 0, .04)); } +.mt-modal-foot { + display: flex; justify-content: flex-end; gap: 8px; + padding: 12px 16px; border-top: 1px solid var(--border, #eee); +} +.mt-btn-cancel, +.mt-btn-primary { + font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px; + border: 1px solid var(--border, #e3e3e0); background: transparent; + color: var(--text, #333); cursor: pointer; +} +.mt-btn-primary { + border-color: transparent; background: var(--accent, #2383e2); + color: #fff; font-weight: 600; +} + +/* ── Modale « Convertir en base de tâches » (task_db_link.js) ─────────── */ +.tdb-overlay { + position: fixed; inset: 0; z-index: 1000; padding-top: 10vh; + display: flex; align-items: flex-start; justify-content: center; + background: rgba(0, 0, 0, .4); +} +.tdb-modal { + width: 520px; max-width: 94vw; overflow: hidden; border-radius: 12px; + background: var(--bg-modal, var(--bg-primary, #fff)); + box-shadow: 0 18px 60px rgba(0, 0, 0, .25); color: var(--text-primary, #333); +} +.tdb-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; font-size: 14px; font-weight: 600; + border-bottom: 1px solid var(--border, #eee); +} +.tdb-close { + border: none; background: none; color: var(--text-dim, #888); + font-size: 14px; cursor: pointer; +} +.tdb-intro { + padding: 12px 16px 0; font-size: 12.5px; line-height: 1.5; + color: var(--text-secondary, #666); +} +.tdb-intro a { color: var(--accent, #2383e2); } +.tdb-body { display: flex; flex-direction: column; gap: 14px; padding: 14px 16px; } +.tdb-role-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; } +.tdb-role-head label { font-size: 13px; font-weight: 600; } +.tdb-role-hint { font-size: 11.5px; color: var(--text-dim, #999); } +.tdb-role-body { display: flex; gap: 8px; } +.tdb-select, +.tdb-new-name { + font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px; + border: 1px solid var(--border, #e3e3e0); + background: var(--bg-primary, #fff); color: var(--text-primary, #333); +} +.tdb-select { flex: 1 1 auto; min-width: 0; } +.tdb-new-name { flex: 1 1 auto; min-width: 0; } +.tdb-warn { + margin: 0 16px 4px; padding: 8px 10px; border-radius: 6px; font-size: 12px; + background: rgba(201, 130, 10, .12); color: #c9820a; +} +.tdb-foot { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; border-top: 1px solid var(--border, #eee); +} +.tdb-spacer { flex: 1; } +.tdb-btn, +.tdb-btn-primary, +.tdb-btn-danger { + font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px; + border: 1px solid var(--border, #e3e3e0); background: transparent; + color: var(--text-primary, #333); cursor: pointer; +} +.tdb-btn-primary { + border-color: transparent; background: var(--accent, #2383e2); + color: #fff; font-weight: 600; +} +.tdb-btn-primary:disabled { opacity: .5; cursor: not-allowed; } +.tdb-btn-danger { color: #e5484d; border-color: rgba(229, 72, 77, .4); } + +/* ── Page « /db » : liste des bases du workspace ───────────────────────── + * En-tête recentré (page de destination du bouton « Ouvrir mes bases »), + * grille de cartes à gauche pour rester lisible sur les longues listes. + */ +.db-index { padding: 40px 24px 64px; max-width: 1100px; margin: 0 auto; } +.db-index-head { + display: flex; align-items: center; justify-content: center; + flex-wrap: wrap; gap: 10px; margin-bottom: 6px; text-align: center; +} +.db-index-head h1 { margin: 0; font-size: 24px; } +.db-index-ws { + font-size: 12px; color: var(--text-dim, #999); + padding: 2px 9px; border-radius: 20px; + border: 1px solid var(--border, #e3e3e0); +} +.db-index-sub { + max-width: 640px; margin: 0 auto 26px; text-align: center; + font-size: 13.5px; line-height: 1.6; color: var(--text-secondary, #666); +} +.db-index-sub a { color: var(--accent, #2383e2); } +.db-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; +} +.db-card { + display: flex; flex-direction: column; gap: 6px; + padding: 16px 16px; border-radius: 12px; text-decoration: none; + border: 1px solid var(--border, #e3e3e0); + background: var(--bg-primary, #fff); color: var(--text, #333); + transition: border-color .12s, box-shadow .12s, transform .12s; +} +.db-card:hover { + border-color: var(--accent, #2383e2); + box-shadow: 0 4px 16px rgba(0, 0, 0, .09); + transform: translateY(-1px); +} +.db-card-head { display: flex; align-items: center; gap: 8px; } +.db-card-icon { font-size: 19px; } +.db-card-name { + flex: 1; min-width: 0; font-size: 14px; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.db-card-badge { + flex-shrink: 0; font-size: 11px; font-weight: 600; + padding: 2px 8px; border-radius: 20px; +} +.db-card-badge.ok { background: rgba(0, 200, 100, .14); color: #00a35a; } +.db-card-badge.warn { background: rgba(201, 130, 10, .14); color: #c9820a; } +.db-card-meta { font-size: 11.5px; color: var(--text-dim, #999); } +.db-card-hint { font-size: 12px; line-height: 1.45; color: var(--text-secondary, #666); } + +/* État vide de `/db` : bloc centré dans la surface disponible. */ +.db-empty { + display: flex; flex-direction: column; align-items: center; + text-align: center; padding: 72px 24px 80px; + max-width: 620px; margin: 0 auto; +} +.db-empty-icon { + display: flex; align-items: center; justify-content: center; + width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px; + border-radius: 50%; + background: var(--bg-hover, rgba(0, 0, 0, .04)); + border: 1px solid var(--border, #e3e3e0); +} +.db-empty h1 { margin: 0 0 10px; font-size: 19px; font-weight: 600; } +.db-empty p { + margin: 0 0 8px; font-size: 14px; line-height: 1.6; + color: var(--text-secondary, #666); +} +.db-empty-hint { max-width: 520px; font-size: 13px; } +.db-empty-hint code { + padding: 1px 5px; border-radius: 4px; font-size: 12px; + background: var(--bg-hover, rgba(0, 0, 0, .05)); +} + +/* ── Bouton « Convertir en base de tâches » (barre de la base) ────────── */ +.db-task-db-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 11px; border-radius: 6px; + border: 1px solid var(--border, #e3e3e0); + background: transparent; color: var(--text-secondary, #555); + font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap; +} +.db-task-db-btn:hover { + border-color: var(--accent, #2383e2); + color: var(--accent, #2383e2); + background: var(--bg-hover, rgba(35, 131, 226, .07)); +} +.mt-source-fix { + font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; + border: 1px solid rgba(201, 130, 10, .45); background: transparent; + color: #c9820a; cursor: pointer; +} +.mt-source-fix:hover { background: rgba(201, 130, 10, .12); } +.db-section-title { + margin: 38px 0 4px; font-size: 15px; font-weight: 600; color: var(--text, #333); + text-align: center; +} +.db-section-sub { + max-width: 640px; margin: 0 auto 14px; text-align: center; + font-size: 12.5px; line-height: 1.55; color: var(--text-dim, #888); +} diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index 85aba39..d1db1c0 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -71,6 +71,7 @@ }); this._applyState = applyState; + this._initResize(); this.fetchProviders(); this.fetchAgents(); this.fetchSkills(); @@ -86,6 +87,11 @@ self.setComposerText(t); setTimeout(function(){ self.send(); }, 60); }; + // Open the panel on a given conversation (sidebar Chat history). + window.fdAgent.openConversation = function(id){ + self.openAgent(); + self.loadConversation(id); + }; // Headless text generation (no tools) via /agent/generate — the clean // path used by "Ask AI" / "AI meeting note" / summarization. Sending a // plain write request through the tool-enabled conversation engine sends @@ -108,6 +114,79 @@ }; }, + // ── Resizable panel width (drag handle on the left edge, persisted) ── + _apMinWidth(){ return Math.min(360, window.innerWidth); }, + _apMaxWidth(){ return Math.max(this._apMinWidth(), Math.min(Math.round(window.innerWidth * 0.9), 1200)); }, + _apClampWidth(w){ + return Math.round(Math.max(this._apMinWidth(), Math.min(this._apMaxWidth(), w || 0))); + }, + _apApplyWidth(w){ + var panel = document.getElementById('fd-agent-panel'); + if(!panel) return; + var width = this._apClampWidth(w); + panel.style.setProperty('--fd-agent-width', width + 'px'); + return width; + }, + _initResize(){ + var self = this; + var panel = document.getElementById('fd-agent-panel'); + var handle = panel && panel.querySelector('.fd-ap-resize'); + if(!panel || !handle) return; + + // Restore the user width (clamped to the current viewport). + try { + var saved = parseInt(localStorage.getItem('fd_agent_width') || '', 10); + if(saved) this._apApplyWidth(saved); + } catch{ /* stockage indisponible */ } + + var startX = 0, startWidth = 0, dragging = false; + var persist = function(){ + try { localStorage.setItem('fd_agent_width', panel.style.getPropertyValue('--fd-agent-width')); } catch{ /* ignoré */ } + }; + + handle.addEventListener('pointerdown', function(e){ + if(e.button !== 0) return; + e.preventDefault(); + try { handle.setPointerCapture(e.pointerId); } catch{ /* ignoré */ } + startX = e.clientX; + startWidth = panel.offsetWidth; + dragging = true; + document.body.classList.add('fd-ap-resizing'); + }); + handle.addEventListener('pointermove', function(e){ + if(!dragging) return; + // The panel is anchored to the right edge → dragging left widens it. + self._apApplyWidth(startWidth - (e.clientX - startX)); + }); + var endDrag = function(e){ + if(!dragging) return; + dragging = false; + document.body.classList.remove('fd-ap-resizing'); + persist(); + try { handle.releasePointerCapture(e.pointerId); } catch{ /* ignoré */ } + }; + handle.addEventListener('pointerup', endDrag); + handle.addEventListener('pointercancel', endDrag); + handle.addEventListener('dblclick', function(){ + self._apApplyWidth(470); + persist(); + }); + // Keyboard a11y: ← / → adjust by 20px, Home resets to the default. + handle.addEventListener('keydown', function(e){ + var step = e.shiftKey ? 60 : 20, cur = panel.offsetWidth; + if(e.key === 'ArrowLeft'){ self._apApplyWidth(cur + step); } + else if(e.key === 'ArrowRight'){ self._apApplyWidth(cur - step); } + else if(e.key === 'Home'){ self._apApplyWidth(470); } + else { return; } + e.preventDefault(); + persist(); + }); + // Keep the width usable when the window shrinks / grows. + window.addEventListener('resize', function(){ + self._apApplyWidth(panel.offsetWidth); + }); + }, + // ── Open / close (robust) ── openAgent(){ if(this.open) { this.refreshContext(); return; } @@ -1588,7 +1667,9 @@ delete_page:'Page supprimée', delete_collection:'Collection supprimée', apply_template:'Template appliqué', create_sub_item:'Sous-élément créé', add_dependency:'Dépendance ajoutée', read_gitea_issues:'Issues Gitea lues', - create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées' + create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées', + web_search:'Recherche web effectuée', fetch_url:'Page web lue', + search_code:'Recherche GitHub effectuée' }; // Build an action step object from an SSE event or a persisted action row. agentPanel._actionStep = function(ev){ diff --git a/static/js/app.js b/static/js/app.js index c0f8ad7..12a31c8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -252,6 +252,12 @@ // managed by Alpine or HTMX keep their default behaviour. function shouldIntercept(a, evt) { if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false; + // Un handler plus profond a déjà traité le clic et appelé preventDefault + // (onglets de vue, lien interne…). Sans ce test, le navigateur AND ce + // navigateur SPA traitent le même clic : le composant rend son état, puis + // `fdLoad` remplace la zone `.main-wrapper` par la réponse serveur — le + // composant perd son DOM et la page finit vide. + if (evt.defaultPrevented) return false; if (a.hasAttribute('data-no-nav')) return false; var target = (a.getAttribute('target') || '').toLowerCase(); if (target && target !== '_self') return false; @@ -278,14 +284,30 @@ } var loading = false; + var LOAD_WATCHDOG_MS = 15000; function fdLoad(url, push) { if (!window.htmx) { window.location.href = url; return; } - if (loading) return; + // Une navigation est déjà en vol : on l'ABANDONNE au profit d'un chargement + // complet. Avant, ce clic etait simplement ignore (aucune URL, aucun + // changement) — et si la promesse htmx ne se resolvait jamais, `loading` + // restait a `true` indefiniment : plus aucune navigation ne repartait, la + // page restait figee (ou a moitie peinte) jusqu'au hard refresh. + if (loading) { window.location.href = url; return; } loading = true; + var settled = false; function done() { + if (settled) return; + settled = true; loading = false; + clearTimeout(watchdog); } + // Filet de securite : `loading` ne doit jamais pouvoir rester bloque. + var watchdog = setTimeout(function () { + if (settled) return; + done(); + window.location.href = url; + }, LOAD_WATCHDOG_MS); var p; try { p = window.htmx.ajax('GET', url, { @@ -361,7 +383,8 @@ mw.style.opacity = '0'; mw.style.pointerEvents = 'none'; mw.setAttribute('x-ignore', ''); - var pending = mw.querySelectorAll('script[src]').length; + var scripts = Array.prototype.slice.call(mw.querySelectorAll('script[src]')); + var pending = scripts.length; var reveal = function () { if (!mw.isConnected) return; mw.style.opacity = ''; @@ -380,7 +403,14 @@ reveal(); }; var onScript = function (e) { - if (!e.target || e.target.tagName !== 'SCRIPT') return; + var node = e.target; + // `load`/`error` en capture sur `document` voit TOUS les <script> de la + // page, pas seulement ceux de la zone swappée : compter les uns et les + // autres décomptait trop vite et montait l'arbre Alpine avant que les + // scripts de la page n'aient tourné (page à moitié montée, composants + // affiché à l'état brut). On ne compte donc que les noeuds attendus. + if (!node || node.tagName !== 'SCRIPT') return; + if (scripts.indexOf(node) === -1) return; if (--pending <= 0) done(); }; if (!pending) { @@ -481,3 +511,41 @@ function openCardDetail(id) { document.getElementById('card-modal-title').textContent = 'Issue #' + id; } + +// ── Side peek : poignée de redimensionnement partagée (v7.49.1) ── +// Une seule implémentation pour les peeks de Library, du workspace local, +// de My Tasks et des bases de données (le code copié trois fois dérivait : +// celui de My Tasks ne capturait pas le pointeur et ne persistait rien). +// La largeur choisie est commune à tous les peeks (localStorage fd_peek_width), +// comme l'utilisateur ne voit qu'un seul panneau à la fois. +window.fdWirePeekResize = function(panel, handle, onClose) { + if (!panel || !handle) return; + var startX, startWidth, dragging = false; + handle.addEventListener('pointerdown', function(e) { + e.preventDefault(); + try { handle.setPointerCapture(e.pointerId); } catch { /* pointeur fantôme */ } + startX = e.clientX; + startWidth = panel.offsetWidth; + dragging = true; + document.body.style.userSelect = 'none'; + }); + handle.addEventListener('pointermove', function(e) { + if (!dragging) return; + var w = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + (startX - e.clientX))); + panel.style.width = w + 'px'; + }); + function stop(e) { + if (!dragging) return; + dragging = false; + document.body.style.userSelect = ''; + try { handle.releasePointerCapture(e.pointerId); } catch { /* déjà relâché */ } + if (e && Math.abs(e.clientX - startX) < 3 && typeof onClose === 'function') onClose(); + try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* navigation privée */ } + } + handle.addEventListener('pointerup', stop); + handle.addEventListener('pointercancel', function() { dragging = false; document.body.style.userSelect = ''; }); + try { + var saved = localStorage.getItem('fd_peek_width'); + if (saved) panel.style.width = saved; + } catch { /* navigation privée */ } +}; diff --git a/static/js/database_table.js b/static/js/database_table.js index 970ad87..4fef042 100644 --- a/static/js/database_table.js +++ b/static/js/database_table.js @@ -238,6 +238,23 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(VIEW_ICONS[t]||VIEW_ICONS.table)+'</svg>'; } + function taskDbButton(collection) { + // Le libellé suit l'état réel de la base : convertie, à + // configurer, ou pas encore convertie. Un simple ✓ était + // introuvable pour l'utilisateur. + var isTask = !!(collection && collection.is_task); + var mapped = !!(collection && collection.task_assignee_prop && + collection.task_status_prop && collection.task_due_prop); + var label = isTask ? (mapped ? '✓ Base de tâches' : '⚠ Configurer') + : 'Convertir en base de tâches'; + var title = isTask + ? (mapped ? 'Cette base alimente My Tasks' + : 'Base connectée mais mapping incomplet — reenregistrer') + : 'Relier Assigné à / Statut / Échéance pour alimenter My Tasks'; + return '<button class="db-task-db-btn" data-task-db="' + collectionId + + '" title="' + escHtml(title) + '">' + label + '</button>'; + } + function renderViewBar() { var h = '<div class="db-view-bar" style="padding:4px 0 8px"><div class="db-view-tabs">'; if (!state.views.length) { @@ -251,7 +268,8 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= }); h += '<button class="db-view-add" data-view-add="1" title="Add a view">+</button>'; h += '</div>'; - h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>'; + h += '<div class="db-view-actions">' + taskDbButton(state.collection) + + '<button class="db-btn-new db-add-page-btn">+ New</button></div>'; h += '</div>'; return h; } @@ -1264,51 +1282,84 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= } } - // ── Global Side Peek for Database Pages (auto-creates panel) ── - function openPageInSidePeek(pageId) { - var panel = document.getElementById('db-side-peek'); - if (!panel) { - panel = document.createElement('div'); - panel.id = 'db-side-peek'; - panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column'; - panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+ - '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+ - '<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+ - '<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+ - '</div>'+ - '<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>'; - document.body.appendChild(panel); - document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; }; - document.getElementById('db-side-peek-full').onclick = function(){ - var ifr = document.getElementById('db-side-peek-iframe'); - if (ifr && ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank'); - }; - var rh = document.getElementById('db-side-peek-resize'); - rh.onpointerdown = function(e) { - e.preventDefault(); e.stopPropagation(); - var sx = e.clientX, sw = panel.offsetWidth; - function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; } - function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; } - document.body.style.cursor='col-resize'; - document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up); - }; + // ── Side peek : panneau latéral des lignes de base (vanilla JS) ── + // Le panneau vit dans _database_table.html (page de base ET bases + // embarquées inline). Il doit être câblé SUR le document, jamais via + // container.__x : le conteneur n'a pas de x-data (les bindings Alpine + // du panneau restaient inertes — clic sur une ligne = rien). + var peekEl = document.getElementById('db-side-peek'); + // Sans panneau (collections embarquées inline dans l'éditeur), le clic ne + // doit pas lever de ReferenceError : repli discret, comme l'ancien code. + if (!peekEl) window.openPageInSidePeek = function() {}; + if (peekEl) { + var peekFrame = document.getElementById('db-side-peek-iframe'); + var peekTitle = document.getElementById('db-side-peek-title'); + var peekLoading = document.getElementById('db-side-peek-loading'); + function closeDbPeek() { + peekEl.classList.remove('open'); + document.getElementById('db-side-peek-iframe').src='about:blank'; + if (peekTitle) { peekTitle.textContent = ''; } } - var iframe = document.getElementById('db-side-peek-iframe'); - // v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId} - // opened an unrelated page with the same id) — resolve the row's - // content page first (lazy-created), then load the real editor. - resolveRowContentPage(pageId).then(function(pid){ - var fr = document.getElementById('db-side-peek-iframe'); - if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; } + window.openPageInSidePeek = function(pageId) { + if (!pageId) return; + peekEl.classList.add('open'); + if (peekLoading) peekLoading.style.display = ''; + var iframe = document.getElementById('db-side-peek-iframe'); + if (iframe) iframe.src='about:blank'; + if (peekTitle) peekTitle.textContent = ''; + resolveRowContentPage(pageId).then(function(pid){ + if (!peekEl.classList.contains('open')) return; + if (peekTitle) peekTitle.textContent = ''; + if (peekLoading) peekLoading.style.display = 'none'; + if (iframe) iframe.src = '/pages/'+pid; + }) + .catch(function(){ + // L'échec est rendu DANS le panneau (srcdoc) : sous le build + // CSP d'Alpine les toasts ne s'affichent pas partout. + if (iframe) { + iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>'; + } + peekEl.classList.add('open'); + toast('Impossible d’ouvrir le contenu de cette ligne', 'error'); + }); + }; + var pc = document.getElementById('db-side-peek-close'); + if (pc) pc.addEventListener('click', closeDbPeek); + var pf = document.getElementById('db-side-peek-full'); + if (pf) pf.addEventListener('click', function() { + if (peekFrame && peekFrame.src && peekFrame.src !== 'about:blank') { + window.location.href = peekFrame.src; + } }); - if (iframe) panel.style.display = 'flex'; + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek(); + }); + document.addEventListener('pointerdown', function(e) { + if (!peekEl.classList.contains('open')) return; + if (peekEl.contains(e.target)) return; + if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return; + closeDbPeek(); + }); + function wireDbPeekResize() { + if (window.fdWirePeekResize) { + window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek); + } else if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wireDbPeekResize); + } + } + wireDbPeekResize(); } function resolveRowContentPage(rowId){ return fetch('/db/pages/'+rowId+'/open/api') - .then(function(r){ return r.ok ? r.json() : null; }) - .then(function(d){ return (d && d.page_id) ? d.page_id : rowId; }) - .catch(function(){ return rowId; }); + .then(function(r){ + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function(d){ + if (!d || !d.page_id) throw new Error('page de contenu introuvable'); + return d.page_id; + }); } })(); } diff --git a/static/js/library.js b/static/js/library.js index 4a6c5ce..1defa2f 100644 --- a/static/js/library.js +++ b/static/js/library.js @@ -965,13 +965,33 @@ _renderIcon(item) { newPage() { this.addNewPage(); }, // ── Side peek ── - async openPeek(item) { + // L'iframe est pilotée en JS : le build CSP d'Alpine n'évalue pas le + // binding `:src` (l'iframe restait vide et aucun document n'apparaissait). + // `about:blank` ferme explicitement le document précédent avant de charger + // le nouveau : sans cela, même URL = pas de rechargement et l'ancien + // document restait affiché. + openPeek(item) { this.peekItem = item; this.peekFavorited = item.favorited || false; + var frame = document.getElementById('lib-peek-iframe'); + var loading = document.getElementById('lib-peek-loading'); + if (!frame) return; + if (loading) loading.style.display = ''; + frame.onload = function () { if (loading) loading.style.display = 'none'; }; + frame.src = 'about:blank'; + // `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la + // page pleine (sidebar, barre, actions compris), pas une variante. + frame.src = '/pages/' + item.id; }, closePeek() { this.peekItem = null; + var frame = document.getElementById('lib-peek-iframe'); + // On décharge l'éditeur : il resterait en mémoire (et son autosave + // tournerait) alors qu'il n'est plus visible. + if (frame) { frame.onload = null; frame.src = 'about:blank'; } + var loading = document.getElementById('lib-peek-loading'); + if (loading) { loading.style.display = ''; loading.textContent = 'Chargement…'; } }, async toggleFavoritePeek() { @@ -1040,40 +1060,16 @@ _renderIcon(item) { } }; } -// Init peek resize handle +// Init peek resize handle — helper partagé fdWirePeekResize (app.js, defer : +// câblage différé jusqu'à sa disponibilité, ou immédiat si on est déjà prêt). (function() { - var panel = document.querySelector('.peek-overlay'); - var handle = document.getElementById('lib-peek-resize-handle'); - if (!panel || !handle) return; - var startX, startWidth, dragging = false; - handle.addEventListener('pointerdown', function(e) { - e.preventDefault(); - handle.setPointerCapture(e.pointerId); - startX = e.clientX; - startWidth = panel.offsetWidth; - dragging = true; - document.body.style.userSelect = 'none'; - }); - handle.addEventListener('pointermove', function(e) { - if (!dragging) return; - var deltaX = startX - e.clientX; - var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX)); - panel.style.width = newWidth + 'px'; - }); - handle.addEventListener('pointerup', function(e) { - if (!dragging) return; - document.body.style.userSelect = ''; - var moved = Math.abs(e.clientX - startX); - if (moved < 3) { - var el = document.querySelector('.peek-overlay'); - if (el && el.__x) { el.__x.$data.peekItem = null; } - } - dragging = false; - try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ } - handle.releasePointerCapture(e.pointerId); - }); - try { - var saved = localStorage.getItem('fd_peek_width'); - if (saved) panel.style.width = saved; - } catch { /* volontaire */ } + function wire() { + var panel = document.querySelector('.peek-overlay'); + if (!panel || !window.fdWirePeekResize) return; + window.fdWirePeekResize(panel, panel.querySelector('#lib-peek-resize-handle'), function() { + if (panel.__x) panel.__x.$data.peekItem = null; + }); + } + if (window.fdWirePeekResize || document.readyState !== 'loading') wire(); + else document.addEventListener('DOMContentLoaded', wire); })(); diff --git a/static/js/local_workspace.js b/static/js/local_workspace.js index de62608..952f907 100644 --- a/static/js/local_workspace.js +++ b/static/js/local_workspace.js @@ -200,39 +200,11 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis → }, _initResizeHandle() { - var panel = document.getElementById('ws-preview-panel'); - var handle = document.getElementById('ws-preview-resize-handle'); - if (!panel || !handle) return; - var startX, startWidth, dragging = false; - handle.addEventListener('pointerdown', function(e) { - e.preventDefault(); - handle.setPointerCapture(e.pointerId); - startX = e.clientX; - startWidth = panel.offsetWidth; - dragging = true; - document.body.style.userSelect = 'none'; - }); - handle.addEventListener('pointermove', function(e) { - if (!dragging) return; - var deltaX = startX - e.clientX; - var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX)); - panel.style.width = newWidth + 'px'; - }); - handle.addEventListener('pointerup', function(e) { - if (!dragging) return; - document.body.style.userSelect = ''; - var moved = Math.abs(e.clientX - startX); - if (moved < 3) { - window._wsData.closePreviewPanel(); - } - dragging = false; - try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ } - handle.releasePointerCapture(e.pointerId); - }); - try { - var saved = localStorage.getItem('fd_peek_width'); - if (saved) panel.style.width = saved; - } catch { /* volontaire */ } + // Helper partagé (app.js) — pointer capture, persistance fd_peek_width. + window.fdWirePeekResize( + document.getElementById('ws-preview-panel'), + document.getElementById('ws-preview-resize-handle'), + function () { window._wsData.closePreviewPanel(); }); }, _recompute() { @@ -403,7 +375,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis → return null; }, - // ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ── + // ── Preview panel (loads the full-screen editor in an iframe) ── selectForPreview(node) { var panel = document.getElementById('ws-preview-panel'); if (!panel) return; @@ -423,9 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis → window.showToast('Failed to copy', 'error'); }); }; - // Load editor in iframe + // Load the full-screen editor in the iframe (no `?embed=1`: the peek + // shows the same editor as the page itself). `about:blank` first, so + // opening another document really reloads the frame instead of keeping + // the previous one when the URL happens to be identical. var iframe = document.getElementById('ws-preview-iframe'); - if (iframe) iframe.src = '/pages/' + node.id + '?embed=1'; + if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id; } // Show panel panel.style.display = 'flex'; requestAnimationFrame(function() { diff --git a/static/js/my_tasks.js b/static/js/my_tasks.js new file mode 100644 index 0000000..11f8c00 --- /dev/null +++ b/static/js/my_tasks.js @@ -0,0 +1,970 @@ +/* ════════════════════════════════════════════════════════════════════════ + * FlowDeck — My Tasks (tableau de bord façon Notion) + * + * Trois vues sur les mêmes tâches normalisées par `/my-tasks/api` : + * • tableau — liste dense, statut / échéance éditables au clic ; + * • kanban — colonnes = options de statut de la base d'origine, glisser + * d'une colonne à l'autre écrit le statut ; + * • calendrier — mois courant, tâches placées sur leur date d'échéance. + * + * Tout passe par le mapping enregistré côté serveur + * (`collections.task_*_prop`) : le front envoie un champ (`status`, `due`, + * `title`), jamais un identifiant de colonne. + * ══════════════════════════════════════════════════════════════════════ */ +/* Le tableau de bord est monté par `flowDeckMyTasks()` pour **chaque** noeud + * `#my-tasks-app` : la navigation interne de `app.js` (htmx) remplace + * `.main-wrapper` sans recharger le document — le <script> est bien rejoué, + * mais son garde global faisait sortir le IIFE et la nouvelle coquille restait + * vide jusqu'au hard refresh. Le drapeau est désormais porté par l'élément + * (`__fdMounted`), pas par `window`. + * + * Filet supplémentaire : une coquille montée mais **restée vide** est le + * symptôme d'un swap interrompu. Plutôt que d'afficher du vide, on remonte + * (les delegations d'événements, elles, ne sont posées qu'une fois par + * noeud grâce à `__fdWired`). */ +function flowDeckMyTasks() { + 'use strict'; + + var app = document.getElementById('my-tasks-app'); + if (!app) return; + if (app.__fdMounted && app.innerHTML.trim()) return; + app.__fdMounted = true; + var wire = !app.__fdWired; // écoutes posées une seule fois par noeud + app.__fdWired = true; + + var cfg = {}; + try { cfg = JSON.parse(app.getAttribute('data-config') || '{}'); } catch (e) { cfg = {}; } + + var state = { + view: cfg.view || 'table', + due: (cfg.filters && cfg.filters.due) || 'all', + sort: (cfg.filters && cfg.filters.sort) || 'due', + hide_done: !!(cfg.filters && cfg.filters.hide_done), + sources: cfg.sources || [], + maxSources: cfg.maxSources || 10, + tasks: [], + month: null, + calView: 'month', + calDay: null + }; + + var MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', + 'août', 'septembre', 'octobre', 'novembre', 'décembre']; + var DAYS = ['lu', 'ma', 'me', 'je', 've', 'sa', 'di']; + + function csrf() { + return (typeof window.getCsrf === 'function') ? window.getCsrf() : ''; + } + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // `collection_pages.icon` vaut la chaine sentinelle "file" pour une ligne + // sans icône (héritage de l'import, cf. `database_table.js`). L'afficher + // telle quel collait le mot "file" devant chaque titre. + function rowIcon(icon) { + return (!icon || icon === 'file') ? '📄' : icon; + } + + // Un identifiant de ligne de base n'est PAS un identifiant de page : + // /db/pages/{id}/open/api renvoie la page de contenu (créée à la volée) + // qui porte les blocs de la tâche. On ne navigue JAMAIS vers + // /pages/{rowId} : cet id désigne une page sans rapport (cf. + // database_table.js) — d'où le panneau latéral, comme dans Library. + var PEEK_SVG_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + + 'stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" ' + + 'x2="18" y2="18"/></svg>'; + var PEEK_SVG_FULL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + + 'stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>' + + '<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>'; + + // Le panneau vit HORS de `#my-tasks-app` : `render()` remplace le contenu + // de l'app à chaque filtre, le panneau doit survivre. + function ensurePeek() { + var p = document.getElementById('mt-peek'); + if (p) return p; + p = document.createElement('div'); + p.className = 'mt-peek'; + p.id = 'mt-peek'; + p.setAttribute('aria-hidden', 'true'); + p.innerHTML = '<div class="mt-peek-handle" id="mt-peek-handle"></div>' + + '<div class="mt-peek-head">' + + '<button class="mt-peek-btn" id="mt-peek-close" title="Close" ' + + 'aria-label="Fermer">' + PEEK_SVG_CLOSE + '</button>' + + '<div class="mt-peek-title" id="mt-peek-title"></div>' + + '<button class="mt-peek-btn" id="mt-peek-full" title="Open full page" ' + + 'aria-label="Ouvrir dans une page entière">' + PEEK_SVG_FULL + '</button>' + + '</div>' + + '<div class="mt-peek-body" id="mt-peek-body">' + + '<div class="mt-peek-loading" id="mt-peek-loading">Chargement…</div>' + + '<iframe id="mt-peek-iframe" title="Contenu de la tâche" ' + + 'style="display:none"></iframe></div>'; + document.body.appendChild(p); + + document.getElementById('mt-peek-close').addEventListener('click', closePeek); + document.getElementById('mt-peek-full').addEventListener('click', function () { + var f = document.getElementById('mt-peek-iframe'); + if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page; + }); + wirePeekResize(p); + return p; + } + + function closePeek() { + var p = document.getElementById('mt-peek'); + if (!p) return; + p.classList.remove('open'); + p.setAttribute('aria-hidden', 'true'); + var f = document.getElementById('mt-peek-iframe'); + // On décharge l'iframe : elle garde sinon en mémoire tout l'éditeur + // d'un document que l'on vient de quitter. + if (f) { f.src = ''; delete f.dataset.page; f.style.display = 'none'; } + var l = document.getElementById('mt-peek-loading'); + if (l) l.style.display = ''; + } + + function wirePeekResize(panel) { + // Helper partagé (app.js) : pointer capture, borne 300–90vw, clic = + // fermer, largeur persistée (localStorage fd_peek_width) — comme Library. + window.fdWirePeekResize(panel, document.getElementById('mt-peek-handle'), closePeek); + } + + function openRowContent(rowId, title) { + var panel = ensurePeek(); + document.getElementById('mt-peek-title').textContent = title || ''; + var loading = document.getElementById('mt-peek-loading'); + var frame = document.getElementById('mt-peek-iframe'); + if (loading) loading.style.display = ''; + if (frame) { frame.style.display = 'none'; frame.src = ''; delete frame.dataset.page; } + panel.classList.add('open'); + panel.setAttribute('aria-hidden', 'false'); + + api('/db/pages/' + rowId + '/open/api').then(function (d) { + var pid = d && d.page_id; + if (!pid) throw new Error('page de contenu introuvable'); + var f = document.getElementById('mt-peek-iframe'); + if (!f || !document.getElementById('mt-peek').classList.contains('open')) return; + if (loading) loading.style.display = 'none'; + f.dataset.page = pid; + f.style.display = ''; + // `about:blank` d'abord : ouvrir une autre tâche doit REcharger + // l'éditeur, sinon l'URL identique laisse le document précédent + // affiché. `/pages/{id}` sans `?embed=1` = l'éditeur plein écran. + f.src = 'about:blank'; + f.src = '/pages/' + pid; + }).catch(function (e) { + if (loading) { + loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message; + loading.style.display = ''; + } + }); + } + + function syncUrl() { + // `replaceState` peut être refusé (iframe sandbox, `file://`, + // navigation cross-origin) : l'URL n'est qu'un confort, jamais + // l'interface — on ne laisse jamais une exception casser l'action. + try { + history.replaceState(null, '', '/my-tasks?' + qs()); + } catch (e) { /* ignoré */ } + } + + function qs() { + var p = new URLSearchParams(); + p.set('view', state.view); + p.set('due', state.due); + p.set('sort', state.sort); + if (state.hide_done) p.set('hide_done', 'true'); + return p.toString(); + } + + function api(path, opts) { + opts = opts || {}; + var headers = { 'X-CSRF-Token': csrf() }; + if (opts.body) headers['Content-Type'] = 'application/json'; + return fetch(path, { + method: opts.method || 'GET', + credentials: 'same-origin', + headers: headers, + body: opts.body ? JSON.stringify(opts.body) : undefined + }).then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { + if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status)); + return d; + }); + }); + } + + /* ── Données ───────────────────────────────────────────────────── */ + + function load() { + return api('/my-tasks/api?' + qs()).then(function (d) { + state.tasks = d.tasks || []; + state.sources = d.sources || []; + render(); + }).catch(function (e) { + app.innerHTML = '<div class="my-tasks-error">' + esc(e.message) + '</div>'; + }); + } + + function configuredSources() { + return state.sources.filter(function (s) { return s.configured; }); + } + + /* ── Barre d'outils ────────────────────────────────────────────── */ + + function toolbar() { + var h = '<div class="mt-toolbar">'; + h += '<div class="mt-toolbar-left">'; + h += viewTabs(); + h += '</div><div class="mt-toolbar-right">'; + h += select('mt-due-filter', 'due', [ + ['all', 'Toutes les échéances'], + ['today', 'Échéance aujourd\'hui'], + ['overdue', 'En retard + aujourd\'hui'], + ['week', 'Cette semaine'] + ]); + h += select('mt-sort', 'sort', [ + ['due', 'Tri : échéance'], + ['status', 'Tri : statut'], + ['source', 'Tri : source'], + ['created', 'Tri : création'] + ]); + h += '<label class="mt-toggle"><input type="checkbox" id="mt-hide-done"' + + (state.hide_done ? ' checked' : '') + '> Masquer terminées</label>'; + h += '<button class="mt-new-btn" id="mt-new-btn">+ Nouvelle tâche</button>'; + h += '</div></div>'; + h += sourcesBar(); + return h; + } + + function viewTabs() { + var labels = { table: 'Tableau', board: 'Kanban', calendar: 'Calendrier' }; + var h = '<div class="mt-view-tabs">'; + Object.keys(labels).forEach(function (k) { + // `data-no-nav` : le changement de vue est traité ici, en JS. + // Sans lui, l'intercepteur de liens de `app.js` rejoue un + // chargement complet de la page et remplace le DOM rendu. + // Le `href` reste présent : le lien reste partageable et + // ouvrable dans un onglet. + h += '<a class="mt-view-tab' + (state.view === k ? ' active' : '') + + '" data-no-nav href="/my-tasks?' + withView(k) + '">' + + labels[k] + '</a>'; + }); + return h + '</div>'; + } + + function withView(v) { + var p = new URLSearchParams(); + p.set('view', v); + p.set('due', state.due); + p.set('sort', state.sort); + if (state.hide_done) p.set('hide_done', 'true'); + return p.toString(); + } + + function select(cls, key, opts) { + var s = '<select class="mt-select ' + cls + '" data-key="' + key + '">'; + opts.forEach(function (o) { + s += '<option value="' + esc(o[0]) + '"' + + (state[key] === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>'; + }); + return s + '</select>'; + } + + function sourcesBar() { + var n = state.sources.length; + var ok = configuredSources().length; + var pend = state.sources.filter(function (s) { return !s.configured; }); + var h = '<div class="mt-sources">'; + h += '<span class="mt-sources-label">Sources : <b>' + ok + '/' + state.maxSources + + '</b> base(s) connectée(s)</span>'; + state.sources.forEach(function (s) { + h += '<a class="mt-source-chip' + (s.configured ? '' : ' pending') + + '" href="/db/' + s.id + '" title="' + esc(s.workspace_name) + '">' + + esc(s.icon || '📋') + ' ' + esc(s.name) + '</a>'; + // Une base connectée au mapping incomplet se corrige d'ici. + if (!s.configured) { + h += '<button class="mt-source-fix" data-fix-source="' + s.id + + '" title="Relier les colonnes manquantes : ' + + esc((s.missing_roles || []).join(', ')) + '">Configurer</button>'; + } + }); + if (pend.length) { + h += '<span class="mt-sources-warn" title="Ces bases n’émettent pas de tâche ' + + 'tant que les 3 colonnes ne sont pas liées">⚠ ' + pend.length + + ' base(s) à configurer</span>'; + } + if (n >= state.maxSources) { + h += '<span class="mt-sources-warn">Limite de ' + state.maxSources + + ' bases atteinte</span>'; + } + return h + '</div>'; + } + + /* ── Vue tableau ────────────────────────────────────────────────────── + * Lignes en `div`, comme Library : c'est ce qui permet de n'afficher le + * bouton « Open » qu'au survol de la ligne (`.mt-hover-only`). + */ + + // Bouton « Open » : SVG et libellé identiques à `.btn-open` de Library, + // pour que les deux listes se ressemblent exactement. + function openButton(t) { + return '<button class="mt-open-btn mt-hover-only" data-open-row="' + t.id + + '" data-open-title="' + esc(t.title) + '" ' + + 'title="Open in side peek" aria-label="Ouvrir dans le panneau latéral">' + + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + + '<rect x="3" y="3" width="18" height="18" rx="2"/>' + + '<line x1="15" y1="3" x2="15" y2="21"/></svg> Open</button>'; + } + + function viewTable() { + if (!state.tasks.length) return emptyState(); + var h = '<div class="mt-table">' + + '<div class="mt-thead">' + + '<div class="mt-th mt-cell-name">Tâche</div>' + + '<div class="mt-th mt-cell-status">Statut</div>' + + '<div class="mt-th mt-cell-due">Échéance</div>' + + '<div class="mt-th mt-cell-src">Source</div>' + + '</div>'; + state.tasks.forEach(function (t) { + h += '<div class="mt-row" data-id="' + t.id + '">'; + h += '<div class="mt-cell mt-cell-name">' + + '<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' + + '<span class="mt-title-text" data-edit="title" title="Cliquer pour renommer">' + + esc(t.title) + '</span>' + openButton(t) + '</div>'; + h += '<div class="mt-cell mt-cell-status">' + statusCell(t) + '</div>'; + h += '<div class="mt-cell mt-cell-due">' + dueCell(t) + '</div>'; + h += '<div class="mt-cell mt-cell-src"><a class="mt-chip" href="/db/' + + t.collection_id + '" title="' + esc(t.workspace_name) + + ' · ouvrir la base">' + esc(t.collection_icon) + ' ' + + esc(t.collection_name) + '</a></div>'; + h += '</div>'; + }); + return h + '</div>'; + } + + function statusCell(t) { + if (!t.status_options || !t.status_options.length) { + return '<span class="mt-static">' + esc(t.status || '—') + '</span>'; + } + return '<select class="mt-status-select" data-id="' + t.id + + '" data-field="status">' + t.status_options.map(function (o) { + return '<option value="' + esc(o) + '"' + + (o === t.status ? ' selected' : '') + '>' + esc(o) + '</option>'; + }).join('') + '</select>'; + } + + function dueCell(t) { + var cls = t.due_state ? ' mt-due-' + t.due_state : ''; + return '<input type="date" class="mt-due-input' + cls + '" data-id="' + t.id + + '" data-field="due" value="' + esc(t.due || '') + '">'; + } + + /* ── Vue kanban ────────────────────────────────────────────────── */ + + function viewBoard() { + var cols = [], seen = {}; + state.tasks.forEach(function (t) { + (t.status_options || []).forEach(function (o) { + if (!seen[o]) { seen[o] = 1; cols.push(o); } + }); + if (!t.status || seen[t.status] === undefined) { + if (t.status && !seen[t.status]) { seen[t.status] = 1; cols.push(t.status); } + } + }); + if (!cols.length) cols = ['À faire']; + + var h = '<div class="mt-board">'; + cols.forEach(function (col) { + var items = state.tasks.filter(function (t) { return (t.status || '') === col; }); + h += '<div class="mt-col" data-status="' + esc(col) + '">'; + h += '<div class="mt-col-head">' + esc(col) + ' <span class="mt-count">' + + items.length + '</span></div>'; + h += '<div class="mt-col-body">'; + items.forEach(function (t) { + h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' + + '<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' + + '<span class="mt-card-title">' + esc(t.title) + '</span>' + + (t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' + + esc(t.due) + '</span>' : '') + + '<span class="mt-card-src">' + esc(t.collection_icon) + ' ' + + esc(t.collection_name) + '</span></div>'; + }); + h += '</div></div>'; + }); + return h + '</div>'; + } + + /* ── Vue calendrier (5 vues) ─────────────────────────────────────── */ + + var CAL_VIEWS = [ + ['month', 'Mois'], ['year', 'Année'], ['week', 'Semaine'], + ['day', 'Jour'], ['planning', 'Planning'] + ]; + var OPEN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + + 'stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/>' + + '<line x1="15" y1="3" x2="15" y2="21"/></svg>'; + + function calEnsure() { + if (!state.month) { + var n = new Date(); + state.month = { y: n.getFullYear(), m: n.getMonth() }; + } + if (!state.calDay) { + var t = new Date(); + state.calDay = t.getFullYear() + '-' + + ('0' + (t.getMonth() + 1)).slice(-2) + '-' + + ('0' + t.getDate()).slice(-2); + } + } + + function isoDate(d) { + return d.getFullYear() + '-' + + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + + ('0' + d.getDate()).slice(-2); + } + + function calDate(iso) { + var p = String(iso || '').split('-'); + return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2])); + } + + // Tâches groupées par jour d'échéance (celles sans échéance sont ignorées). + function tasksByDay() { + var byDay = {}; + state.tasks.forEach(function (t) { + if (!t.due) return; + (byDay[t.due] = byDay[t.due] || []).push(t); + }); + return byDay; + } + + // Décalage du calendrier, la granularité dépendant de la vue courante. + function calShift(delta) { + calEnsure(); + var v = state.calView; + if (v === 'year') { + state.month = { y: state.month.y + delta, m: state.month.m }; + } else if (v === 'week') { + var w = calDate(state.calDay); + w.setDate(w.getDate() + delta * 7); + state.calDay = isoDate(w); + } else if (v === 'day') { + var d = calDate(state.calDay); + d.setDate(d.getDate() + delta); + state.calDay = isoDate(d); + state.month = { y: d.getFullYear(), m: d.getMonth() }; + } else { + var m = state.month.m + delta; + state.month = { + m: ((m % 12) + 12) % 12, + y: state.month.y + Math.floor(m / 12) + }; + } + render(); + } + + function calTitle() { + calEnsure(); + var y = state.month.y, m = state.month.m; + if (state.calView === 'year') return String(y); + if (state.calView === 'planning') return 'Planning — ' + MONTHS[m] + ' ' + y; + if (state.calView === 'day') { + var d = calDate(state.calDay); + return DAYS[(d.getDay() + 6) % 7] + ' ' + d.getDate() + ' ' + + MONTHS[d.getMonth()] + ' ' + d.getFullYear(); + } + if (state.calView === 'week') { + var s = calDate(state.calDay); + var lead = (s.getDay() + 6) % 7; // lundi = 0 + s.setDate(s.getDate() - lead); + var e = new Date(s.getFullYear(), s.getMonth(), s.getDate() + 6); + return s.getDate() + ' – ' + e.getDate() + ' ' + MONTHS[e.getMonth()] + + ' ' + e.getFullYear(); + } + return MONTHS[m] + ' ' + y; + } + + function calTabs() { + var h = '<div class="mt-cal-tabs">'; + CAL_VIEWS.forEach(function (v) { + h += '<button class="mt-cal-tab' + (state.calView === v[0] ? ' active' : '') + + '" data-cal-view="' + v[0] + '">' + v[1] + '</button>'; + }); + return h + '</div>'; + } + + // Événement de calendrier : titre + bouton d'ouverture au survol. + function calEvHtml(t, draggable) { + return '<div class="mt-cal-ev' + (draggable ? ' drag' : '') + '"' + + (draggable ? ' draggable="true" data-id="' + t.id + '"' : '') + + ' title="' + esc(t.collection_name) + ' · ' + esc(t.status || '') + + ' · ' + esc(t.due || '') + '">' + + '<span class="mt-cal-ev-text">' + esc(t.title) + '</span>' + + '<button class="mt-cal-open mt-hover-only" data-open-row="' + t.id + + '" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' + + 'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + '</button>' + + '</div>'; + } + + function viewCalendar() { + calEnsure(); + var h = '<div class="mt-cal">' + + '<div class="mt-cal-head">' + + '<button class="mt-cal-nav" data-nav="-1" aria-label="Précédent">‹</button>' + + '<span class="mt-cal-title">' + esc(calTitle()) + '</span>' + + '<button class="mt-cal-nav" data-nav="1" aria-label="Suivant">›</button>' + + '</div>' + calTabs() + '<div class="mt-cal-body">'; + if (state.calView === 'year') h += calYear(); + else if (state.calView === 'week') h += calWeek(); + else if (state.calView === 'day') h += calDay(); + else if (state.calView === 'planning') h += calPlanning(); + else h += calMonth(); + return h + '</div></div>'; + } + + /* Mois : grille 7 × 6, glisser-déposer pour changer l'échéance. */ + function calMonth() { + calEnsure(); + var y = state.month.y, m = state.month.m; + var byDay = tasksByDay(); + var first = new Date(y, m, 1); + var lead = (first.getDay() + 6) % 7; // lundi = 0 + var start = new Date(y, m, 1 - lead); + var todayIso = isoDate(new Date()); + + var h = '<div class="mt-cal-grid">'; + DAYS.forEach(function (d) { h += '<div class="mt-cal-dow">' + d + '</div>'; }); + for (var i = 0; i < 42; i++) { + var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); + var key = isoDate(d); + var evs = byDay[key] || []; + h += '<div class="mt-cal-cell' + (d.getMonth() !== m ? ' out' : '') + + (key === todayIso ? ' today' : '') + '" data-day="' + key + '">'; + h += '<div class="mt-cal-day">' + d.getDate() + '</div>'; + evs.forEach(function (t) { h += calEvHtml(t, true); }); + h += '</div>'; + } + return h + '</div>'; + } + + /* Année : 12 mini-mois ; un point signale un jour chargé. */ + function calYear() { + calEnsure(); + var byDay = tasksByDay(); + var todayIso = isoDate(new Date()); + var h = '<div class="mt-cal-year">'; + for (var mo = 0; mo < 12; mo++) { + var y = state.month.y; + var first = new Date(y, mo, 1); + var lead = (first.getDay() + 6) % 7; + var start = new Date(y, mo, 1 - lead); + var dim = new Date(y, mo + 1, 0).getDate(); + h += '<div class="mt-cal-mini">'; + h += '<div class="mt-cal-mini-title">' + MONTHS[mo] + '</div>'; + h += '<div class="mt-cal-mini-grid">'; + DAYS.forEach(function (d) { + h += '<span class="mt-cal-mini-dow">' + d.charAt(0) + '</span>'; + }); + for (var i = 0; i < lead; i++) h += '<span class="mt-cal-mini-day out"></span>'; + for (var dd = 1; dd <= dim; dd++) { + var key = y + '-' + ('0' + (mo + 1)).slice(-2) + '-' + + ('0' + dd).slice(-2); + var n = (byDay[key] || []).length; + h += '<button class="mt-cal-mini-day' + (key === todayIso ? ' today' : '') + + (n ? ' busy' : '') + '" data-cal-day="' + key + '" ' + + 'title="' + dd + ' ' + MONTHS[mo] + (n ? ' — ' + n + ' tâche(s)' : '') + '">' + + dd + '</button>'; + } + h += '</div></div>'; + } + return h + '</div>'; + } + + /* Semaine : 7 colonnes, une par jour. */ + function calWeek() { + calEnsure(); + var byDay = tasksByDay(); + var s = calDate(state.calDay); + var lead = (s.getDay() + 6) % 7; // lundi = 0 + s.setDate(s.getDate() - lead); + var todayIso = isoDate(new Date()); + var h = '<div class="mt-cal-week">'; + for (var i = 0; i < 7; i++) { + var d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i); + var key = isoDate(d); + var evs = byDay[key] || []; + h += '<div class="mt-cal-wcol' + (key === todayIso ? ' today' : '') + '">'; + h += '<div class="mt-cal-wcol-head">' + DAYS[i] + ' ' + d.getDate() + '</div>'; + h += '<div class="mt-cal-wcol-body">'; + if (!evs.length) h += '<div class="mt-cal-wempty">—</div>'; + evs.forEach(function (t) { h += calEvHtml(t, false); }); + h += '</div></div>'; + } + return h + '</div>'; + } + + /* Jour : la liste complète des tâches du jour sélectionné. */ + function calDay() { + calEnsure(); + var byDay = tasksByDay(); + var key = state.calDay; + var evs = byDay[key] || []; + var h = '<div class="mt-cal-dayview">'; + h += '<div class="mt-cal-dayview-head">' + esc(calTitle()) + + ' · <b>' + evs.length + '</b> tâche(s)</div>'; + if (!evs.length) { + h += '<div class="mt-cal-dayview-empty">Aucune tâche pour ce jour.</div>'; + return h + '</div>'; + } + h += '<div class="mt-cal-dayview-list">'; + evs.forEach(function (t) { + h += '<div class="mt-cal-task" data-id="' + t.id + '">' + + '<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' + + '<span class="mt-cal-task-title">' + esc(t.title) + '</span>' + + '<span class="mt-cal-task-src">' + esc(t.collection_icon) + ' ' + + esc(t.collection_name) + '</span>' + + '<span class="mt-cal-task-status">' + esc(t.status || '—') + '</span>' + + '<button class="mt-open-btn" data-open-row="' + t.id + + '" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' + + 'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + ' Open</button>' + + '</div>'; + }); + return h + '</div></div>'; + } + + /* Planning : colonnes = statuts, lignes = jours du mois (tâches placées + à leur date d'échéance). C'est la vue « planning » de Notion. */ + function calPlanning() { + calEnsure(); + var byDay = tasksByDay(); + var y = state.month.y, m = state.month.m; + var statuses = [], seen = {}; + state.tasks.forEach(function (t) { + if (!t.due) return; + var s = t.status || 'Sans statut'; + if (!seen[s]) { seen[s] = 1; statuses.push(s); } + }); + if (!statuses.length) statuses = ['Sans statut']; + var dim = new Date(y, m + 1, 0).getDate(); + var todayIso = isoDate(new Date()); + + var h = '<div class="mt-cal-plan">'; + h += '<div class="mt-cal-plan-row head"><div class="mt-cal-plan-gutter"></div>'; + statuses.forEach(function (s) { + h += '<div class="mt-cal-plan-col-title">' + esc(s) + '</div>'; + }); + h += '</div>'; + for (var d = 1; d <= dim; d++) { + var key = y + '-' + ('0' + (m + 1)).slice(-2) + '-' + ('0' + d).slice(-2); + var evs = byDay[key] || []; + h += '<div class="mt-cal-plan-row' + (key === todayIso ? ' today' : '') + '">' + + '<div class="mt-cal-plan-gutter">' + d + '</div>'; + statuses.forEach(function (s) { + h += '<div class="mt-cal-plan-cell">'; + evs.filter(function (t) { + return (t.status || 'Sans statut') === s; + }).forEach(function (t) { h += calEvHtml(t, false); }); + h += '</div>'; + }); + h += '</div>'; + } + return h + '</div>'; + } + + /* ── États vides ───────────────────────────────────────────────── */ + + function emptyState() { + var title, msg, hint, act; + var connected = configuredSources().length; + if (!state.sources.length) { + title = 'Aucune base connectée'; + msg = "Aucune base de tâches n'alimente ce tableau de bord."; + hint = 'Ouvrez une base, puis cliquez sur « Convertir en base de tâches » ' + + 'dans sa barre (à gauche du bouton New) et reliez les colonnes ' + + 'Assigné à, Statut et Échéance.'; + act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>'; + } else if (!connected) { + title = 'Mapping incomplet'; + msg = 'Bases connectées mais aucune configurée.'; + hint = 'Il manque le mapping Assigné à / Statut / Échéance. Ouvrez la ' + + 'base et cliquez sur « Configurer » dans sa barre.'; + act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>'; + } else if (state.due !== 'all' || state.hide_done || state.sort !== 'due') { + title = 'Aucun résultat'; + msg = 'Aucune tâche ne correspond aux filtres actifs.'; + hint = ''; + act = '<a class="mt-empty-btn" href="/my-tasks">Réinitialiser les filtres</a>'; + } else { + title = 'Rien à faire'; + msg = "Aucune tâche ne vous est assignée pour le moment."; + hint = 'Ajoutez-en une, ou branchesz une autre base pour voir plus large.'; + act = '<button class="mt-empty-btn" id="mt-new-btn">Créer une tâche</button>'; + } + // Même gabarit centré que l'état vide rendu par le serveur + // (`_render_empty`), pour que les deux se ressemblent. + return '<div class="mt-empty"><div class="mt-empty-icon">📋</div>' + + '<h2>' + esc(title) + '</h2><p>' + esc(msg) + '</p>' + + (hint ? '<p class="mt-empty-hint">' + esc(hint) + '</p>' : '') + + act + '</div>'; + } + + /* ── Modale « nouvelle tâche » (multi-destinations) ────────────── */ + + // Ouvre la modale de conversion pour une base donnée (les bases + // connectées mais mal configurées ne peuvent pas recevoir de tâche). + function openConvertModal(collectionId) { + if (collectionId && window.FlowDeckTaskDb) { + window.FlowDeckTaskDb.open(Number(collectionId)); + } + } + + function openNewModal() { + var srcs = configuredSources(); + if (!srcs.length) { + alert("Aucune base de tâches connectée.\n\nConvertissez d'abord une base : " + + "ouvrez-la, puis cliquez sur « Convertir en base de tâches » " + + "dans la barre de la base, à gauche du bouton New."); + return; + } + var groups = {}; + srcs.forEach(function (s) { + (groups[s.workspace_name || 'Autres'] = groups[s.workspace_name || 'Autres'] || []) + .push(s); + }); + var h = '<div class="mt-modal-overlay" id="mt-modal-overlay"><div class="mt-modal">'; + h += '<div class="mt-modal-head"><span>Nouvelle tâche</span>' + + '<button class="mt-modal-close" id="mt-modal-close">✕</button></div>'; + h += '<div class="mt-modal-body">'; + h += '<label class="mt-field">Titre<input id="mt-new-title" type="text" ' + + 'placeholder="Ex. Envoyer la maquette"></label>'; + h += '<label class="mt-field">Échéance<input id="mt-new-due" type="date"></label>'; + Object.keys(groups).forEach(function (ws) { + h += '<div class="mt-modal-group">' + esc(ws) + '</div>'; + groups[ws].forEach(function (s) { + h += '<label class="mt-dest"><input type="radio" name="mt-dest" value="' + + s.id + '"><span class="mt-icon">' + esc(s.icon || '📋') + '</span>' + + esc(s.name) + '</label>'; + }); + }); + h += '</div><div class="mt-modal-foot">' + + '<button class="mt-btn-cancel" id="mt-new-cancel">Annuler</button>' + + '<button class="mt-btn-primary" id="mt-new-create">Créer</button></div>'; + h += '</div></div>'; + document.body.insertAdjacentHTML('beforeend', h); + + var first = document.querySelector('input[name="mt-dest"]'); + if (first) first.checked = true; + document.getElementById('mt-new-title').focus(); + + var close = function () { + var ov = document.getElementById('mt-modal-overlay'); + if (ov) ov.remove(); + }; + // `Escape` ferme aussi la modale. + document.addEventListener('keydown', function onEsc(e) { + if (e.key !== 'Escape') return; + document.removeEventListener('keydown', onEsc); + close(); + }); + document.getElementById('mt-modal-close').onclick = close; + document.getElementById('mt-new-cancel').onclick = close; + document.getElementById('mt-modal-overlay').onclick = function (e) { + if (e.target === this) close(); + }; + document.getElementById('mt-new-create').onclick = function () { + var title = document.getElementById('mt-new-title').value.trim(); + var due = document.getElementById('mt-new-due').value; + var dest = document.querySelector('input[name="mt-dest"]:checked'); + if (!title) { alert('Donnez un titre à la tâche.'); return; } + if (!dest) { alert('Choisissez une base de destination.'); return; } + api('/my-tasks/api/task', { + method: 'POST', + body: { collection_id: Number(dest.value), title: title, due: due || null } + }).then(function () { close(); load(); }) + .catch(function (e) { close(); alert(e.message); }); + }; + } + + /* ── Écriture en direct ────────────────────────────────────────── */ + + function patch(id, field, value) { + return api('/my-tasks/api/task/' + id, { method: 'PUT', body: { field: field, value: value } }) + .then(function (d) { + if (d.left_mytasks) { + state.tasks = state.tasks.filter(function (t) { return t.id !== id; }); + render(); + } else if (d.task) { + state.tasks = state.tasks.map(function (t) { + return t.id === d.task.id ? d.task : t; + }); + render(); + } + }) + .catch(function (e) { alert(e.message); load(); }); + } + + function startTitleEdit(span) { + if (span.querySelector('input')) return; + var tr = span.closest('.mt-row'); + var id = Number(tr.dataset.id); + var old = span.textContent; + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'mt-title-input'; + input.value = old; + span.textContent = ''; + span.appendChild(input); + input.focus(); + input.select(); + var done = false; + var commit = function () { + if (done) return; + done = true; + var v = input.value.trim(); + if (v && v !== old) patch(id, 'title', v); else render(); + }; + input.onblur = commit; + input.onkeydown = function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') { done = true; render(); } + }; + } + + /* ── Rendu + délégation d'événements ───────────────────────────── */ + + function render() { + var body = state.view === 'board' ? viewBoard() + : state.view === 'calendar' ? viewCalendar() + : viewTable(); + app.innerHTML = '<div class="mt-head"><h1>📋 My Tasks</h1>' + + '<span class="mt-count-total">' + state.tasks.length + ' tâche(s)</span></div>' + + toolbar() + body; + wireDnD(); + } + + function wireDnD() { + var cards = app.querySelectorAll('[draggable="true"]'); + cards.forEach(function (c) { + c.addEventListener('dragstart', function (e) { + e.dataTransfer.setData('text/plain', c.dataset.id); + e.dataTransfer.effectAllowed = 'move'; + }); + }); + app.querySelectorAll('.mt-col').forEach(function (col) { + col.addEventListener('dragover', function (e) { e.preventDefault(); }); + col.addEventListener('drop', function (e) { + e.preventDefault(); + var id = Number(e.dataTransfer.getData('text/plain')); + if (id && col.dataset.status) patch(id, 'status', col.dataset.status); + }); + }); + // Calendrier : glisser une tâche sur un jour change son échéance. + app.querySelectorAll('.mt-cal-cell').forEach(function (cell) { + cell.addEventListener('dragover', function (e) { e.preventDefault(); }); + cell.addEventListener('drop', function (e) { + e.preventDefault(); + var id = Number(e.dataTransfer.getData('text/plain')); + if (id && cell.dataset.day) patch(id, 'due', cell.dataset.day); + }); + }); + } + + if (wire) app.addEventListener('click', function (e) { + var t = e.target; + + if (t.id === 'mt-new-btn') { openNewModal(); return; } + if (t.dataset && t.dataset.openRow) { + e.preventDefault(); + // Le titre vient de l'attribut : il est disponible aussi bien dans + // le tableau que dans le calendrier ou la vue Jour. + var label = t.dataset.openTitle; + if (!label) { + var row = t.closest('.mt-row'); + var cell = row && row.querySelector('.mt-title-text'); + label = cell ? cell.textContent : ''; + } + openRowContent(Number(t.dataset.openRow), label); + return; + } + if (t.dataset && t.dataset.fixSource) { + e.preventDefault(); + openConvertModal(t.dataset.fixSource); + return; + } + if (t.classList.contains('mt-cal-nav')) { + calShift(Number(t.dataset.nav)); + return; + } + if (t.dataset && t.dataset.calView) { + state.calView = t.dataset.calView; + render(); + return; + } + if (t.dataset && t.dataset.calDay) { + // Un jour de la vue Année ouvre la vue Jour dessus. + state.calDay = t.dataset.calDay; + var cd = calDate(state.calDay); + state.month = { y: cd.getFullYear(), m: cd.getMonth() }; + state.calView = 'day'; + render(); + return; + } + if (t.dataset && t.dataset.edit === 'title') { startTitleEdit(t); return; } + + var link = t.closest ? t.closest('a.mt-view-tab') : null; + if (link) { + e.preventDefault(); + var p = new URLSearchParams(link.getAttribute('href').split('?')[1] || ''); + state.view = p.get('view') || 'table'; + syncUrl(); + render(); + } + }); + + if (wire) app.addEventListener('change', function (e) { + var t = e.target; + if (t.classList.contains('mt-select')) { + state[t.dataset.key] = t.value; + syncUrl(); + load(); + } else if (t.id === 'mt-hide-done') { + state.hide_done = t.checked; + syncUrl(); + load(); + } else if (t.classList.contains('mt-status-select')) { + patch(Number(t.dataset.id), 'status', t.value); + } else if (t.classList.contains('mt-due-input')) { + patch(Number(t.dataset.id), 'due', t.value); + } + }); + + load(); + + /* Une modale ou un panneau restés ouverts pendant une navigation + * masqueraient la page suivante : on referme au remontage. */ + if (wire) { + var staleModal = document.getElementById('mt-modal-overlay'); + if (staleModal) staleModal.remove(); + closePeek(); + document.addEventListener('keydown', function (e) { + // `Escape` ferme le panneau latéral, comme dans Library. + if (e.key === 'Escape') closePeek(); + }); + } +} + +// htmx ré-exécute les <script> de la zone swappée : on remonte à chaque +// navigation interne. `popstate` couvre le bouton « retour », où le <script> +// n'est pas forcément rejoué. +document.addEventListener('htmx:afterSwap', flowDeckMyTasks); +window.addEventListener('popstate', flowDeckMyTasks); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', flowDeckMyTasks); +} else { + flowDeckMyTasks(); +} diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index faedbd4..d3e1d4d 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -2104,9 +2104,9 @@ applyAIBlocks(text){ async _pickSyncedBlock(idx){ const block=this.blocks[idx];if(!block)return; try{ - const r=await fetch('/api/synced-blocks');const d=await r.json(); + const r=await fetch('/board/api/synced-blocks');const d=await r.json(); const items=d.synced_blocks||[]; - if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;} + if(!items.length){this.showToast('No synced blocks yet — create one from a Board page first.');return;} const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';}); const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n')); if(!choice)return; @@ -2114,7 +2114,7 @@ applyAIBlocks(text){ if(!match)return; const sid=parseInt(match[1]); if(!sid){this.showToast('Invalid synced block','error');return;} - const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json(); + const sr=await fetch('/board/api/synced-blocks/'+sid);const sd=await sr.json(); if(!sd||!sd.content){this.showToast('Synced block not found','error');return;} block.synced_id=sid; block._synced_content=sd.content||[]; @@ -2545,4 +2545,34 @@ applyAIBlocks(text){ if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); } else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); }); window.editorState = editorState; + + // ── Ctrl/Cmd + S : sauvegarder le document ──────────────────────── + // L'autosave (1,5 s) suffit pour la frappe au quotidien, mais on ne peut + // pas fermer l'onglet « au bout d'un moment » en etant sur que tout est + // parti. Le raccourci est attendu par tout le monde : il appelle le même + // `save()` que l'autosave (meme endpoint, meme file hors-ligne) et rend + // la main explicitement. + // `window.E` survit aux navigations partielles : on exige donc que + // l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon + // Ctrl+S sauvegarderait un editeur detache depuis une autre page. + document.addEventListener('keydown', function (e) { + if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; + if (String(e.key).toLowerCase() !== 's') return; + var E = window.E; + if (!E || !document.getElementById('_blocksCt') || typeof E.save !== 'function') return; + e.preventDefault(); // sinon le navigateur ouvre « Enregistrer la page » + if (E.canEdit === false) { + if (E.showToast) E.showToast('This page is locked (read-only)', 'error'); + return; + } + if (E.saving) { if (E.showToast) E.showToast('Saving…'); return; } + E.dirty = true; + var notify = function () { + if (E.showToast) E.showToast('Saved', 'success'); + else if (window.showToast) window.showToast('Saved', 'success'); + }; + try { E.save(notify); } catch (err) { + if (E.showToast) E.showToast('Save failed', 'error'); + } + }); } diff --git a/static/js/task_db_link.js b/static/js/task_db_link.js new file mode 100644 index 0000000..963a8de --- /dev/null +++ b/static/js/task_db_link.js @@ -0,0 +1,205 @@ +/* ════════════════════════════════════════════════════════════════════════ + * FlowDeck — « Convertir en base de tâches » + * + * Modale de linkage : pour les trois rôles requis (Assigné à / Statut / + * Échéance) on choisit soit une colonne existante compatible, soit on en + * crée une nouvelle avec le bon type. Le serveur fait foi + * (`POST /db/{id}/task-db/api`) : il valide les types, refuse le double + * emploi d'une colonne et applique la limite de 10 bases connectées. + * ══════════════════════════════════════════════════════════════════════ */ +(function () { + 'use strict'; + if (window.__fdTaskDbLinkLoaded) return; + window.__fdTaskDbLinkLoaded = true; + + var ROLE_LABELS = { assignee: 'Assigné à', status: 'Statut', due: "Date d'échéance" }; + var ROLE_HINTS = { + assignee: 'Colonne « personne » utilisée pour filtrer vos tâches.', + status: 'Colonne statut ou sélection (À faire / En cours / Terminée).', + due: 'Colonne de type date, utilisée par les vues et le calendrier.' + }; + var DEFAULT_NAMES = { assignee: 'Assigné à', status: 'Statut', due: "Échéance" }; + var TYPE_LABELS = { + person: 'personne', status: 'statut', select: 'sélection', date: 'date', + text: 'texte', number: 'nombre', checkbox: 'case à cocher' + }; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + function csrf() { + return (typeof window.getCsrf === 'function') ? window.getCsrf() : ''; + } + + function api(path, opts) { + opts = opts || {}; + var headers = { 'X-CSRF-Token': csrf() }; + if (opts.body) headers['Content-Type'] = 'application/json'; + return fetch(path, { + method: opts.method || 'GET', + credentials: 'same-origin', + headers: headers, + body: opts.body ? JSON.stringify(opts.body) : undefined + }).then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { + if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status)); + return d; + }); + }); + } + + /* ── Modale ─────────────────────────────────────────────────────── */ + + function roleRow(state, role) { + var selected = state.mapping[role]; + var current = state.candidates[role] || []; + // La colonne déjà mappée doit rester sélectionnable même si elle est + // de type compatible mais exclue des candidats (déjà affectée). + var all = state.all_properties || []; + if (selected && !current.some(function (p) { return p.id === selected; })) { + var kept = all.filter(function (p) { return p.id === selected; })[0]; + if (kept) current = [kept].concat(current); + } + + var h = '<div class="tdb-role" data-role="' + role + '">'; + h += '<div class="tdb-role-head"><label>' + esc(ROLE_LABELS[role]) + '</label>'; + h += '<span class="tdb-role-hint">' + esc(ROLE_HINTS[role]) + '</span></div>'; + h += '<div class="tdb-role-body">'; + h += '<select class="tdb-select" data-tdb-role="' + role + '">'; + h += '<option value="">— créer une nouvelle colonne —</option>'; + current.forEach(function (p) { + h += '<option value="' + p.id + '"' + + (String(selected) === String(p.id) ? ' selected' : '') + '>' + + esc(p.name) + ' (' + esc(TYPE_LABELS[p.prop_type] || p.prop_type) + + ')</option>'; + }); + h += '</select>'; + h += '<input class="tdb-new-name" data-tdb-new="' + role + '" type="text" ' + + 'placeholder="' + esc(DEFAULT_NAMES[role]) + '" ' + + (selected ? 'style="display:none"' : '') + '>'; + h += '</div></div>'; + return h; + } + + function openModal(collectionId) { + closeModal(); + api('/db/' + collectionId + '/task-db/api').then(function (state) { + if (!state.exists) return; + + var roles = ['assignee', 'status', 'due']; + var h = '<div class="tdb-overlay" id="tdb-overlay"><div class="tdb-modal">'; + h += '<div class="tdb-head"><span>' + + (state.is_task ? 'Base de tâches' : 'Convertir en base de tâches') + + '</span><button class="tdb-close" id="tdb-close">✕</button></div>'; + h += '<div class="tdb-intro">Pour alimenter <a href="/my-tasks">My Tasks</a>, ' + + 'reliez les trois colonnes ci-dessous. Une colonne absente sera créée ' + + 'avec le bon type.</div>'; + h += '<div class="tdb-body">'; + roles.forEach(function (r) { h += roleRow(state, r); }); + h += '</div>'; + if (state.sources_count >= state.max_sources) { + h += '<div class="tdb-warn">⚠ Limite de ' + state.max_sources + + ' bases connectées atteinte : déconnectez-en une avant.</div>'; + } + h += '<div class="tdb-foot">'; + if (state.is_task) { + h += '<button class="tdb-btn-danger" id="tdb-disable">' + + 'Disconnecter</button>'; + } + h += '<span class="tdb-spacer"></span>'; + h += '<button class="tdb-btn" id="tdb-cancel">Annuler</button>'; + h += '<button class="tdb-btn-primary" id="tdb-save"' + + (state.sources_count >= state.max_sources && !state.is_task + ? ' disabled' : '') + '>' + + (state.is_task ? 'Mettre à jour' : 'Convertir') + '</button>'; + h += '</div></div></div>'; + document.body.insertAdjacentHTML('beforeend', h); + wire(state, collectionId); + }).catch(function (e) { alert(e.message); }); + } + + function closeModal() { + var ov = document.getElementById('tdb-overlay'); + if (ov) ov.remove(); + } + + function wire(state, collectionId) { + var overlay = document.getElementById('tdb-overlay'); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) closeModal(); + }); + document.getElementById('tdb-close').onclick = closeModal; + document.getElementById('tdb-cancel').onclick = closeModal; + + // Choisir une colonne masque le champ « nouvelle colonne ». + overlay.addEventListener('change', function (e) { + var role = e.target.dataset.tdbRole; + if (!role) return; + var input = overlay.querySelector('[data-tdb-new="' + role + '"]'); + if (input) input.style.display = e.target.value ? 'none' : ''; + }); + + var disable = document.getElementById('tdb-disable'); + if (disable) { + disable.onclick = function () { + if (!confirm('Disconnecter cette base de My Tasks ?\n\n' + + 'Les tâches restent dans la base.')) return; + api('/db/' + collectionId + '/task-db/api', { method: 'DELETE' }) + .then(function () { closeModal(); toast('Base déconnectée de My Tasks'); }) + .catch(function (e) { alert(e.message); }); + }; + } + + document.getElementById('tdb-save').onclick = function () { + var mapping = {}, create_missing = {}; + ['assignee', 'status', 'due'].forEach(function (role) { + var sel = overlay.querySelector('[data-tdb-role="' + role + '"]'); + var val = sel ? sel.value : ''; + if (val) { + mapping[role] = Number(val); + } else { + var input = overlay.querySelector('[data-tdb-new="' + role + '"]'); + create_missing[role] = { + name: (input && input.value.trim()) || DEFAULT_NAMES[role] + }; + } + }); + api('/db/' + collectionId + '/task-db/api', + { method: 'POST', body: { mapping: mapping, create_missing: create_missing } }) + .then(function (d) { + closeModal(); + toast(d.configured + ? '✅ Base connectée à My Tasks' + : '⚠ Mapping incomplet — des colonnes manquent'); + }) + .catch(function (e) { alert(e.message); }); + }; + } + + function toast(msg) { + var t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' + + 'z-index:1200;background:var(--bg-modal,#fff);color:var(--text-primary,#333);' + + 'border:1px solid var(--border,#e3e3e0);border-radius:8px;padding:9px 16px;' + + 'font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.16)'; + document.body.appendChild(t); + setTimeout(function () { t.remove(); }, 3200); + } + + /* ── Point d'entrée depuis la barre de la base ──────────────────── */ + + window.FlowDeckTaskDb = { open: openModal, close: closeModal }; + + document.addEventListener('click', function (e) { + var trigger = e.target.closest ? e.target.closest('[data-task-db]') : null; + if (!trigger) return; + e.preventDefault(); + e.stopPropagation(); + var id = trigger.dataset.taskDb; + if (id) openModal(Number(id)); + }); +})(); diff --git a/tests/test_agent_web_tools.py b/tests/test_agent_web_tools.py new file mode 100644 index 0000000..8a66944 --- /dev/null +++ b/tests/test_agent_web_tools.py @@ -0,0 +1,430 @@ +"""FlowDeck — v7.46.0 : web tools de l'agent (web_search / fetch_url / search_code). + +Ces tests ne touchent JAMAIS le réseau : la couche httpx est injectée via le +paramètre ``transport`` des services, et les tools sont exercés à travers le +registre (``ToolRegistry.execute``) comme le ferait ``AgentEngine``. + +Points critiques couverts : +* le registre expose bien les 3 tools et leurs schémas ; +* chaque preset de la galerie ne référence que des tools existants ; +* ``web_search`` : provider Exa, repli DuckDuckGo, et filtrage des URLs + anti-bot du moteur ; +* ``fetch_url`` : markdown extrait, troncature, et **refus SSRF** (localhost, + IP privée, schémas `file:`/`ftp:`, lien vers metadata cloud à travers une + redirection) ; +* ``search_code`` : repos / code / issues, et message clair sur quota. + +Convention du projet : les tests async passent par ``asyncio.run`` (idem +``tests/test_agent.py``), pas de marqueur pytest-asyncio. +""" +from __future__ import annotations + +import asyncio +import contextlib +import ipaddress +import json +import socket + +import httpx +import pytest + +from app.config import settings +from app.services import http_client +from app.services import tool_registry as tr +from app.services import web_search as ws +from app.services.skill_gallery import GALLERY, export_skill, parse_payload +from app.services.tool_registry import ToolRegistry + +WEB_TOOLS = ("web_search", "fetch_url", "search_code") + +#: IP publique factice : les tests ne doivent dépendre ni du réseau HTTP ni du DNS. +PUBLIC_IP = "93.184.216.34" + + +@pytest.fixture(autouse=True) +def stub_dns(monkeypatch): + """Neutralise le DNS pour les noms d'hôtes publics. + + Le garde-fou SSRF (`_is_public_host`) résout l'hôte pour décider si l'IP + est publique : sans ce stub, chaque test `fetch_url` sur `example.com` + ferait une vraie résolution DNS — lente, et susceptible d'échouer sous + `pytest -n auto`, ce qui rendrait ces tests dépendants de l'environnement. + Les IP littérales (`127.0.0.1`, `169.254.169.254`, `::1`) gardent la + résolution réelle : le refus des adresses privées reste donc testé pour de + vrai, y compris sur les redirections. + """ + real = socket.getaddrinfo + + def fake_getaddrinfo(host, *args, **kwargs): + try: + ipaddress.ip_address(host) + except ValueError: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))] + return real(host, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + +@pytest.fixture +def registry() -> ToolRegistry: + return ToolRegistry() + + +@contextlib.contextmanager +def mock_http(handler): + """Injecte un transport httpx dans le client partagé des services. + + ``web_search`` importe ``shared_client`` au niveau module, l'outillage + ``fetch_url`` fait un import local : on patche les deux références pour + qu'aucun test n'atteigne jamais le réseau, même sans ``transport=``. + """ + original = http_client.shared_client + + def build(**kwargs): + kwargs.pop("transport", None) + kwargs["transport"] = httpx.MockTransport(handler) + return original(**kwargs) + + targets = (http_client, ws, tr) + for module in targets: + monkey = getattr(module, "shared_client", None) + if monkey is not None: + module.shared_client = build + try: + yield + finally: + for module in targets: + if getattr(module, "shared_client", None) is build: + module.shared_client = original + + +def run(coro): + return asyncio.run(coro) + + +# ── Registre ─────────────────────────────────────────────────────────────── + +def test_registry_exposes_web_tools(registry): + for name in WEB_TOOLS: + assert name in registry.tools, name + assert registry.tools[name].description + + +def test_registry_schema_includes_web_tools(registry): + names = [t["name"] for t in registry.schema()] + for name in WEB_TOOLS: + assert name in names + # Un tool hors périmètre ne doit jamais fuiter dans le schéma. + scoped = [t["name"] for t in registry.schema({"tools": ["search_workspace"]})] + assert scoped == ["search_workspace"] + + +def test_registry_rejects_unknown_tool(registry): + res = run(registry.execute("web_search_does_not_exist", {})) + assert res.status == "error" + + +# ── Cohérence galerie / registre ─────────────────────────────────────────── + +def test_gallery_allowed_tools_all_exist(): + tools = set(ToolRegistry().tools) + for slug, preset in GALLERY.items(): + unknown = [t for t in preset["allowed_tools"] if t not in tools] + assert not unknown, f"{slug} référence des tools inexistants : {unknown}" + + +def test_gallery_presets_are_complete(): + for slug, preset in GALLERY.items(): + assert preset["name"].strip(), slug + assert preset["description"].strip(), slug + assert preset["prompt_template"].strip(), slug + assert preset["allowed_tools"], slug + + +def test_web_presets_survive_export_import(): + """Chaque nouveau preset doit survivre au cycle export → import.""" + for slug in ("recherche-marche", "veille-techno", "debug-web"): + preset = GALLERY[slug] + payload = export_skill({ + "name": preset["name"], + "description": preset["description"], + "prompt_template": preset["prompt_template"], + "allowed_tools_json": json.dumps(preset["allowed_tools"]), + }) + fields = parse_payload(payload) + assert fields["name"] == preset["name"] + assert sorted(fields["allowed_tools"]) == sorted(preset["allowed_tools"]) + + +# ── web_search ───────────────────────────────────────────────────────────── + +EXA_BODY = { + "results": [ + {"title": "FastAPI docs", "url": "https://fastapi.tiangolo.com/", + "text": "FastAPI framework", "publishedDate": "2026-01-02T00:00:00Z"}, + {"title": "Spam", "url": "https://duckduckgo.com/y.js", "text": "à filtrer"}, + ], +} + +DDG_HTML = """ +<table><tr> +<td><a class="result-link" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=1">Résultat A</a></td> +<td class="result-snippet">Extrait du <b>résultat</b> A</td> +</tr><tr> +<td><a class="result-link" href="https://example.org/b">Résultat B</a></td> +<td class="result-snippet">Extrait B</td> +</tr></table> +""" + + +def test_web_search_exa(monkeypatch): + monkeypatch.setattr(settings, "web_search_provider", "exa") + monkeypatch.setattr(settings, "exa_api_key", "test-key") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["x-api-key"] == "test-key" + return httpx.Response(200, json=EXA_BODY) + + with mock_http(handler): + results, provider = run(ws.search_web("fastapi", transport=httpx.MockTransport(handler))) + + assert provider == "exa" + assert [r["url"] for r in results] == ["https://fastapi.tiangolo.com/"] + assert results[0]["source"] == "exa" + assert results[0]["title"] == "FastAPI docs" + assert results[0]["published"] == "2026-01-02" + + +def test_web_search_filters_search_engine_urls(monkeypatch): + """Une URL du moteur lui-même ne doit jamais être renvoyée à l'IA.""" + monkeypatch.setattr(settings, "web_search_provider", "exa") + monkeypatch.setattr(settings, "exa_api_key", "k") + handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731 + with mock_http(handler): + results, _ = run(ws.search_web("x", transport=httpx.MockTransport(handler))) + assert all("duckduckgo.com" not in r["url"] for r in results) + + +def test_web_search_falls_back_to_duckduckgo(monkeypatch): + """Sans clé Exa, le tool bascule sur le repli sans compte.""" + monkeypatch.setattr(settings, "web_search_provider", "exa") + monkeypatch.setattr(settings, "exa_api_key", "") + + def handler(request: httpx.Request) -> httpx.Response: + assert "duckduckgo" in str(request.url) + return httpx.Response(200, text=DDG_HTML, + headers={"content-type": "text/html"}) + + with mock_http(handler): + results, provider = run(ws.search_web("test", transport=httpx.MockTransport(handler))) + + assert provider == "duckduckgo" + assert [r["url"] for r in results] == ["https://example.com/a", "https://example.org/b"] + assert results[0]["title"] == "Résultat A" + assert results[0]["snippet"] == "Extrait du résultat A" + + +def test_web_search_empty_query_returns_nothing(monkeypatch): + monkeypatch.setattr(settings, "exa_api_key", "k") + results, provider = run(ws.search_web(" ")) + assert results == [] + assert provider == "" + + +def test_available_providers_reflects_config(monkeypatch): + monkeypatch.setattr(settings, "exa_api_key", "") + assert ws.available_providers()["exa"] is False + monkeypatch.setattr(settings, "exa_api_key", "k") + assert ws.available_providers()["exa"] is True + + +def test_web_search_tool_returns_actionable_error(registry, monkeypatch): + """Aucun provider ne répond → le tool dit comment corriger.""" + monkeypatch.setattr(settings, "exa_api_key", "") + handler = lambda r: httpx.Response(503, text="") # noqa: E731 + with mock_http(handler): + res = run(registry.execute("web_search", {"query": "test"})) + assert res.status == "error" + assert "EXA_API_KEY" in res.message + + +def test_web_search_tool_success(registry, monkeypatch): + monkeypatch.setattr(settings, "web_search_provider", "exa") + monkeypatch.setattr(settings, "exa_api_key", "k") + handler = lambda r: httpx.Response(200, json=EXA_BODY) # noqa: E731 + with mock_http(handler): + res = run(registry.execute("web_search", {"query": "fastapi", "num_results": 3})) + assert res.status == "success" + assert res.data["count"] == 1 + assert res.data["provider"] == "exa" + + +# ── fetch_url ────────────────────────────────────────────────────────────── + +HTML_PAGE = """ +<html><head><title>Titre de la page + +
+

Titre de la page

+

Premier paragraphe avec du gras.

+
print("hello")
+
  • un
  • deux
+
+""" + + +def test_fetch_url_returns_markdown(registry): + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://example.com/page" + return httpx.Response(200, text=HTML_PAGE, + headers={"content-type": "text/html; charset=utf-8"}) + + with mock_http(handler): + res = run(registry.execute("fetch_url", {"url": "https://example.com/page"})) + + assert res.status == "success", res.message + assert "Premier paragraphe" in res.data["markdown"] + assert "print" in res.data["markdown"] + assert res.data["title"] == "Titre de la page" + assert res.data["truncated"] is False + + +@pytest.mark.parametrize("url", [ + "http://localhost:8080/api/health", + "http://127.0.0.1/admin", + "http://169.254.169.254/latest/meta-data/", + "http://[::1]/", + "file:///etc/passwd", + "ftp://example.com/x", +]) +def test_fetch_url_blocks_ssrf_targets(registry, url): + res = run(registry.execute("fetch_url", {"url": url})) + assert res.status == "error", f"{url} aurait dû être refusé" + + +def test_fetch_url_revalidates_redirect_target(registry): + """Une URL publique redirigeant vers le metadata cloud doit être refusée.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/"}) + + with mock_http(handler): + res = run(registry.execute("fetch_url", {"url": "https://example.com/"})) + + assert res.status == "error" + assert "non autorisé" in res.message + + +def test_fetch_url_follows_public_redirect(registry): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/old": + return httpx.Response(301, headers={"location": "https://example.com/new"}) + return httpx.Response(200, text=HTML_PAGE, + headers={"content-type": "text/html"}) + + with mock_http(handler): + res = run(registry.execute("fetch_url", {"url": "https://example.com/old"})) + + assert res.status == "success", res.message + assert res.data["url"] == "https://example.com/new" + + +def test_fetch_url_truncates(registry): + long_html = "

" + ("a" * 50000) + "

" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=long_html, + headers={"content-type": "text/html"}) + + with mock_http(handler): + res = run(registry.execute( + "fetch_url", {"url": "https://example.com/long", "max_chars": 1000})) + + assert res.status == "success" + assert res.data["truncated"] is True + assert len(res.data["markdown"]) < 1200 + + +def test_fetch_url_empty_url(registry): + assert run(registry.execute("fetch_url", {"url": " "})).status == "error" + + +# ── search_code ──────────────────────────────────────────────────────────── + +GH_REPOS = { + "total_count": 2, + "items": [ + {"full_name": "tiangolo/fastapi", "html_url": "https://github.com/tiangolo/fastapi", + "description": "FastAPI framework", "stargazers_count": 80000}, + {"full_name": "someone/fastapi-clone", "html_url": "https://github.com/someone/fastapi-clone", + "description": "A clone", "stargazers_count": 3}, + ], +} + +GH_ISSUES = { + "total_count": 1, + "items": [ + {"number": 42, "title": "Crash on startup", "state": "closed", + "html_url": "https://github.com/o/r/issues/42", "body": "It crashes"}, + ], +} + + +def test_search_code_repos(monkeypatch): + monkeypatch.setattr(settings, "github_token", "") + handler = lambda r: httpx.Response(200, json=GH_REPOS) # noqa: E731 + results = run(ws.search_github("fastapi", "repositories", 5, + transport=httpx.MockTransport(handler))) + assert [r["title"] for r in results] == ["tiangolo/fastapi", "someone/fastapi-clone"] + assert results[0]["stars"] == 80000 + assert results[0]["source"] == "github/repos" + + +def test_search_code_issues_sends_token(monkeypatch): + monkeypatch.setattr(settings, "github_token", "ghp_test") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer ghp_test" + assert request.url.path.endswith("/search/issues") + return httpx.Response(200, json=GH_ISSUES) + + results = run(ws.search_github("crash", "issues", 5, + transport=httpx.MockTransport(handler))) + assert results[0]["title"] == "Crash on startup" + assert results[0]["state"] == "closed" + assert results[0]["url"].endswith("/issues/42") + + +def test_search_code_quota_message(monkeypatch): + monkeypatch.setattr(settings, "github_token", "") + handler = lambda r: httpx.Response(403, json={}) # noqa: E731 + with pytest.raises(RuntimeError) as err: + run(ws.search_github("x", "repos", 5, transport=httpx.MockTransport(handler))) + assert "GITHUB_TOKEN" in str(err.value) + + +def test_search_code_tool_wraps_errors(registry, monkeypatch): + """Le tool doit transformer une erreur GitHub en message lisible, sans réseau.""" + async def _boom(*a, **kw): + raise RuntimeError("GitHub: requête de recherche invalide (422)") + + # L'outillage importe ``search_github`` au moment de l'appel : c'est + # l'attribut du module qu'il faut patcher, pas celui du registre. + monkeypatch.setattr(ws, "search_github", _boom) + res = run(registry.execute("search_code", {"query": "!!!"})) + assert res.status == "error" + assert "GitHub" in res.message + + +def test_search_code_tool_success(registry, monkeypatch): + monkeypatch.setattr(settings, "github_token", "") + handler = lambda r: httpx.Response(200, json=GH_REPOS) # noqa: E731 + real_search = ws.search_github + + async def _fake(query, kind="repositories", limit=5, transport=None): + return await real_search(query, kind, limit, transport=httpx.MockTransport(handler)) + + monkeypatch.setattr(ws, "search_github", _fake) + res = run(registry.execute("search_code", {"query": "fastapi", "kind": "repositories"})) + assert res.status == "success" + assert res.data["count"] == 2 + assert res.data["results"][0]["title"] == "tiangolo/fastapi" diff --git a/tests/test_anomalies_v746.py b/tests/test_anomalies_v746.py new file mode 100644 index 0000000..d698eb6 --- /dev/null +++ b/tests/test_anomalies_v746.py @@ -0,0 +1,376 @@ +"""FlowDeck — correctifs d'anomalies v7.46.0 (audit de fonctionnement). + +Chaque test verrouille une anomalie réellement constatée sur l'instance : + +* **P0-3** ``/auth/user`` renvoyait le ``password_hash`` (et le cookie de + session, signé mais non chiffré, l'embarquait) ; +* **P0-4** ``gitea_oauth_client_id`` vaut le placeholder ``test-id`` → + ``/auth/login`` redirigeait vers Gitea avec un client_id invalide ; +* **P0-1** écritures anonymes sur ``/api/workspaces*`` et + ``/api/local-workspace/items`` (``uid = ... else 1``) ; +* **P0-2** ``/workspace/*`` sans session : export CSV, historique, commentaires ; +* **P1-5** bouton Home : ``local_workspaces[0]`` (ordre alphabétique) au lieu + de l'espace actif ; +* **P1-6** ``/workspace/favorites`` : 500 permanent (colonne ``collection_id`` + supprimée par la migration v2.2.0) ; +* **P1-7** ``/api/v2/agents/conversations`` masqué par ``/agents/{agent_id}`` ; +* **P1-8** l'éditeur de page appelait ``/api/synced-blocks`` (404) au lieu de + ``/board/api/synced-blocks`` ; +* **P1-9** ``/board/api/synced-blocks`` : 500 anonyme + absence de contrôle de + propriété sur PUT/DELETE. +""" +from __future__ import annotations + +import re + +import pytest +from conftest import anon_csrf, login_test_client + + +@pytest.fixture +def client(): + """Same isolated temp DB contract as tests/conftest.py::client.""" + import os + import tempfile + + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + backup_dir = tempfile.mkdtemp(prefix="fd_backups_") + data_dir = tempfile.mkdtemp(prefix="fd_data_") + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" + os.environ["FLOWDECK_DATA_DIR"] = data_dir + + import app.config + + s = app.config.settings + s.database_url = f"sqlite:///{db_path}" + s.app_secret_key = "test-secret-for-tests" + s.rate_limit_enabled = False + s.backup_enabled = False + s.backup_dir = backup_dir + s.project_sync_enabled = False + + from app.db import init_db + from app.main import app + + init_db() + + from fastapi.testclient import TestClient + + c = login_test_client(TestClient(app)) + try: + yield c + finally: + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def _mk_ws(client, name: str, owner: int = 1) -> int: + r = client.post("/api/workspaces", json={"name": name}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +# ══════════════════════ P0-3 — password_hash ══════════════════════ + +def test_auth_user_never_exposes_password_hash(client): + r = client.get("/auth/user") + assert r.status_code == 200 + assert "password_hash" not in r.text + + +def test_session_cookie_does_not_embed_password_hash(client): + from app.auth.session import SessionManager, public_user + + sanitized = public_user({"id": 1, "login": "a", "password_hash": "deadbeef"}) + assert "password_hash" not in sanitized + + raw = SessionManager.create_session( + {"id": 1, "login": "a", "password_hash": "deadbeef", "is_admin": 1} + ) + assert "deadbeef" not in raw + # Le payload décodé ne contient plus le champ sensible (décodé par la même + # instance de serializer que le serveur, independamment de `settings`). + decoded = SessionManager.decode_session(raw) + assert decoded is not None + assert "password_hash" not in decoded + assert decoded["login"] == "a" + + +# ══════════════════════ P0-4 — OAuth placeholder ══════════════════════ + +def test_placeholder_gitea_credentials_are_not_enabled(client): + import app.auth.providers as providers + + gitea = providers.GiteaProvider( + base_url="https://git.example.net", + client_id="test-id", + client_secret="test-secret", + redirect_uri="", + ) + assert gitea.is_enabled() is False + + +def test_login_does_not_redirect_to_placeholder_client(client): + r = client.get("/auth/login", follow_redirects=False) + assert r.status_code == 200 + assert "client_id=test-id" not in r.text + assert "not configured" in r.text.lower() + + +def test_login_redirects_when_credentials_are_real(client): + from app.config import settings + + old_id, old_secret = settings.gitea_oauth_client_id, settings.gitea_oauth_client_secret + settings.gitea_oauth_client_id = "real-id" + settings.gitea_oauth_client_secret = "real-secret" + try: + r = client.get("/auth/login", follow_redirects=False) + assert r.status_code == 302 + assert "client_id=real-id" in r.headers["location"] + finally: + settings.gitea_oauth_client_id = old_id + settings.gitea_oauth_client_secret = old_secret + + +# ══════════════════════ P0-1 — écritures anonymes ══════════════════════ + +@pytest.mark.parametrize( + "method,path,body", + [ + ("post", "/api/workspaces", {"name": "ANON"}), + ("put", "/api/workspaces/1", {"name": "ANON"}), + ("delete", "/api/workspaces/1", None), + ("post", "/api/workspaces/1/select", {}), + ("post", "/api/local-workspace/items", {"name": "ANON"}), + ("put", "/api/local-workspace/items/1", {"name": "ANON"}), + ("delete", "/api/local-workspace/items/1", None), + ("post", "/api/local-workspace/items/1/restore", {}), + ("put", "/api/local-workspace/items/1/move", {"parent_id": None}), + ], +) +def test_anonymous_write_is_rejected(client, method, path, body): + c = anon_csrf(client) + fn = getattr(c, method) + r = fn(path, json=body) if body is not None else fn(path) + assert r.status_code == 401, f"{method.upper()} {path} -> {r.status_code} {r.text[:200]}" + + +def test_anonymous_workspace_write_creates_nothing(client): + ws_id = _mk_ws(client, "Real WS") + c = anon_csrf(client) + assert c.post("/api/workspaces", json={"name": "Ghost"}).status_code == 401 + assert c.delete(f"/api/workspaces/{ws_id}").status_code == 401 + + from app.db import get_conn + + with get_conn() as conn: + names = [r[0] for r in conn.execute("SELECT name FROM workspaces")] + assert "Ghost" not in names + assert "Real WS" in names + + +def test_workspace_rename_delete_require_ownership(client): + ws_id = _mk_ws(client, "WS of admin") + other = login_test_client(client, user_id=2, login="intruder", is_admin=0) + assert other.put(f"/api/workspaces/{ws_id}", json={"name": "hijacked"}).status_code == 403 + assert other.delete(f"/api/workspaces/{ws_id}").status_code == 403 + assert other.post(f"/api/workspaces/{ws_id}/select").status_code == 403 + + from app.db import get_conn + + with get_conn() as conn: + assert conn.execute( + "SELECT name FROM workspaces WHERE id=?", (ws_id,) + ).fetchone()[0] == "WS of admin" + + +# ══════════════════════ P0-2 — /workspace authentifié ══════════════════════ + +@pytest.mark.parametrize( + "path", + [ + "/workspace/favorites", + "/workspace/pages/1/comments", + "/workspace/pages/1/history", + "/workspace/templates/database", + "/workspace/collections/1/export/csv", + "/workspace/collections/1/sprints", + "/workspace/collections/1/dashboards", + ], +) +def test_workspace_router_requires_session(client, path): + c = anon_csrf(client) + r = c.get(path) + assert r.status_code == 401, f"{path} -> {r.status_code} {r.text[:160]}" + + +def test_public_sharing_route_stays_public(client): + """La seule route publique du router `/workspace` reste accessible.""" + from app.db import get_conn + + with get_conn() as conn: + conn.execute("INSERT INTO collections (name) VALUES ('Public')") + conn.commit() + cid = conn.execute("SELECT MAX(id) FROM collections").fetchone()[0] + conn.execute( + "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)", + (cid, "Page publique"), + ) + conn.commit() + + r = anon_csrf(client).get(f"/workspace/public/{cid}") + assert r.status_code == 200, r.text[:200] + assert "Page publique" in r.text + + # Une collection absente ne doit surtout pas exiger une session (401). + r2 = anon_csrf(client).get("/workspace/public/424242") + assert r2.status_code != 401 + + +# ══════════════════════ P1-6 — favorites ══════════════════════ + +def test_favorites_roundtrip_uses_current_schema(client): + from app.db import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO pages (workspace, title, content, content_format) " + "VALUES ('WS','Fav page','','blocks')" + ) + conn.commit() + page_id = conn.execute("SELECT MAX(id) FROM pages").fetchone()[0] + + r = client.get("/workspace/favorites") + assert r.status_code == 200, r.text + assert r.json()["favorites"] == [] + + r = client.post("/workspace/favorites", json={"page_id": page_id}) + assert r.status_code == 200, r.text + + r = client.get("/workspace/favorites") + assert r.status_code == 200, r.text + favs = r.json()["favorites"] + assert len(favs) == 1 + assert favs[0]["page_id"] == page_id + assert favs[0]["page_title"] == "Fav page" + + assert client.delete(f"/workspace/favorites/{favs[0]['id']}").status_code == 200 + assert client.get("/workspace/favorites").json()["favorites"] == [] + + +def test_favorites_add_requires_page_id(client): + assert client.post("/workspace/favorites", json={}).status_code == 400 + + +# ══════════════════════ P1-7 — ordre de routes agents ══════════════════════ + +def test_agents_conversations_route_is_not_shadowed(client): + r = client.get("/api/v2/agents/conversations") + # 401 « API token required » = la route a bien été atteinte (avant : 422 + # int_parsing sur /agents/{agent_id}). + assert r.status_code == 401, r.text + assert "int_parsing" not in r.text + + +# ══════════════════════ P1-8 / P1-9 — synced blocks ══════════════════════ + +def test_page_editor_uses_board_prefix_for_synced_blocks(): + from pathlib import Path + + src = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8") + assert "'/api/synced-blocks'" not in src + assert src.count("'/board/api/synced-blocks'") == 1 + assert "'/board/api/synced-blocks/'" in src + assert "Settings → Synced Blocks" not in src + + +def test_synced_blocks_anonymous_is_401_not_500(client): + c = anon_csrf(client) + assert c.get("/board/api/synced-blocks").status_code == 401 + assert c.get("/board/api/synced-blocks/1").status_code == 401 + assert c.post("/board/api/synced-blocks", json={"title": "x"}).status_code == 401 + + +def test_synced_block_cannot_be_edited_by_another_user(client): + + r = client.post("/board/api/synced-blocks", json={"title": "Bloc", "content": []}) + assert r.status_code == 200, r.text + sid = r.json()["synced_block_id"] + + intruder = login_test_client(client, user_id=2, login="intruder", is_admin=0) + assert intruder.put(f"/board/api/synced-blocks/{sid}", json={"title": "pwn"}).status_code == 403 + assert intruder.delete(f"/board/api/synced-blocks/{sid}").status_code == 403 + + from app.services.synced_blocks import get_synced_block + + assert get_synced_block(sid)["title"] == "Bloc" + + owner = login_test_client(client, user_id=1, login="tester", is_admin=1) + assert owner.put(f"/board/api/synced-blocks/{sid}", json={"title": "ok"}).status_code == 200 + assert get_synced_block(sid)["title"] == "ok" + + +def test_synced_blocks_list_with_session(client): + r = client.get("/board/api/synced-blocks") + assert r.status_code == 200, r.text + assert "synced_blocks" in r.json() + + +# ══════════════════════ P1-5 — bouton Home ══════════════════════ + +def test_undefined_template_variable_is_logged_not_silent(caplog): + """P1-10 : une variable absente du contexte rend ``""`` (contrattenu par + 40+ templates optionnelles) mais ne doit plus être totally silencieuse.""" + import logging + + from app.templating import ENV + + with caplog.at_level(logging.WARNING, logger="app.templating"): + out = ENV.from_string("{{ totally_unknown_variable }}").render() + assert out == "" + assert any( + "totally_unknown_variable" in r.getMessage() for r in caplog.records + ), [r.getMessage() for r in caplog.records] + + +def test_home_button_targets_the_active_workspace(client): + """``local_workspaces[0]`` = premier workspace TRIÉ PAR NOM ; le Home doit + cibler l'espace actif (``local_ws_id``).""" + from pathlib import Path + + tpl = Path("app/templates/base.html").read_text(encoding="utf-8") + m = re.search(r'= 2 + titles = [t["title"] for t in data["tasks"]] + assert "Task 1" in titles and "Task 2" in titles + assert col_name in [s["name"] for s in data["sources"]] + # Le périmètre est transverse : pas de workspace_id dans le payload. + assert "workspace_id" not in data + client.delete(f"/db/api/{cid}") -def test_my_tasks_view_today(client): - resp = client.get("/my-tasks?view=today") +@pytest.mark.parametrize("view", ["table", "board", "calendar"]) +def test_my_tasks_views(client, view): + """Les trois vues (tableau / kanban / calendrier) sont acceptées. + + Sans base connectée, le serveur rend son état vide (le conteneur client + n'est chargé que lorsqu'il y a des tâches à afficher) : on vérifie donc le + code HTTP ici, et le contenu via l'API. + """ + resp = client.get("/my-tasks", params={"view": view}) assert resp.status_code == 200 + assert "My Tasks" in resp.text + assert client.get("/my-tasks/api", + params={"view": view}).status_code == 200 -def test_my_tasks_view_overdue(client): - resp = client.get("/my-tasks?view=overdue") +@pytest.mark.parametrize("due", ["all", "today", "overdue", "week"]) +def test_my_tasks_due_filters(client, due): + resp = client.get("/my-tasks/api", params={"due": due}) assert resp.status_code == 200 + assert resp.json()["filters"]["due"] == due # ── v2.1.0: Workspace, Comments, Favorites, CSV ── @@ -1716,18 +1761,33 @@ def test_oauth_login_local_page(client): assert "Login" in resp.text or "login" in resp.text.lower() -def test_oauth_login_gitea_redirect(client): - """GET /auth/login?provider=gitea — redirects to Gitea OAuth (configured in test env).""" +def test_oauth_login_gitea_redirect(client, monkeypatch): + """GET /auth/login?provider=gitea — redirects to Gitea OAuth when configured. + + v7.46.0 : les valeurs de substitution livrées dans app/config.py + (``test-id`` / ``test-secret``) ne comptent plus comme « configuré » — sinon + /auth/login redirigeait vers Gitea avec un client_id invalide. Le test + configure donc de VRAIS identifiants. + """ + from app.config import settings + + monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id") + monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret") resp = client.get("/auth/login?provider=gitea", follow_redirects=False) - # Gitea OAuth IS configured in test env → redirect to Gitea + # Gitea OAuth configuré → redirect to Gitea assert resp.status_code == 302 assert "login/oauth" in resp.headers.get("location", "").lower() + assert "client_id=gitea-real-id" in resp.headers["location"] -def test_oauth_login_with_link_mode(client): +def test_oauth_login_with_link_mode(client, monkeypatch): """GET /auth/login?provider=gitea&mode=link — link mode redirects to Gitea OAuth.""" + from app.config import settings + + monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id") + monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret") resp = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False) - # Gitea OAuth IS configured → redirect to Gitea with link mode set in session + # Gitea OAuth configuré → redirect to Gitea with link mode set in session assert resp.status_code == 302 assert "login/oauth" in resp.headers.get("location", "").lower() @@ -1786,8 +1846,12 @@ def test_get_redirect_uri_env_override_wins(monkeypatch): monkeypatch.undo() -def test_oauth_login_redirect_uri_dynamic(client): +def test_oauth_login_redirect_uri_dynamic(client, monkeypatch): """The authorize URL carries the request-derived redirect_uri (encoded).""" + from app.config import settings + + monkeypatch.setattr(settings, "gitea_oauth_client_id", "gitea-real-id") + monkeypatch.setattr(settings, "gitea_oauth_client_secret", "gitea-real-secret") resp = client.get( "/auth/login?provider=gitea", headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "flowdeck.dracodev.net"}, @@ -2802,28 +2866,47 @@ def test_sprint_burndown(client): def test_my_tasks_page_render(client): - """My Tasks page renders cross-database aggregation.""" - # Create a task collection - resp = client.post("/db/api", json={"name": "My Tasks DB"}) - coll_id = resp.json()["id"] + """My Tasks page renders the three views and its sources. - # Toggle to task mode - client.put(f"/db/{coll_id}/toggle-task/api") + Le tableau de bord est rendu côté client depuis ``/my-tasks/api`` : le HTML + transporte la configuration (``data-config``), pas les tâches. + """ + import time - # Add pages - client.post(f"/db/{coll_id}/pages/api", json={ - "title": "Urgent fix", - "properties": {"Status": "Todo"}, - }) - client.post(f"/db/{coll_id}/pages/api", json={ - "title": "Deploy", - "properties": {"Status": "Done"}, - }) + from app.db import get_conn + name = f"My Tasks DB {int(time.time() * 1000)}" + with get_conn() as conn: + ws_row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=(SELECT id FROM users WHERE login='tester') LIMIT 1" + ).fetchone() + ws_id = ws_row["id"] if ws_row else conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES (?, " + "(SELECT id FROM users WHERE login='tester'))", ("Test Workspace",) + ).lastrowid + conn.commit() - resp = client.get("/my-tasks?view=all") + coll_id = client.post("/db/api", + json={"name": name, "workspace_id": ws_id}).json()["id"] + + # `toggle-task` reste rétrocompatible : il réalise la conversion complète. + resp = client.put(f"/db/{coll_id}/toggle-task/api") assert resp.status_code == 200 - assert "Urgent fix" in resp.text - assert "My Tasks DB" in resp.text + assert resp.json()["is_task"] is True + + client.post(f"/db/{coll_id}/pages/api", json={"title": "Urgent fix"}) + client.post(f"/db/{coll_id}/pages/api", json={"title": "Deploy"}) + + resp = client.get("/my-tasks") + assert resp.status_code == 200 + assert 'id="my-tasks-app"' in resp.text + assert "/static/js/my_tasks.js" in resp.text + + # Les titres sont bien exposés par l'API alimentée par la page. + titles = [t["title"] for t in client.get("/my-tasks/api").json()["tasks"]] + assert "Urgent fix" in titles + assert name in [s["name"] for s in client.get("/my-tasks/api").json()["sources"]] + + client.delete(f"/db/api/{coll_id}") # ── v4.6.0: Content Blocks Enriched ── diff --git a/tests/test_home_workspace_param.py b/tests/test_home_workspace_param.py new file mode 100644 index 0000000..3dd2b9a --- /dev/null +++ b/tests/test_home_workspace_param.py @@ -0,0 +1,168 @@ +"""FlowDeck — bouton « Home » de la sidebar : le paramètre ``?ws=`` est respecté. + +Le sidebar construit ``/local-workspace?ws=`` (premier workspace possédé, +trié par nom). La route déclarait seulement ``folder`` : ``ws`` était ignoré et +la page affichait le workspace **actif** (cookie), donc Home pouvait montrer un +workspace différent de celui annoncé dans l'URL. + +Ces tests verrouillent le contrat : +* ``?ws=`` affiche bien le workspace demandé (et aligne le cookie) ; +* un ``?ws=`` appartenant à quelqu'un d'autre est ignoré — aucune fuite ; +* sans ``?ws=``, le comportement précédent (workspace actif) est inchangé. +""" +from __future__ import annotations + +import pytest +from conftest import login_test_client + + +@pytest.fixture +def client(): + import os + import tempfile + + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" + + from app.config import settings + from app.db import init_db + from app.main import app + + settings.database_url = f"sqlite:///{db_path}" + settings.llm_provider = "offline" + init_db() + + from fastapi.testclient import TestClient + + client = login_test_client(TestClient(app)) + try: + yield client + finally: + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def _make_workspace(client, name: str) -> dict: + r = client.post("/api/workspaces", json={"name": name}) + assert r.status_code == 200, r.text + return r.json() + + +def _workspace_id_in(client, workspace_id: int) -> int | None: + """Lit le workspace_id embarqué dans le HTML rendu (lw-config).""" + r = client.get(f"/local-workspace?ws={workspace_id}") + assert r.status_code == 200, r.text + body = r.text + marker = '"workspace_id":' + idx = body.find(marker) + assert idx != -1, "lw-config absent de la page" + rest = body[idx + len(marker):].lstrip() + digits = "" + for ch in rest: + if ch.isdigit(): + digits += ch + else: + break + return int(digits) if digits else None + + +def test_home_ws_param_is_honoured(client): + """Home doit afficher le workspace qu'il annonce, pas celui du cookie.""" + client.post("/auth/register", json={ + "email": "home1@test.dev", "password": "secret123", "name": "Home1"}) + zeta = _make_workspace(client, "Zeta Project") # id plus élevé + alpha = _make_workspace(client, "Alpha Project") # premier par nom + + # Le cookie « workspace actif » pointe sur Zeta… + client.post(f"/api/workspaces/{zeta['id']}/select") + + # …mais Home annonce le premier par nom (Alpha). + assert _workspace_id_in(client, alpha["id"]) == alpha["id"] + assert _workspace_id_in(client, zeta["id"]) == zeta["id"] + + +def test_home_ws_param_title_matches_content(client): + """Le titre affiché doit être celui du workspace demandé, pas celui du cookie. + + Régression : la sidebar est calculée avant le changement de workspace, donc + `active_ws_name` désignait l'ancien workspace — le contenu aurait été celui + de Zeta sous le titre « Alpha Project ». + """ + client.post("/auth/register", json={ + "email": "home5@test.dev", "password": "secret123", "name": "Home5"}) + zeta = _make_workspace(client, "Zeta Project") + alpha = _make_workspace(client, "Alpha Project") + client.post(f"/api/workspaces/{zeta['id']}/select") + + r = client.get(f"/local-workspace?ws={alpha['id']}") + assert r.status_code == 200 + # La sidebar liste légitimement tous les workspaces : on cible le , + # qui reflète le workspace dont le contenu est rendu. + import re + + title = re.search(r"<title>(.*?)", r.text, re.S).group(1) + assert "Alpha Project" in title + assert "Zeta Project" not in title + + +def test_home_ws_param_aligns_cookie(client): + """Après un Home vers ?ws=, le cookie doit suivre le workspace affiché.""" + client.post("/auth/register", json={ + "email": "home2@test.dev", "password": "secret123", "name": "Home2"}) + a = _make_workspace(client, "AAA") + b = _make_workspace(client, "BBB") + client.post(f"/api/workspaces/{a['id']}/select") + + r = client.get(f"/local-workspace?ws={b['id']}") + assert r.status_code == 200 + assert client.cookies.get("flowdeck_workspace") == str(b["id"]) + + +def test_foreign_ws_param_is_ignored(client): + """Un ?ws= appartenant à un autre utilisateur ne doit rien divulguer.""" + client.post("/auth/register", json={ + "email": "home3@test.dev", "password": "secret123", "name": "Home3"}) + mine = _make_workspace(client, "Mine") + + # Workspace appartenant à un AUTRE utilisateur (FK users respectée) + from app.db import get_conn + from app.password_utils import hash_password + + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, email, full_name, password_hash, is_active) " + "VALUES ('ghost', 'ghost@test.dev', 'Ghost', ?, 1)", + (hash_password("secret123"),)) + conn.commit() + ghost_id = conn.execute("SELECT id FROM users WHERE login='ghost'").fetchone()["id"] + conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", + ("Secret Corp", ghost_id)) + conn.commit() + foreign = conn.execute( + "SELECT id FROM workspaces WHERE name='Secret Corp'").fetchone()["id"] + + r = client.get(f"/local-workspace?ws={foreign}") + assert r.status_code == 200 + # Le workspace demandé n'est pas rendu : on retombe sur celui du visiteur. + assert _workspace_id_in(client, foreign) == mine["id"] + assert client.cookies.get("flowdeck_workspace") != str(foreign) + assert "Secret Corp" not in r.text + + +def test_without_ws_param_active_workspace_is_used(client): + """Sans ?ws=, le comportement historique est inchangé.""" + client.post("/auth/register", json={ + "email": "home4@test.dev", "password": "secret123", "name": "Home4"}) + _make_workspace(client, "Active") + b = _make_workspace(client, "Other") + client.post(f"/api/workspaces/{b['id']}/select") + + assert _workspace_id_in(client, b["id"]) == b["id"] diff --git a/tests/test_my_tasks_workspace_coherence.py b/tests/test_my_tasks_workspace_coherence.py new file mode 100644 index 0000000..1fd14bd --- /dev/null +++ b/tests/test_my_tasks_workspace_coherence.py @@ -0,0 +1,1523 @@ +"""FlowDeck — My Tasks : contrat « façon Notion » + cohérence workspace. + +Bug d'origine : après suppression de tous les documents d'un workspace, la vue +My Tasks continuait d'afficher les tâches de ce workspace (les bases et leurs +lignes survivaient, la vue ne vérifiait rien). + +Évolution : My Tasks devient un poste de travail **transverse** (tous les +workspaces, comme l'onglet *Home* de Notion) alimenté par des **sources +opt-in** : une base n'émet ses tâches qu'après « conversion en base de tâches » +et le mapping explicite de ses 3 colonnes requises. + +Ce fichier verrouille : + +* le périmètre transverse (plus de scoping par `flowdeck_workspace`) ; +* l'opt-in : une base non convertie n'émet rien ; une conversion incomplète + n'émet rien non plus ; +* le mapping **nommé** (Assigné à / Statut / Échéance) et sa validation ; +* la limite de 10 bases connectées ; +* les 3 vues, les filtres globaux et le tri ; +* l'édition en direct (statut / échéance / titre) via le mapping ; +* l'ajout rapide multi-destinations (auto-assigné) ; +* les cascades de suppression (corbeille / purge / workspace / base). +""" +from __future__ import annotations + +import contextlib +import json +import os +import re +import tempfile +from datetime import UTC, date, timedelta + +import pytest +from conftest import login_test_client + + +@pytest.fixture +def client(): + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" + + from app.config import settings + from app.db import init_db + from app.main import app + + settings.database_url = f"sqlite:///{db_path}" + settings.llm_provider = "offline" + settings.rate_limit_enabled = False + init_db() + + from fastapi.testclient import TestClient + + client = login_test_client(TestClient(app)) + try: + yield client + finally: + for suffix in ("", "-wal", "-shm"): + try: + os.unlink(db_path + suffix) + except FileNotFoundError: + pass + + +@contextlib.contextmanager +def _conn(): + from app.db import get_conn + + with get_conn() as conn: + yield conn + + +def _one(sql: str, params=()): + """Première ligne, via une connexion qui se ferme toute seule.""" + with _conn() as conn: + row = conn.execute(sql, params).fetchone() + return dict(row) if row else None + + +def _make_workspace(client, name: str) -> int: + r = client.post("/api/workspaces", json={"name": name}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _host_page(ws_id: int, title: str) -> int: + with _conn() as conn: + page_id = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section) VALUES ('', ?, ?, '', 'blocks', 'Private')", + (ws_id, title), + ).lastrowid + conn.commit() + return page_id + + +def _prop(cid: int, name: str, prop_type: str, options=None) -> int: + with _conn() as conn: + pos = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties " + "WHERE collection_id=?", (cid,), + ).fetchone()[0] + pid = conn.execute( + "INSERT INTO collection_properties (collection_id, name, prop_type, " + "options_json, position, required, visible_in_views) VALUES (?,?,?,?,?,0,1)", + (cid, name, prop_type, json.dumps(options or []), pos), + ).lastrowid + conn.commit() + return pid + + +def _database(client, ws_id: int, name: str, *, host_page: bool = True, + rows: int = 0) -> int: + """Base pleine page si `host_page`, sinon base rattachée au workspace seule.""" + page = _host_page(ws_id, name) if host_page else None + with _conn() as conn: + cid = conn.execute( + "INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, ?, ?)", + (name, ws_id, page), + ).lastrowid + if page: + conn.execute( + "UPDATE pages SET content_format='collection', collection_id=? WHERE id=?", + (cid, page), + ) + conn.commit() + if rows: + with _conn() as conn: + for i in range(rows): + conn.execute( + "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)", + (cid, f"{name} #{i + 1}"), + ) + conn.commit() + return cid + + +def _host_page_id(cid: int) -> int: + with _conn() as conn: + return conn.execute( + "SELECT parent_page_id FROM collections WHERE id=?", (cid,) + ).fetchone()[0] + + +def _other_user() -> dict: + """Un second utilisateur (la base de test n'en contient qu'un).""" + with _conn() as conn: + row = conn.execute( + "SELECT * FROM users WHERE id<>? ORDER BY id LIMIT 1", (_user()["id"],) + ).fetchone() + if row is not None: + return dict(row) + uid = conn.execute( + "INSERT INTO users (login, full_name, is_admin) VALUES ('other','Other',0)" + ).lastrowid + conn.commit() + return _one("SELECT * FROM users WHERE id=?", (uid,)) + + +def _today() -> date: + """« Aujourd'hui » selon le service (fuseau de l'utilisateur, sinon UTC).""" + from app.services.task_databases import user_today as service_today + + with _conn() as conn: + return service_today(conn, _user()["id"]) + + +def _user() -> dict: + return _one("SELECT * FROM users ORDER BY id LIMIT 1") + + +def _connect(client, cid: int, *, assignee: str = "Assigné à", + status: str = "Statut", due: str = "Échéance") -> dict: + """Convertit la base via l'API (mapping explicite, colonnes créées).""" + r = client.post(f"/db/{cid}/task-db/api", json={ + "create_missing": { + "assignee": {"name": assignee, "prop_type": "person"}, + "status": {"name": status, "prop_type": "status", + "options": [{"name": "À faire"}, {"name": "En cours"}, + {"name": "Terminée"}]}, + "due": {"name": due, "prop_type": "date"}, + } + }) + assert r.status_code == 200, r.text + return r.json() + + +def _row(cid: int, title: str, *, user=None, status: str = "À faire", + due: str = "") -> int: + """Ligne dont les valeurs sont **indexées par ID de colonne**.""" + with _conn() as conn: + props = {p["id"]: p for p in conn.execute( + "SELECT id, prop_type FROM collection_properties WHERE collection_id=?", (cid,) + ).fetchall()} + a = props[[i for i, p in props.items() if p["prop_type"] == "person"][0]]["id"] + s = props[[i for i, p in props.items() if p["prop_type"] == "status"][0]]["id"] + d = props[[i for i, p in props.items() if p["prop_type"] == "date"][0]]["id"] + pv = {str(a): [{"id": user["id"], "login": user["login"], + "full_name": user["full_name"] or user["login"]}], + str(s): status} + if due: + pv[str(d)] = due + rid = conn.execute( + "INSERT INTO collection_pages (collection_id, title, position, " + "property_values_json) VALUES (?, ?, 0, ?)", + (cid, title, json.dumps(pv)), + ).lastrowid + conn.commit() + return rid + + +def _api(client, **params): + r = client.get("/my-tasks/api", params=params) + assert r.status_code == 200, r.text + return r.json() + + +# ── Périmètre transverse + opt-in ──────────────────────────────────────── + +def test_my_tasks_spans_all_workspaces(client): + """Toutes les bases connectées, quel que soit le workspace actif.""" + a = _make_workspace(client, "Alpha") + b = _make_workspace(client, "Beta") + for ws, name, n in ((a, "Tâches A", 2), (b, "Tâches B", 3)): + cid = _database(client, ws, name) + _connect(client, cid) + u = _user() + for i in range(n): + _row(cid, f"{name} #{i + 1}", user=u) + + body = _api(client) + assert body["total"] == 5, body + assert {s["name"] for s in body["sources"]} == {"Tâches A", "Tâches B"} + # Le cookie de workspace ne cadre plus la vue. + assert _api(client, **{})["total"] == 5 + + +def test_unconverted_database_feeds_nothing(client): + """Opt-in : une base jamais convertie n'apparaît pas.""" + ws = _make_workspace(client, "OptIn") + cid = _database(client, ws, "Backlog", rows=2) + + body = _api(client) + assert body["total"] == 0 + assert body["sources_total"] == 0 + assert body["tasks"] == [] + + _connect(client, cid) + # La conversion crée les colonnes : les 2 lignes existantes n'ont pas de + # statut, mais elles sont listées (assigné vide = implicite « mienne »). + assert _api(client)["sources_total"] == 1 + + +def test_mapping_incomplete_emits_nothing_but_is_listed(client): + """Base connectée au mapping incomplet : visible, mais silencieuse.""" + ws = _make_workspace(client, "Partial") + cid = _database(client, ws, "Partielle") + status_prop = _prop(cid, "Statut", "status", [{"name": "À faire"}, + {"name": "Terminée"}]) + _prop(cid, "Échéance", "date") + + # Connectée mais mapping partiel : seul le statut est lié. + with _conn() as conn: + conn.execute( + "UPDATE collections SET is_task=1, task_status_prop=? WHERE id=?", + (status_prop, cid), + ) + conn.commit() + body = _api(client) + assert body["sources_total"] == 1 + assert body["sources"][0]["configured"] is False + assert set(body["sources"][0]["missing_roles"]) == {"assignee", "due"} + assert body["total"] == 0 + + +def test_only_tasks_assigned_to_me_are_listed(client): + ws = _make_workspace(client, "Assignees") + cid = _database(client, ws, "Sprint") + _connect(client, cid) + u = _user() + + other = _other_user() + + _row(cid, "Pour moi", user=u) + _row(cid, "Pour quelqu'un d'autre", user=other) + + body = _api(client) + assert body["total"] == 1 + assert body["tasks"][0]["title"] == "Pour moi" + assert body["tasks"][0]["assignees"] == [u["login"]] + + +# ── Mapping nommé + validation ─────────────────────────────────────────── + +def test_conversion_links_existing_columns_by_name(client): + """Des colonnes déjà présentes sont **liées**, pas dupliquées.""" + ws = _make_workspace(client, "Linking") + cid = _database(client, ws, "Projets") + a = _prop(cid, "Resp.", "person") + s = _prop(cid, "Avancement", "select", [{"name": "Fait"}]) + d = _prop(cid, "Livraison", "date") + + state = client.get(f"/db/{cid}/task-db/api").json() + assert {p["id"] for p in state["candidates"]["assignee"]} == {a} + assert {p["id"] for p in state["candidates"]["status"]} == {s} + assert {p["id"] for p in state["candidates"]["due"]} == {d} + + out = _connect(client, cid, assignee="Resp.", status="Avancement", due="Livraison") + assert out["mapping"] == {"assignee": a, "status": s, "due": d} + + with _conn() as conn: + names = {r["name"] for r in conn.execute( + "SELECT name FROM collection_properties WHERE collection_id=?", (cid,) + )} + assert names == {"Resp.", "Avancement", "Livraison"} # aucune colonne créée + + +def test_conversion_rejects_incompatible_column(client): + """Une colonne Date ne peut pas servir de colonne « Assigné à ».""" + ws = _make_workspace(client, "Types") + cid = _database(client, ws, "Typée") + d = _prop(cid, "Date", "date") + + r = client.post(f"/db/{cid}/task-db/api", json={"mapping": {"assignee": d}}) + assert r.status_code == 400 + assert "incompatible" in r.json()["detail"].lower() + + +def test_conversion_rejects_a_column_used_twice(client): + """Une même colonne ne peut pas couvrir deux rôles (Notion l'interdit).""" + ws = _make_workspace(client, "Double") + cid = _database(client, ws, "Double emploi") + # Type `select` : compatible à la fois de « Statut »… mais pas de « Date ». + sel = _prop(cid, "Etat", "select", [{"name": "A"}]) + other = _prop(cid, "Autre Etat", "select", [{"name": "B"}]) + + # Deux rôles de même famille de types compatibles → double emploi détecté. + r = client.post(f"/db/{cid}/task-db/api", json={"mapping": { + "assignee": None, "status": sel, "due": other, + }}) + assert r.status_code == 400 + + # Et le cas typique : la même colonne pour deux rôles compatibles. + r = client.post(f"/db/{cid}/task-db/api", json={ + "mapping": {"status": sel}, + "create_missing": {"assignee": {"prop_type": "person"}}, + }) + assert r.status_code == 200, r.text + r = client.post(f"/db/{cid}/task-db/api", json={ + "mapping": {"status": sel, "due": sel}, + }) + assert r.status_code == 400 + assert "incompatible" in r.json()["detail"].lower() + + +def test_disconnect_keeps_the_tasks(client): + ws = _make_workspace(client, "Disconnect") + cid = _database(client, ws, "Tasks") + _connect(client, cid) + u = _user() + _row(cid, "Gardée", user=u) + assert _api(client)["total"] == 1 + + r = client.delete(f"/db/{cid}/task-db/api") + assert r.status_code == 200, r.text + assert _api(client)["total"] == 0 + + with _conn() as conn: + assert conn.execute( + "SELECT COUNT(*) FROM collection_pages WHERE id IS NOT NULL AND collection_id=?", + (cid,), + ).fetchone()[0] == 1 + + +def test_sources_are_capped_at_ten(client): + ws = _make_workspace(client, "Dix") + for i in range(10): + _connect(client, _database(client, ws, f"Base {i}")) + + listed = client.get("/db/task-dbs/api") + assert listed.status_code == 200, listed.text + assert listed.json()["limit_reached"] is True + + r = client.post(f"/db/{_database(client, ws, 'Base 11')}/task-db/api", json={}) + assert r.status_code == 409 + assert "10" in r.json()["detail"] + + +# ── Filtres, tri, vues ─────────────────────────────────────────────────── + +def _seed(client, ws: int) -> int: + cid = _database(client, ws, "Backlog") + _connect(client, cid) + u = _user() + today = _today() + _row(cid, "En retard", user=u, status="À faire", + due=(today - timedelta(days=3)).isoformat()) + _row(cid, "Aujourd'hui", user=u, status="En cours", due=today.isoformat()) + _row(cid, "Future", user=u, status="À faire", + due=(today + timedelta(days=10)).isoformat()) + _row(cid, "Terminée", user=u, status="Terminée", + due=(today - timedelta(days=1)).isoformat()) + _row(cid, "Sans date", user=u, status="À faire") + return cid + + +def test_due_filters_and_hide_done(client): + ws = _make_workspace(client, "Filters") + _seed(client, ws) + + def titles(**kw): + return [t["title"] for t in _api(client, **kw)["tasks"]] + + assert len(titles(due="all")) == 5 + assert titles(due="today") == ["Aujourd'hui"] + assert set(titles(due="overdue")) == {"En retard", "Aujourd'hui", "Terminée"} + assert set(titles(due="week")) == {"En retard", "Aujourd'hui", "Terminée"} # borné à J+7 + assert "Terminée" not in titles(hide_done="true") + # Tri par statut : les tâches terminées passent en fin de liste. + by_status = titles(due="all", sort="status") + assert by_status[-1] == "Terminée" + assert "Terminée" not in titles(due="all", sort="status", hide_done="true") + + +def test_unknown_view_and_sort_fall_back(client): + """Un paramètre d'URL hors enum est ramené à une valeur réelle.""" + ws = _make_workspace(client, "Guards") + _seed(client, ws) + body = _api(client, view="n'importe quoi", sort="; DROP TABLE pages;") + assert body["filters"]["sort"] == "due" + assert body["total"] == 5 + + +def test_due_state_flags_drive_the_table_styling(client): + ws = _make_workspace(client, "States") + _seed(client, ws) + states = {t["title"]: t["due_state"] for t in _api(client)["tasks"]} + assert states["En retard"] == "overdue" + assert states["Aujourd'hui"] == "today" + assert states["Future"] == "upcoming" + assert states["Sans date"] == "" + + +def test_task_payload_carries_its_source_and_columns(client): + """Chaque tâche porte sa source et les colonnes à écrire (pas de devinette).""" + ws = _make_workspace(client, "Payload") + cid = _seed(client, ws) + task = _api(client)["tasks"][0] + assert task["collection_id"] == cid + assert task["collection_name"] == "Backlog" + assert task["workspace_name"] == "Payload" + assert task["status_prop"] and task["due_prop"] and task["assignee_prop"] + assert task["status_options"][0] == "À faire" + + +# ── Modification en direct ─────────────────────────────────────────────── + +def test_editing_status_writes_the_mapped_column(client): + ws = _make_workspace(client, "Edit") + _seed(client, ws) + task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date") + + r = client.put(f"/my-tasks/api/task/{task['id']}", + json={"field": "status", "value": "En cours"}) + assert r.status_code == 200, r.text + assert r.json()["task"]["status"] == "En cours" + + with _conn() as conn: + pv = json.loads(conn.execute( + "SELECT property_values_json FROM collection_pages WHERE id=?", + (task["id"],), + ).fetchone()[0]) + assert pv[str(task["status_prop"])] == "En cours" + + +def test_editing_due_and_title(client): + ws = _make_workspace(client, "Edit2") + _seed(client, ws) + task = next(t for t in _api(client)["tasks"] if t["title"] == "Future") + iso = (_today() + timedelta(days=2)).isoformat() + + client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "due", "value": iso}) + client.put(f"/my-tasks/api/task/{task['id']}", + json={"field": "title", "value": "Future renommée"}) + + body = _api(client) + updated = next(t for t in body["tasks"] if t["id"] == task["id"]) + assert updated["due"] == iso + assert updated["title"] == "Future renommée" + + +def test_unassigning_removes_the_task_from_my_tasks(client): + ws = _make_workspace(client, "Unassign") + _seed(client, ws) + task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date") + other = _other_user() + + r = client.put(f"/my-tasks/api/task/{task['id']}", json={ + "field": "assignee", + "value": [{"id": other["id"], "login": other["login"]}], + }) + assert r.status_code == 200, r.text + assert r.json()["left_mytasks"] is True + assert task["id"] not in [t["id"] for t in _api(client)["tasks"]] + + +def test_update_rejects_unknown_field_and_foreign_source(client): + ws = _make_workspace(client, "Guards2") + cid = _seed(client, ws) + task = _api(client)["tasks"][0] + + r = client.put(f"/my-tasks/api/task/{task['id']}", + json={"field": "collection_id", "value": 1}) + assert r.status_code == 400 + + with _conn() as conn: + conn.execute("UPDATE collections SET is_task=0 WHERE id=?", (cid,)) + conn.commit() + r = client.put(f"/my-tasks/api/task/{task['id']}", + json={"field": "status", "value": "À faire"}) + assert r.status_code == 400 + assert "no longer feeds" in r.json()["detail"] + + +# ── Ajout rapide multi-destinations ────────────────────────────────────── + +def test_quick_add_creates_a_self_assigned_task_in_the_chosen_base(client): + ws = _make_workspace(client, "QuickAdd") + a = _database(client, ws, "Clients") + b = _database(client, ws, "Editorial") + for cid in (a, b): + _connect(client, cid) + + r = client.post("/my-tasks/api/task", + json={"collection_id": b, "title": "Nouvelle vidéo", "due": "2030-01-02"}) + assert r.status_code == 200, r.text + assert r.json()["collection_id"] == b + + body = _api(client) + assert body["total"] == 1 + task = body["tasks"][0] + assert task["title"] == "Nouvelle vidéo" + assert task["collection_name"] == "Editorial" + assert task["assignees"] == [_user()["login"]] # auto-assignée + assert task["due"] == "2030-01-02" + + +def test_quick_add_refuses_a_database_that_does_not_feed_my_tasks(client): + ws = _make_workspace(client, "QuickAdd2") + cid = _database(client, ws, "Non connectée") + + r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"}) + assert r.status_code == 400 + assert "does not feed" in r.json()["detail"] + + +def test_quick_add_validates_its_input(client): + ws = _make_workspace(client, "QuickAdd3") + cid = _database(client, ws, "Tasks") + _connect(client, cid) + + assert client.post("/my-tasks/api/task", + json={"title": "Sans base"}).status_code == 400 + assert client.post("/my-tasks/api/task", + json={"collection_id": cid}).status_code == 400 + assert client.post("/my-tasks/api/task", + json={"collection_id": cid, "title": " "}).status_code == 400 + + +def test_quick_add_requires_a_session(client): + cid = _database(client, _make_workspace(client, "Anon"), "Tasks") + r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"}, + cookies={"flowdeck_session": ""}) + assert r.status_code in (401, 403) + + +# ── Rendu de la page ───────────────────────────────────────────────────── + +def test_dashboard_renders_the_three_views(client): + ws = _make_workspace(client, "Render") + _seed(client, ws) + + r = client.get("/my-tasks") + assert r.status_code == 200, r.text + assert 'id="my-tasks-app"' in r.text + assert "/static/js/my_tasks.js" in r.text + # Le cookie de workspace n'est plus réécrit par cette vue. + assert "flowdeck_workspace" not in r.headers.get("set-cookie", "") + + for view in ("table", "board", "calendar"): + r = client.get("/my-tasks", params={"view": view}) + assert r.status_code == 200, r.text + # Le JSON est échappé pour l'attribut HTML (`"`). + assert "my-tasks-app" in r.text + assert """ in r.text and view in r.text + + +def test_dashboard_without_source_explains_the_conversion(client): + ws = _make_workspace(client, "NoSource") + _database(client, ws, "Backlog", rows=1) + + r = client.get("/my-tasks") + assert r.status_code == 200, r.text + assert "Convertir en base de tâches" in r.text + assert "Jusqu'à 10 bases" in r.text + # Rien à afficher côté client → le script n'est pas chargé, mais la + # feuille de style l'est (sinon l'invite retombe à gauche). + assert "/static/js/my_tasks.js" not in r.text + assert "/static/css/my_tasks.css" in r.text + + +def test_dashboard_without_any_workspace(client): + r = client.get("/my-tasks") + assert r.status_code in (200, 302), r.text + + +# ── Cohérence workspace : corbeille, purge, suppression ────────────────── + +def test_trashed_database_host_page_hides_its_tasks(client): + """Base à la corbeille → plus de tâches, la base reste restaurable.""" + ws = _make_workspace(client, "Coherence A") + cid = _database(client, ws, "Backlog") + _connect(client, cid) + u = _user() + for i in range(3): + _row(cid, f"Backlog #{i + 1}", user=u) + assert _api(client)["total"] == 3 + assert _api(client)["total"] == 3 + + page = _host_page_id(cid) + client.post(f"/api/pages/{page}/trash") + + body = _api(client) + assert body["total"] == 0 + assert body["tasks"] == [] + + with _conn() as conn: + assert conn.execute( + "SELECT COUNT(*) FROM collections WHERE name='Backlog'").fetchone()[0] == 1 + + client.post(f"/board/api/trash/{page}/restore") + assert _api(client)["total"] == 3 + + +def test_trashed_row_content_page_hides_the_row(client): + """La page contenu d'une ligne à la corbeille → la ligne sort de My Tasks.""" + ws = _make_workspace(client, "Coherence B") + cid = _database(client, ws, "Tasks") + _connect(client, cid) + rid = _row(cid, "Avec page", user=_user()) + + with _conn() as conn: + page_id = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, collection_id, collection_row_id) " + "VALUES ('', ?, 'Row doc', '', 'blocks', 'DbRow', ?, ?)", + (ws, cid, rid), + ).lastrowid + conn.commit() + + assert _api(client)["total"] == 1 + client.post(f"/api/pages/{page_id}/trash") + assert _api(client)["total"] == 0 + + +def test_permanent_delete_of_host_page_deletes_the_database(client): + ws = _make_workspace(client, "Coherence E") + cid = _database(client, ws, "Backlog", rows=2) + page = _host_page_id(cid) + + client.post(f"/api/pages/{page}/trash") + r = client.delete(f"/board/api/trash/{page}") + assert r.status_code == 200, r.text + assert r.json()["collections"] == 1 + + with _conn() as conn: + assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", + (cid,)).fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", + (cid,)).fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", + (page,)).fetchone()[0] == 0 + + +def test_trash_purge_deletes_hosted_databases(client): + from app.services.trash import purge_expired + + ws = _make_workspace(client, "Coherence F") + cid = _database(client, ws, "Backlog", rows=1) + page = _host_page_id(cid) + + with _conn() as conn: + conn.execute( + "UPDATE pages SET deleted_at='2000-01-01 00:00:00' WHERE id=?", (page,)) + conn.commit() + + summary = purge_expired(days=30) + assert page in summary["purged"] + with _conn() as conn: + assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", + (cid,)).fetchone()[0] == 0 + + +def test_empty_trash_deletes_hosted_databases(client): + ws = _make_workspace(client, "Coherence G") + cid = _database(client, ws, "Backlog", rows=1) + page = _host_page_id(cid) + + client.post(f"/api/pages/{page}/trash") + r = client.post("/board/api/trash/empty") + assert r.status_code == 200, r.text + assert r.json()["collections"] == 1 + with _conn() as conn: + assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", + (cid,)).fetchone()[0] == 0 + + +def test_delete_workspace_removes_its_databases(client): + ws = _make_workspace(client, "Coherence H") + cid = _database(client, ws, "Backlog", rows=1) + + r = client.delete(f"/api/workspaces/{ws}") + assert r.status_code == 200, r.text + assert r.json()["collections"] == 1 + + with _conn() as conn: + assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", + (cid,)).fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM pages WHERE workspace_id=?", + (ws,)).fetchone()[0] == 0 + + +def test_delete_collection_detaches_host_page(client): + """Supprimer une base laisse la page hôte vivante (plus de FK bloquante).""" + ws = _make_workspace(client, "Coherence I") + cid = _database(client, ws, "Backlog", rows=1) + page = _host_page_id(cid) + + r = client.delete(f"/db/api/{cid}") + assert r.status_code == 200, r.text + assert r.json()["status"] == "deleted" + + with _conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page,)).fetchone() + assert row["collection_id"] is None + assert row["content_format"] == "blocks" + + +def test_today_follows_the_user_timezone(client): + """« Aujourd'hui » suit ``users.timezone`` — le fuseau que le projet + utilise déjà pour le calendrier des bases. + + Sans cela, une tâche due aujourd'hui bascule dans « demain » (ou + l'inverse) dès que l'utilisateur est à plus de quelques heures d'UTC. + """ + from datetime import datetime + + from app.services.task_databases import user_today as service_today + + ws = _make_workspace(client, "TZ") + cid = _database(client, ws, "Tasks") + _connect(client, cid) + uid = _user()["id"] + + def set_tz(tz): + with _conn() as conn: + conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, uid)) + conn.commit() + + def service_day(): + with _conn() as conn: + return service_today(conn, uid) + + try: + # Sans fuseau : UTC (le comportement historique du service). + set_tz("") + assert service_day() == datetime.now(UTC).date() + + # Kiritimati est à UTC+14 : le jour du service peut avancer d'un cran. + set_tz("Pacific/Kiritimati") + ahead = service_day() + assert ahead in (datetime.now(UTC).date(), + (datetime.now(UTC) + timedelta(hours=14)).date()) + + # Une tâche datée du jour du service est « aujourd'hui » pour la vue. + _row(cid, "Du jour", user=_user(), due=ahead.isoformat()) + assert [t["title"] for t in _api(client, due="today")["tasks"]] == ["Du jour"] + + # Si le jour du service diffère de celui d'UTC, une tâche datée du + # jour UTC ne doit PAS être considérée comme « aujourd'hui ». + # On repart d'un jeu de lignes vide : « Du jour » était daté de + # `ahead` (Kiritimati) et le tri des tâches n'est pas garanti entre + # les deux fuseaux. + set_tz("Pacific/Midway") # UTC-7 + behind = service_day() + with _conn() as conn: + conn.execute( + "DELETE FROM collection_pages WHERE collection_id=? " + "AND title IN ('Du jour', 'Jour UTC')", + (cid,), + ) + conn.commit() + + # Le jour « aujourd'hui » suit le fuseau de l'utilisateur. + _row(cid, "Jour du service", user=_user(), due=behind.isoformat()) + got = [t["title"] for t in _api(client, due="today")["tasks"]] + assert got == ["Jour du service"], got + + # Le jour UTC n'en fait partie que s'il coïncide avec celui du + # service — ce qui dépend de l'heure : on ne l'affirme que dans ce cas. + utc_day = datetime.now(UTC).date() + if utc_day != behind: + _row(cid, "Jour UTC", user=_user(), due=utc_day.isoformat()) + got = [t["title"] for t in _api(client, due="today")["tasks"]] + assert "Jour UTC" not in got + finally: + with _conn() as conn: + conn.execute("UPDATE users SET timezone='' WHERE id=?", (uid,)) + conn.commit() + + +# ── Page « /db » : destination du bouton « Ouvrir mes bases » ─────────── + +def test_db_page_lists_the_workspace_databases(client): + """`/db` était un dump JSON de debug sans layout : il sert de destination + au bouton « Ouvrir mes bases » de My Tasks.""" + ws = _make_workspace(client, "Index") + _database(client, ws, "Clients") + _database(client, ws, "Editorial") + + r = client.get("/db", cookies={"flowdeck_workspace": str(ws)}) + assert r.status_code == 200, r.text + assert "Bases de données" in r.text + assert "Clients" in r.text and "Editorial" in r.text + assert "
" not in r.text          # plus de dump JSON
+    assert "base de tâches" in r.text.lower()
+
+
+def test_db_page_shows_the_task_database_state(client):
+    """Chaque base annonce son état : connectée, à configurer, ou à convertir."""
+    ws = _make_workspace(client, "Index2")
+    _database(client, ws, "Non configurée")
+    linked = _database(client, ws, "Connectée")
+    _connect(client, linked)
+
+    r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
+    assert r.status_code == 200, r.text
+    assert "Base de tâches" in r.text
+    assert "Convertir en base de tâches" in r.text
+    assert "Alimente My Tasks" in r.text
+
+
+def test_db_page_skips_databases_whose_host_page_is_trashed(client):
+    """Une base dont la page hôte est à la corbeille n'est pas proposée."""
+    ws = _make_workspace(client, "Index3")
+    _database(client, ws, "Vivante")
+    gone = _database(client, ws, "Supprimée")
+    client.post(f"/api/pages/{_host_page_id(gone)}/trash")
+
+    r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
+    assert r.status_code == 200, r.text
+    assert "Vivante" in r.text
+    assert "Supprimée" not in r.text
+
+
+def test_db_page_lists_other_workspaces_too(client):
+    """Le workspace actif d'abord, les autres dans une section séparée : sans
+    cela, un workspace sans base affichait une page vide alors que l'utilisateur
+    en possédait ailleurs."""
+    a = _make_workspace(client, "WS-A")
+    b = _make_workspace(client, "WS-B")
+    _database(client, a, "Base A")
+    _database(client, b, "Base B")
+
+    body = client.get("/db", cookies={"flowdeck_workspace": str(b)}).text
+    assert "Base B" in body
+    # Base A reste atteignable, mais signalée comme appartenant à un autre
+    # workspace — elle alimente My Tasks comme les autres.
+    assert "Base A" in body
+    assert "Autres workspaces" in body
+
+
+def test_db_page_shows_workspace_name_and_mine_first(client):
+    a = _make_workspace(client, "Mon espace")
+    b = _make_workspace(client, "Autre espace")
+    _database(client, a, "La mienne")
+    _database(client, b, "La sienne")
+
+    body = client.get("/db", cookies={"flowdeck_workspace": str(a)}).text
+    assert "Mon espace" in body
+    assert body.index("La mienne") < body.index("La sienne")
+
+
+def test_db_page_requires_a_session(client):
+    """Sans session, `/db` redirige vers la page de connexion (et non pas une
+    liste de bases)."""
+    from conftest import anon
+
+    resp = anon(client).get("/db", follow_redirects=False)
+    assert resp.status_code in (302, 303), resp.status_code
+    assert "/auth/login" in resp.headers.get("location", "")
+
+
+def test_empty_my_tasks_points_at_the_right_button(client):
+    """L'invite doit désigner le bouton qui existe réellement."""
+    ws = _make_workspace(client, "Invite")
+    _database(client, ws, "Backlog", rows=1)
+
+    r = client.get("/my-tasks")
+    assert r.status_code == 200, r.text
+    assert "Convertir en base de tâches" in r.text
+    # L'ancien texte renvoyait vers un bouton « … » qui n'existe pas.
+    assert "bouton « … »" not in r.text
+    assert "à gauche du bouton" in r.text
+
+
+def test_database_view_bar_exposes_the_conversion_button(client):
+    """Le bouton est dans la barre de la base, avec un libellé lisible."""
+    r = client.get("/static/js/database_table.js")
+    assert r.status_code == 200, r.text
+    assert "db-task-db-btn" in r.text
+    assert "Convertir en base de tâches" in r.text
+    assert "data-task-db=" in r.text
+
+
+# ── Bases portées par une page (`collections.workspace_id` NULL) ──────────
+#
+# Une base née d'un document (page convertie en base, base inline, base liée
+# ou modèle importé) a `workspace_id` NULL et n'appartient qu'à travers sa page
+# hôte. Filtrer sur `workspace_id`, ou joindre `workspaces` sans repli, la
+# faisait disparaître de `/db` **et** de My Tasks : l'utilisateur voyait sa base
+# dans la sidebar mais n'arrivait plus à la convertir ni à la retrouver.
+
+
+def _page_hosted_database(ws_id: int, name: str, *, rows: int = 0,
+                          host_page: bool = True) -> int:
+    """Base portée par une page du workspace, `workspace_id` laissé NULL."""
+    page = _host_page(ws_id, name) if host_page else None
+    with _conn() as conn:
+        cid = conn.execute(
+            "INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, NULL, ?)",
+            (name, page),
+        ).lastrowid
+        if page:
+            conn.execute(
+                "UPDATE pages SET content_format='collection', collection_id=? WHERE id=?",
+                (cid, page),
+            )
+        conn.commit()
+    if rows:
+        with _conn() as conn:
+            for i in range(rows):
+                conn.execute(
+                    "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
+                    (cid, f"{name} #{i + 1}"),
+                )
+            conn.commit()
+    return cid
+
+
+def test_db_page_lists_page_hosted_database(client):
+    """Une base à `workspace_id` NULL est bien listée dans son workspace."""
+    ws = _make_workspace(client, "Portée par page")
+    _page_hosted_database(ws, "Base portée", rows=2)
+
+    body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
+    assert "Base portée" in body
+    assert "Aucune base dans ce workspace" not in body
+
+
+def test_db_page_shows_page_hosted_base_of_another_workspace(client):
+    """Workspace actif vide + base portée par une page ailleurs : la section
+    « Autres workspaces » la remonte, avec le nom du workspace d'origine."""
+    mine = _make_workspace(client, "Actif")
+    other = _make_workspace(client, "Elsewhere")
+    _page_hosted_database(other, "Base ailleurs")
+
+    body = client.get("/db", cookies={"flowdeck_workspace": str(mine)}).text
+    assert "Base ailleurs" in body
+    assert "Autres workspaces" in body
+    # Le workspace d'appartenance est indiqué (l'en-tête ne montre que l'actif).
+    assert "Elsewhere" in body
+
+
+def test_page_hosted_task_database_feeds_my_tasks(client):
+    """Le bout en bout du signalement : une base portée par une page, convertie
+    en base de tâches, doit alimenter My Tasks."""
+    ws = _make_workspace(client, "Émission")
+    cid = _page_hosted_database(ws, "Backlog page")
+    _connect(client, cid)
+    _row(cid, "Écrire le rapport", user=_user())
+
+    payload = client.get("/my-tasks/api").json()
+    assert payload["total"] == 1, payload
+    assert payload["tasks"][0]["title"] == "Écrire le rapport"
+    assert payload["sources_total"] == 1
+    # La source est attribuée à son workspace effectif, pas à `NULL`.
+    assert payload["sources"][0]["workspace_name"] == "Émission"
+
+
+def test_task_database_of_shared_workspace_is_listed(client):
+    """Un workspace dont l'utilisateur est **membre** (pas propriétaire) doit
+    alimenter ses bases : le périmètre est `owner_id` ∪ `workspace_members`."""
+    mine = _make_workspace(client, "Mien")
+    shared = _make_workspace(client, "Partagé")
+    with _conn() as conn:
+        conn.execute(
+            "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) "
+            "VALUES (?, ?, 'editor')",
+            (shared, _user()["id"]),
+        )
+        conn.commit()
+
+    cid = _page_hosted_database(shared, "Base partagée")
+    _connect(client, cid)
+
+    sources = client.get("/db/task-dbs/api").json()
+    assert [s["name"] for s in sources["sources"]] == ["Base partagée"]
+    assert "Base partagée" in client.get(
+        "/db", cookies={"flowdeck_workspace": str(mine)}
+    ).text
+
+
+def _other_workspace(owner_id: int, name: str) -> int:
+    with _conn() as conn:
+        ws_id = conn.execute(
+            "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
+            (name, owner_id),
+        ).lastrowid
+        conn.commit()
+    return ws_id
+
+
+def test_db_page_hides_bases_of_another_user(client):
+    """Isolation : la liste « Autres workspaces » ne doit exposer que les
+    workspaces de l'appelant."""
+    from conftest import login_test_client
+    from fastapi.testclient import TestClient
+
+    from app.main import app
+
+    # La session (et donc la ligne `users`) doit exister avant d'attribuer un
+    # workspace à cet utilisateur : `workspaces.owner_id` est une clé étrangère.
+    stranger = login_test_client(
+        TestClient(app), user_id=4242, login="stranger", is_admin=0
+    )
+    their_ws = _other_workspace(4242, "Espace inconnu")
+    their_cid = _page_hosted_database(their_ws, "Base inconnue")
+
+    mine = _make_workspace(client, "Mien")
+    _page_hosted_database(mine, "Ma base")
+
+    assert "Ma base" in client.get("/db").text
+
+    body = stranger.get("/db").text
+    assert "Base inconnue" in body          # la sienne, bien
+    assert "Ma base" not in body             # pas celle d'autrui
+    assert "Mien" not in body
+    assert stranger.get("/db/task-dbs/api").json()["total"] == 0
+
+    # Même verdict côté My Tasks : aucune source étrangère.
+    assert stranger.get("/db/task-dbs/api").json()["sources"] == []
+    assert their_cid != mine
+
+
+def test_db_page_skips_database_without_any_workspace(client):
+    """Base sans `workspace_id` **et** sans page hôte : elle n'appartient à
+    personne, donc à personne ne doit la voir (une liste « toutes les bases »
+    de l'instance l'afficherait à tout le monde)."""
+    ws = _make_workspace(client, "Seul")
+    with _conn() as conn:
+        conn.execute(
+            "INSERT INTO collections (name, workspace_id, parent_page_id) "
+            "VALUES ('Base orpheline', NULL, NULL)"
+        )
+        conn.commit()
+
+    body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
+    assert "Base orpheline" not in body
+
+
+def test_page_hosted_database_of_deleted_page_is_hidden(client):
+    """Le repli sur la page hôte ne doit pas ressusciter une base dont le
+    document a été supprimé."""
+    ws = _make_workspace(client, "Coroutine")
+    cid = _page_hosted_database(ws, "Base morte")
+    with _conn() as conn:
+        conn.execute(
+            "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id="
+            "(SELECT parent_page_id FROM collections WHERE id=?)",
+            (cid,),
+        )
+        conn.commit()
+
+    assert "Base morte" not in client.get(
+        "/db", cookies={"flowdeck_workspace": str(ws)}
+    ).text
+
+
+# ══════════════════════════════════════════════════════════════════════════
+# v7.47.1 — Rendu « non connectée », icône de ligne et navigation interne
+# ══════════════════════════════════════════════════════════════════════════
+
+def _my_tasks_js() -> str:
+    from pathlib import Path
+
+    return Path("static/js/my_tasks.js").read_text(encoding="utf-8")
+
+
+def _my_tasks_css() -> str:
+    from pathlib import Path
+
+    return Path("static/css/my_tasks.css").read_text(encoding="utf-8")
+
+
+def test_disconnected_state_still_links_the_stylesheet(client):
+    """Aucune base connectée : l'invite doit être **centrée**.
+
+    Régression : la feuille de style était liée par le même mini-template que
+    le script, et ce dernier était conditionné à ``app_js``. Sans base, la
+    page perdait donc sa feuille de style et tout retombait à gauche.
+    """
+    body = client.get("/my-tasks").text
+
+    assert "my_tasks.css" in body, "la feuille de style doit TOUJOURS être liée"
+    # Gabarit centré par la feuille de style, pas par du style inline.
+    assert "my-tasks-empty" in body
+    assert 'style="text-align:center"' not in body
+    # Aucun tableau de bord client à monter : le script reste inutile.
+    assert "my_tasks.js" not in body
+
+
+def test_no_workspace_state_still_links_the_stylesheet(client):
+    """Même exigence pour l'état « aucun workspace » (même gabarit)."""
+    body = client.get("/my-tasks").text
+
+    assert "Aucun workspace" in body
+    assert "my_tasks.css" in body
+    assert "my_tasks.js" not in body
+
+
+def test_connected_state_links_stylesheet_and_script(client):
+    """Dès qu'une base alimente la vue, CSS + JS sont tous deux liés."""
+    ws = _make_workspace(client, "Connectée")
+    cid = _page_hosted_database(ws, "Base")
+    _connect(client, cid)
+
+    body = client.get("/my-tasks").text
+    assert 'id="my-tasks-app"' in body
+    assert "my_tasks.css" in body
+    assert "my_tasks.js" in body
+
+
+def test_my_tasks_js_does_not_use_a_one_shot_window_guard():
+    """La navigation interne rejoue le