diff --git a/.gitignore b/.gitignore index 1fe6c6c..72d1a05 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ commit_msg.txt e2e/node_modules/ e2e/shots/ e2e/test-results/ + +# Artefacts du navigateur Playwright MCP (logs / snapshots) +.playwright-mcp/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6d1c1b..34f1ce4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,8 +1,8 @@ -# Architecture FlowDeck — Document Complet v7.69.8 +# Architecture FlowDeck — Document Complet v7.70.0 > **FlowDeck** = clone complet de **Notion** intégré nativement à **Gitea**, servi par une application **FastAPI** monolithique (SSR Jinja2 + htmx + Alpine.js), avec **API REST publique v2**, **agent IA conversationnel**, **automatisations**, **SSO entreprise** et **édition collaborative temps réel**. > -> Document d'architecture à jour de la version **7.69.8** (`VERSION`). Historique complet : voir §17 et `CHANGELOG.md`. Modèle de données détaillé table par table : `docs/DATA_MODEL.md`. +> Document d'architecture à jour de la version **7.70.0** (`VERSION`). Historique complet : voir §17 et `CHANGELOG.md`. Modèle de données détaillé table par table : `docs/DATA_MODEL.md`. ## Table des matières @@ -30,7 +30,7 @@ 22. [Frontend : rendu, CSP, PWA & Web Clipper](#22-frontend-rendu-csp-pwa--web-clipper) 23. [Déploiement & exploitation](#23-déploiement--exploitation) 24. [Outils de développement, tests & CI](#24-outils-de-développement-tests--ci) -25. [Historique des versions v1.0 → v7.69.8](#25-historique-des-versions-v10--v7698) +25. [Historique des versions v1.0 → v7.70.0](#25-historique-des-versions-v10--v7700) 26. [Chantiers ouverts & suites](#26-chantiers-ouverts--suites) 27. [Références](#27-références) @@ -996,9 +996,9 @@ Gitea Actions, 3 jobs sur push + PR → main/develop : `lint` (ruff + eslint), ` --- -## 25. Historique des versions v1.0 → v7.69.8 +## 25. Historique des versions v1.0 → v7.70.0 -Version courante : **7.69.8** (`VERSION`). Bandes thématiques vérifiées contre les titres réels du `CHANGELOG.md` (⚠️ le CHANGELOG saute v2.8→v4.5, documentées uniquement dans `ROADMAP.md` ; plusieurs versions portent un titre « bande roadmap » décalé — ex. `## v5.11.7 — v5.8.0 Calendrier & Rappels` — et trois 7.46.0 distinctes coexistent). +Version courante : **7.70.0** (`VERSION`). Bandes thématiques vérifiées contre les titres réels du `CHANGELOG.md` (⚠️ le CHANGELOG saute v2.8→v4.5, documentées uniquement dans `ROADMAP.md` ; plusieurs versions portent un titre « bande roadmap » décalé — ex. `## v5.11.7 — v5.8.0 Calendrier & Rappels` — et trois 7.46.0 distinctes coexistent). ### Socle v1.0 – v2.x (résumé, inchangé du doc v4.0) @@ -1033,7 +1033,7 @@ Version courante : **7.69.8** (`VERSION`). Bandes thématiques vérifiées contr - **v7.3.1–v7.35** — **cycle d'audit** (43 items A1–A43, `ROADMAP.md`) : sécurité P0→P2 (401 partout, CSRF A19/A38, SSRF A12, autoescape A10, logs A36, CSP A20/A43), A21 SQLite hors event loop, A32 tests routers, A27 extraction JS (−85 %), A28 routers → packages, A35 Python 3.13, A42 httpx partagé. - **v7.36–v7.50** — fondations **E2E Playwright** ; **A20 terminé : Alpine build CSP, `unsafe-eval` retiré** (v7.43) ; éditeur visuel d'automations (v7.45) ; palette avec onglet Réponses IA ; navigation partielle anti-FOUC ; **web tools de l'agent** + 11 skills (v7.46) ; **sidebar à onglets** Home/Chat/Meeting/Inbox ; **My Tasks 3 vues transverse** ; **side peek** plein écran. - **v7.51–v7.58** — **Menu + de l'assistant** en 8 phases : hub de contexte, skills gérer/parcourir, canevas Design System, mémoire d'agent, connecteurs (socle → Google/M365 OAuth lecture seule → Discord/Telegram/Teams/**MCP**), plugins catalogue on/off à effet réel. -- **v7.59–v7.69.8** — éditeur **façon Notion** : mobile (drawer réglable, menus au `/`), bouton + d'insertion, menus contextuels de bloc et de sélection refondus, **commentaires ancrés sur la sélection de texte** (surlignage jaune, tiroir refondu), **réactions sur texte + tiroir**, **copier/couper/coller multi-blocs**, colonnes réellement rendues, tables GFM, identité visuelle (logo/bannière/icônes PWA/`og:image`), **Library** (onglet AI Meeting Notes groupé par date, Recents réels, Publish unifié, colonnes visited/source). +- **v7.59–v7.70.0** — éditeur **façon Notion** : mobile (drawer réglable, menus au `/`), bouton + d'insertion, menus contextuels de bloc et de sélection refondus, **commentaires ancrés sur la sélection de texte** (surlignage jaune, tiroir refondu), **réactions sur texte + tiroir**, **copier/couper/coller multi-blocs**, colonnes réellement rendues, tables GFM, identité visuelle (logo/bannière/icônes PWA/`og:image`), **Library** (onglet AI Meeting Notes groupé par date, Recents réels, Publish unifié, colonnes visited/source) ; **projet Gitea** : miroir local ouvert en parallèle du dépôt, sidebar Repository distincte. --- @@ -1064,10 +1064,10 @@ Extraits de `ROADMAP.md` (audit du 2026-09-30) et `WORKLOAD.md` : | `docs/V6_SSO_SAML_Enterprise_Auth.md`, `V6_Granular_Permissions.md`, `V6_PWA_Progressive_Web_App.md`, `V6_Web_Clipper.md` | features v6.x | | `docs/V68_Sites_Forms.md`, `V69_Search_Ask_AI.md`, `V70_Automations_Workers.md`, `V71_Calendar_Meetings.md`, `V72_Enterprise_SCIM_2FA.md`, `V73_Wiki_Teamspaces_Polish.md`, `V74_Agent_Plus_Menu.md` | features v6.8–v7.4 | | `docs/architecture-meeting-notion-flowdeck.md`, `docs/FLOWDECK_MCP_SERVER_GUIDE.md` | spec Meeting Notes ; conception serveur MCP (non implémentée) | -| `CHANGELOG.md` | journal détaillé v7.36→v7.69.8 (et au-delà, désordonné — voir §25) | +| `CHANGELOG.md` | journal détaillé v7.36→v7.70.0 (et au-delà, désordonné — voir §25) | | `ROADMAP.md` | plans v2.8–v4.5 + **audit A1–A43** + suivi des cycles v6/v7 | | `WORKLOAD.md`, `TESTING.md`, `CONTRIBUTING.md`, `BRANCHING.md` | pilotage, grille visuelle, conventions, branches | --- -*Ce document décrit l'état du projet à la version 7.69.8 (2026-10-09). Les affirmations marquées « factuel » ou « ⚠️ » signalent des écarts connus entre le code et les documents de pilotage plus anciens.* +*Ce document décrit l'état du projet à la version 7.70.0 (2026-10-09). Les affirmations marquées « factuel » ou « ⚠️ » signalent des écarts connus entre le code et les documents de pilotage plus anciens.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 647412c..9e8e381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,208 @@ # Changelog - FlowDeck +## v7.71.5 (2026-10-10) — Galerie système : +94 templates (135 au total) + +### Added + +- **Galerie système portée à 135 presets** (auparavant 41, + 6 bases legacy + = 47 installés). Nouveaux templates, tous genres : + - **+39 pages** — Semainier, Daily standup, Agenda de réunion, Compte rendu + de sprint, Kickoff/Registre des risques/Leçons apprises/Note de + cadrage/Plan de communication, Runbook d'incident, Checklist de code + review, Doc d'API/architecture, Plan de test, Changelog, User persona, + Guide d'entretien utilisateur, Roadmap produit, Business Model Canvas, + SWOT, RACI, Proposition commerciale, Facture, Plan marketing, Brief, + Script d'appel, Rapport de performance, Fiche de poste, Guide + d'entretien d'embauche, Politique interne, Guide d'installation, Plan de + repas, Liste de courses, Checklist de ménage, Suivi d'entraînement, Plan + de carrière, Rétro perso, Checklist de valise, Boîte à idées. + - **+9 blocs** — Étapes numérotées, Avantages/Inconvénients, Mise en garde + + Astuce, Définition, Ressources/liens, Contacts clés, Présentation, + Comparatif d'options, Signature d'email. + - **+26 bases** — Pipeline de vente, Devis/Factures, Réunions, Registre des + décisions, Backlog/Idées, Sprints, Roadmap/Fonctionnalités, Tests QA, + Risques, Candidatures, Formations & cours, Compétences, Abonnements, + Budget/Revenus, Dépenses partagées, Campagnes marketing, Publications + réseaux sociaux, KPI/Métriques, Événements, Recettes, Films & séries, + Voyages, Fournisseurs, Matériel/Équipement, Rendez-vous santé, + Entraînements. + - **+20 lignes** — Client, Opportunité, Devis, Facture, Réunion, Décision, + Risque, Idée, Fonctionnalité, Cas de test, Vidéo, Événement, Voyage, + Recette, Séance d'entraînement, Candidature, Formation, Fournisseur, + Abonnement, Indicateur (base cible résolue par nom). + + Les installations existantes sont rafraîchies automatiquement au boot + (`PRESET_VERSION`), sans toucher aux copies personnelles. + +## v7.71.4 (2026-10-10) — Templates de page : titre automatique, plus d'erreur « variable requise » + +### Fixed + +- **« Échec : HTTP 400 {"detail":"variable requise manquante : title"} »** — + plusieurs presets de page déclaraient `title` comme variable *requise* ; le + sélecteur l'envoyait vide → refus. `title` est désormais une **variable + système réservée** (jamais requise) : une valeur fournie prime, sinon la + résolution retombe sur le titre de la page cible puis sur le nom du + template. Les presets concernés déclarent `title` en optionnel. +- **Le titre du document suit le template choisi** — appliquer un template de + page à la page courante (ou via `/template`, ou en créer une nouvelle) + définit maintenant le titre à partir de `title_template` (ex. + « CR — Compte rendu client (2026-10-10) »), au lieu de laisser + « Untitled ». Le titre est mis à jour en place dans l'éditeur sans + rechargement. + +## v7.71.3 (2026-10-10) — Templates : commande `/` + galerie enrichie (> 20 presets) + +### Added + +- **Commande slash `/template`** dans l'éditeur : elle ouvre le même sélecteur + de templates mais **sans les templates de page** — filtres limités à + *Bases / Lignes / Blocs* (plus « Tous »). Un template de blocs s'insère au + curseur, un template de base crée une base inline dans le document, un + template de ligne crée la ligne dans sa base (sans quitter la page). +- **Galerie système enrichie à 38 presets** (auparavant 7), tous genres + confondus : + - **18 pages** : Note de réunion, Compte rendu client, 1:1, Revue hebdo, + Journal quotidien, Journal de gratitude, Spécification produit, Plan de + projet, Rétrospective, Post-mortem incident, Objectifs OKR, Décision + (ADR), Procédure (SOP), Onboarding, Notes de cours, Fiche de lecture, + Recette, Itinéraire, Fiche prospect, Communiqué… + - **6 blocs** : Sommaire + actions, Questions de réunion, Matrice + d'Eisenhower, FAQ, Citation + source, Checklist de démarrage. + - **10 bases** : Tâches, Gestion de projet, Suivi de bugs, Contenu + éditorial, Habitudes, Dépenses, Inventaire, Bibliothèque de liens, + Objectifs, Annuaire d'équipe. + - **5 lignes** : Tâche, Bug, Article, Dépense, Habitude (résolues par nom + de base cible — utilisables depuis le menu *New ▾* d'une base ou le + sélecteur). + +### Fixed + +- **Types de blocs des presets** : les entêtes utilisaient `heading` + (rendu comme un simple paragraphe) et les cases à cocher `todo` — corrigés + en `heading_1/2/3` et `to_do` (types réels de l'éditeur). Les presets + système déjà installés sont **rafraîchis automatiquement** au boot + (`preset_version`), sans toucher aux copies personnelles de l'utilisateur. +- **Manifeste des presets de base** : `ensure_system_presets` ignorait la + définition `collection` (schéma) → les bases système étaient créées vides ; + le manifeste est désormais transmis, et une base créée depuis un preset + porte ses propriétés (Statut, Priorité, Échéance…). +- **Templates de ligne** : la base cible d'un preset est résolue par nom + (`target_collection_name`) dans le workspace, avec un message explicite si + la base n'existe pas — au lieu d'un échec technique. + +## v7.71.2 (2026-10-10) — Templates : flux de création des documents + menu contextuel + +### Changed + +- **Un seul flux de création de document, cohérent partout.** Le bouton + « New page » (barre de titre du workspace local, actions de section, + menu « New » en bas de la sidebar, menu contextuel) **crée désormais un + document vide** et navigue dessus — comportement Notion. Il n'ouvre plus + l'ancien panneau « New page from template » (supprimé) dont les boutons + Annuler/fermer étaient inopérants (collision d'`id` `fd-tpl-picker` avec + le sélecteur de l'éditeur : `document.getElementById('fd-tpl-close')` + renvoyait `null`, la fermeture n'était jamais câblée, et l'overlay + bloquait le reste de l'UI — d'où le menu contextuel perçu comme cassé). +- **Nouveau sélecteur global « Nouveau depuis un template »**, adossé au + registre unifié (`/api/templates`) : accessible depuis le menu « New » + de la sidebar (*From template…*), utilisable sur toutes les pages + (sidebar, workspace local, library), avec recherche, filtres, « Gérer… », + état vide/erreur explicite et Annuler/✕ fonctionnels. +- **Filtres du sélecteur de l'éditeur restylés** (segmented control + partagé `.fd-tpl-filter`, libellés FR : Tous / Pages / Bases / Lignes / + Blocs, état actif accentué, survol) — mêmes classes que le sélecteur + global, aucun nouveau langage visuel. + +### Fixed + +- Menu contextuel clic droit de la section Workspace : plus bloqué par + l'ancien overlay figé du panneau template. + +### Tests + +- `e2e/templates_picker.spec.js` : +2 (le bouton « New page » crée un + document vide et navigue ; le menu *New > From template* ouvre le + sélecteur global et le ✕ ferme) ; filtres mis à jour (libellés FR). + +## v7.71.1 (2026-10-10) — Templates : correctifs du sélecteur (centrage, filtres, aperçu, base inline) + +### Fixed + +- **Fenêtre « Appliquer un template » centrée** — l'overlay portait + `display:flex` en style inline ; Alpine, au retour de `x-show`, réapplique + `display:block` et écrasait le flex → le dialogue restait collé en haut à + gauche. Le flex vit désormais dans une enveloppe distincte de `x-show` + (l'overlay ne contrôle plus que la visibilité). +- **Boutons de filtre `Tous / page / blocks / row / database` inopérants** — + les handlers inline contenaient deux expressions séparées par `;` + (`tplKind = k; loadTplList()`), refusées par le parseur CSP d'Alpine.js + (« CSP Parser Error: Unexpected token: loadTplList »). Une méthode unique + `setTplKind(k)` / `setTplVar(name, value)` est appelée depuis les + handlers (une seule expression par binding, invariant build CSP). +- **« Aperçu indisponible : HTTP 400 » sur certains templates** — l'aperçu + partageait la résolution stricte de l'instanciation : une variable + déclarée requise non encore saisie levait `ValueError` → 400. L'aperçu + utilise désormais un mode non strict (`_resolve_all(..., strict=False)`) : + les tokens non résolus restent visibles avec un avertissement « À + compléter », seule l'instanciation exige la saisie. +- **Appliquer un template de type Database dans le document courant** — la + création partait dans une base détachée sans insertion. Le sélecteur + transmet `parent_page_id`, la collection est créée INLINE + (`is_inline=1`, `parent_page_id`) et le front insère le bloc embed + `collection` à la position du curseur, sans navigation (même patron que + la pilule « Database »). + +### Tests + +- `tests/test_fd_templates.py` : +3 (aperçu jamais 400, presets système, + base inline rattachée à la page) — 13 tests. +- `e2e/templates_picker.spec.js` : +2 (filtres par genre, insertion d'un + template Database dans le document) — 4 tests. + +## v7.71.0 (2026-10-10) — Templates unifiés façon Notion (registre + sélecteur + gestionnaire) + +### Fixed + +- **Bouton « Templates » de la page vide réparé (cause établie par preuve, + phase 0 du cahier des charges)** — la pilule portait + `@click="gsMoreOpen = !gsMoreOpen"`, soit exactement le même flag que le + bouton « ••• » : le clic ouvrait le menu de création de base + (Table/Board/List…) au lieu d'un sélecteur (hypothèses H1+H4 confirmées + par lecture du code ; H2 écartée — le composant `editorState` est bien + enregistré via `Alpine.data`). La pilule et l'entrée « Apply template… » + du menu ••• ouvrent désormais le sélecteur unifié. + +### Added + +- **Registre unifié `templates` (migrations 48 + 49)** — catalogue les trois + silos legacy (`database_templates`, `page_templates`, + `page_global_templates`) sans les détruire (backfill idempotent au boot, + adaptateurs de lecture) ; journal `template_runs` (compteurs, audit, + idempotence) et `template_recurrences` (RRULE + scheduler 60 s + dédupliqué par `occurrence_key`). +- **`TemplateService.instantiate()` unique** — UI, API v2, agent + (`apply_template` rebranché + nouvel outil lecture `list_templates`) et + scheduler partagent la même instanciation transactionnelle : variables + système (`{{today}}`, `{{now}}`, `{{me}}`, `{{date:+7d}}`…) et déclarées + figées à la création, validation via `validate_property_value()`, + dégradation avec avertissements (propriété renommée), aucun `{{token}}` + survivant, pages-supports masquées (hors recherche/arbre/récents). +- **Sélecteur (§7.1)** : recherche, filtres par genre, aperçu résolu, + formulaire de variables, insertion de blocs au curseur / remplissage de + la page en place / création + navigation, toast avec annulation. +- **Gestionnaire `/templates`** (entrée sidebar + page SSR) : liste + filtrable, side peek (aperçu, variables, défauts, récurrence, journal), + création (vierge, depuis page/ligne/base, import `.json`), duplication, + archivage, corbeille, export/import au format `flowdeck-template` v1. +- **Menu *New ▾* des bases** : `GET /api/collections/{id}/templates` + (défaut ★, créations dédiées) ; **galerie système** (7 presets) ; + **API interne** `/api/templates…` et **API v2** `/api/v2/fd-templates…` + (liste, instantiate idempotent, export, import). +- **Tests** : `tests/test_fd_templates.py` (10 tests, §17.2) et + `e2e/templates_picker.spec.js` (régression T0-3 : le clic ouvre le + sélecteur et applique visiblement un template). + ## v7.70.1 (2026-10-10) — Refonte navigation Repository : fix cache + erreurs console ### Fixed diff --git a/VERSION b/VERSION index 91bf70d..23ea335 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.70.1 \ No newline at end of file +7.71.5 \ No newline at end of file diff --git a/app/config.py b/app/config.py index 921354d..9c498b9 100644 --- a/app/config.py +++ b/app/config.py @@ -147,6 +147,14 @@ class Settings(BaseSettings): github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10 web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url + # ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ── + GEOCODER_PROVIDER: str = "none" # none | nominatim | custom + GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org" + VIEW_ROWS_MAX: int = 1000 + MAP_POINTS_MAX: int = 100 + TIMELINE_LOAD_MAX: int = 200 + CHART_MAX_SUBGROUPS: int = 50 + @property def db_path(self) -> Path: if self.database_url == "sqlite:///:memory:": diff --git a/app/db.py b/app/db.py index 0024c5b..7e0f6ca 100644 --- a/app/db.py +++ b/app/db.py @@ -833,6 +833,17 @@ def init_db(): from app.migrations import apply_migrations apply_migrations(conn) + # ── Templates unifiés (§9.5, §12) : backfill idempotent des silos + # legacy + presets système. Non bloquant : un échec laisse les silos + # legacy servir leurs anciennes surfaces (dégradation, pas panne). + try: + from app.services.fd_templates import ensure_registry, reset_registry_cache + reset_registry_cache() + ensure_registry(conn) + except Exception: + import logging as _logging + _logging.getLogger(__name__).exception("ensure_registry templates") + # Webhook subscriptions (v2.1.0) — created here (idempotent) so the full # schema exists without depending on the FastAPI lifespan startup. from app.services.webhook_outbound import init_webhook_tables diff --git a/app/main.py b/app/main.py index 0b658ae..1f871c0 100644 --- a/app/main.py +++ b/app/main.py @@ -44,6 +44,7 @@ from app.routers.audit import router as audit_router from app.routers.automations import router as automations_router from app.routers.collaboration import router as collaboration_router from app.routers.emoji import router as emoji_router +from app.routers.fd_templates import router as fd_templates_router from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router from app.routers.governance import router as governance_router @@ -154,6 +155,10 @@ async def lifespan(_app: FastAPI): from app.services.reminders import reminder_scheduler reminder_task = _spawn("reminder_scheduler", reminder_scheduler) + # ── Templates récurrents (§11.2) : scan 60 s dédupliqué ── + from app.services.fd_templates import template_recurrence_scheduler + template_rec_task = _spawn("template_recurrence_scheduler", template_recurrence_scheduler) + # ── Semantic search (v6.9.0): incremental vector indexing ── from app.services.semantic_search import semantic_index_scheduler semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler) @@ -172,7 +177,7 @@ async def lifespan(_app: FastAPI): try: yield finally: - _tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task) + _tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, template_rec_task, semantic_task, calendar_task) if webhook_task is not None: _tasks = _tasks + (webhook_task,) for task in _tasks: @@ -186,7 +191,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.70.1", + version="7.71.5", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -268,6 +273,8 @@ app.include_router(audit_router) app.include_router(governance_router) # v7.3.0 — teamspaces + verified wiki app.include_router(wiki_router) +# Templates unifiés façon Notion (registre + gestionnaire + API interne) +app.include_router(fd_templates_router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/migrations.py b/app/migrations.py index 8f81530..422b65a 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1802,3 +1802,226 @@ def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None: "ON meeting_transcripts(event_occurrence_id) " "WHERE event_occurrence_id <> ''" ) + + +# ── Vues Notion §10 : migrations 44 à 47 (après 39-43 réservées Agents&Skills) ── + +@register(44, "vues-notion: view_config_v2 (config_version + index view_type)") +def _migration_view_config_v2(conn: sqlite3.Connection) -> None: + """Migration 44 — versionnage des config_json de vues (phase 1, Chart).""" + cols = columns(conn, "collection_views") + if "config_version" not in cols: + conn.execute( + "ALTER TABLE collection_views ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_collection_views_type " + "ON collection_views(collection_id, view_type)" + ) + + +@register(45, "vues-notion: place_geocode_cache") +def _migration_place_geocode_cache(conn: sqlite3.Connection) -> None: + """Migration 45 — cache partage du Geocoding Service (phase 3, Map).""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS geocode_cache ( + query_hash TEXT PRIMARY KEY, + provider TEXT NOT NULL DEFAULT 'nominatim', + query_text TEXT NOT NULL DEFAULT '', + result_json TEXT NOT NULL DEFAULT '{}', + lat REAL, + lng REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + hit_count INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at)" + ) + + +@register(46, "vues-notion: dashboard_widgets") +def _migration_dashboard_widgets(conn: sqlite3.Connection) -> None: + """Migration 46 — widgets de la vue Dashboard (phase 4).""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + row_index INTEGER NOT NULL DEFAULT 0, + col_span INTEGER NOT NULL DEFAULT 1, + height_units INTEGER NOT NULL DEFAULT 2, + source_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL, + source_view_id INTEGER REFERENCES collection_views(id) ON DELETE SET NULL, + inline_config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_view " + "ON dashboard_widgets(view_id, row_index, position)" + ) + + +@register(47, "vues-notion: form_questions_v2") +def _migration_form_questions_v2(conn: sqlite3.Connection) -> None: + """Migration 47 — questions du Form builder + repondant (phase 5).""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS form_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE, + property_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + label_override TEXT, + sync_label INTEGER NOT NULL DEFAULT 1, + help_text TEXT NOT NULL DEFAULT '', + required INTEGER NOT NULL DEFAULT 0, + widget TEXT NOT NULL DEFAULT 'auto', + max_selections INTEGER, + conditional_json TEXT, + UNIQUE(view_id, property_name) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_form_questions_view " + "ON form_questions(view_id, position)" + ) + for table, ddl, col in ( + ("form_responses", "ALTER TABLE form_responses ADD COLUMN view_id INTEGER", "view_id"), + ("form_responses", "ALTER TABLE form_responses ADD COLUMN respondent_user_id INTEGER REFERENCES users(id)", "respondent_user_id"), + ("form_responses", "ALTER TABLE form_responses ADD COLUMN anonymous INTEGER NOT NULL DEFAULT 0", "anonymous"), + ): + try: + existing = columns(conn, table) + except Exception: + continue + if col not in existing: + try: + conn.execute(ddl) + except Exception: + pass + + +# ── Templates Notion §9 : migrations 48 à 49 (registre unifié + journal) ── + +@register(48, "templates-notion: registre unifié `templates`") +def _migration_templates_registry(conn: sqlite3.Connection) -> None: + """Migration 48 — registre unifié des templates (phase 1). + + Catalogue les trois silos legacy (``database_templates``, + ``page_templates``, ``page_global_templates``) SANS les détruire : + colonnes ``source_kind``/``source_id`` + backfill idempotent côté + service (``TemplateService.backfill_registry``), rejouable au boot. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + workspace_id INTEGER NOT NULL DEFAULT 1, + teamspace_id INTEGER, + kind TEXT NOT NULL DEFAULT 'page' + CHECK (kind IN ('page','row','database','blocks')), + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT '📄', + cover_url TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'personal' + CHECK (scope IN ('personal','workspace','teamspace','system')), + owner_id INTEGER, + target_collection_id INTEGER, + content_page_id INTEGER, + source_kind TEXT NOT NULL DEFAULT 'native' + CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')), + source_id TEXT NOT NULL DEFAULT '', + manifest_json TEXT NOT NULL DEFAULT '{}', + variables_json TEXT NOT NULL DEFAULT '[]', + is_default INTEGER NOT NULL DEFAULT 0, + is_system INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + archived_at TEXT, + deleted_at TEXT, + sync_version INTEGER NOT NULL DEFAULT 1 + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_templates_ws_kind " + "ON templates(workspace_id, kind)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_templates_target " + "ON templates(target_collection_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_templates_source " + "ON templates(source_kind, source_id)" + ) + # Un seul défaut actif par base (les lignes soft-deleted/archivées ne comptent pas). + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_templates_default " + "ON templates(target_collection_id) " + "WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL" + ) + + +@register(49, "templates-notion: récurrences + journal `template_runs`") +def _migration_template_runs(conn: sqlite3.Connection) -> None: + """Migration 49 — récurrences des templates de ligne + journal des + instanciations (compteurs du gestionnaire, dédup scheduler, audit).""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS template_recurrences ( + template_id TEXT PRIMARY KEY REFERENCES templates(id) ON DELETE CASCADE, + rrule TEXT NOT NULL DEFAULT '', + timezone TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + next_run_at TEXT, + last_run_at TEXT, + end_count INTEGER, + updated_by INTEGER, + updated_at TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS template_runs ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE, + run_source TEXT NOT NULL DEFAULT 'ui' + CHECK (run_source IN ('ui','api','agent','automation','recurrence')), + actor_id INTEGER, + occurrence_key TEXT, + created_type TEXT NOT NULL DEFAULT '', + created_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ok' + CHECK (status IN ('ok','error','undone')), + warnings_json TEXT NOT NULL DEFAULT '[]', + error TEXT NOT NULL DEFAULT '', + idempotency_key TEXT, + created_at TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_occurrence " + "ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_idem " + "ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_template_runs_tpl " + "ON template_runs(template_id, created_at)" + ) diff --git a/app/routers/api_v2/templates_io.py b/app/routers/api_v2/templates_io.py index 3b01f38..42187e4 100644 --- a/app/routers/api_v2/templates_io.py +++ b/app/routers/api_v2/templates_io.py @@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str created += 1 conn.commit() return {"imported": created, "status": "ok"} + + +# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ── +# (chemins /fd-templates/* : les /templates/* historiques restent inchangés) + +def _v2_actor(user) -> dict: + return {"id": user.get("id"), "login": user.get("login", ""), + "full_name": user.get("full_name", ""), + "is_admin": bool(user.get("is_admin"))} + + +@router.get("/fd-templates") +def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None), + kind: str | None = None, q: str | None = None): + from app.services.fd_templates import TemplateService + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q) + return {"templates": items} + + +@router.get("/fd-templates/{template_id}") +def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)): + from app.services.fd_templates import TemplateService, _row_to_dto + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + try: + row = TemplateService.get_template(conn, _v2_actor(user), template_id) + return _row_to_dto(conn, row) + except LookupError: + raise HTTPException(404, "Template not found") + except PermissionError: + raise HTTPException(403, "Forbidden") + + +@router.post("/fd-templates/{template_id}/instantiate") +def instantiate_fd_template_v2(template_id: str, request: Request, + authorization: str | None = Header(default=None), + body: dict = Body(default={})): + from app.services.fd_templates import TemplateService + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key") + if idem: + hit = check_idempotency(request, user["id"]) + if hit: + return hit["data"] + with get_conn() as conn: + try: + result = TemplateService.instantiate( + conn, _v2_actor(user), template_id, body.get("variables") or {}, + body.get("context") or {}, idempotency_key=idem, run_source="api") + except LookupError: + raise HTTPException(404, "Template not found") + except PermissionError: + raise HTTPException(403, "Forbidden") + except ValueError as exc: + raise HTTPException(400, str(exc)) + audit_log(user, "template.instantiate", "template", template_id, "") + if idem: + store_idempotency(idem, user["id"], result, 200) + return result + + +@router.get("/fd-templates/{template_id}/export") +def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)): + """Export au format manifeste flowdeck-template v1 (annexe B).""" + from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto + user = get_bearer_user(request, authorization) + _v2_rate_check(request, user) + with get_conn() as conn: + try: + row = TemplateService.get_template(conn, _v2_actor(user), template_id) + dto = _row_to_dto(conn, row) + blocks, _w = _read_holder_blocks(conn, row) + except LookupError: + raise HTTPException(404, "Template not found") + except PermissionError: + raise HTTPException(403, "Forbidden") + manifest = { + "format": "flowdeck-template", "version": 1, "kind": dto["kind"], + "name": dto["name"], "description": dto["description"], "icon": dto["icon"], + "category": dto["category"], "variables": dto["variables"], + "manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks}, + } + if dto.get("recurrence"): + manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"), + "timezone": dto["recurrence"].get("timezone", "")} + return manifest + + +@router.post("/fd-templates/import") +def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None), + body: dict = Body(default={})): + """Import d'un manifeste flowdeck-template v1 (scope personnel, §14).""" + from app.services.fd_templates import KINDS, TemplateService + user = require_scope("write")(request, authorization) + _v2_rate_check(request, user) + if body.get("format") != "flowdeck-template": + raise HTTPException(400, "format must be flowdeck-template") + try: + version = int(body.get("version") or 0) + except (TypeError, ValueError): + raise HTTPException(400, "version invalide") + if version > 1: + raise HTTPException(400, "version de manifeste non supportée") + kind = body.get("kind") or "page" + if kind not in KINDS: + raise HTTPException(400, "kind inconnu") + blocks = ((body.get("content") or {}).get("blocks") or []) + if not isinstance(blocks, list) or len(blocks) > 2000: + raise HTTPException(400, "content.blocks invalide ou trop volumineux") + target = body.get("target") or {} + spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200], + "description": (body.get("description") or "")[:2000], + "icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120], + "scope": "personal", "blocks": blocks, + "manifest": body.get("manifest") or {}, "variables": body.get("variables") or [], + "title_template": (body.get("manifest") or {}).get("title_template", "")} + if kind == "row": + # La base cible est résolue par nom dans le workspace, jamais par id + # externe ; si absente, le template est créé « orphelin » et signalé. + cname = target.get("collection_name", "") + with get_conn() as conn: + actor = _v2_actor(user) + row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None + if row: + spec["target_collection_id"] = row["id"] + dto = TemplateService.create_template(conn, actor, spec) + conn.commit() + return {"id": dto["id"], "status": "imported"} + # Orphelin personnel : on choisit la première base comme pis-aller ? Non : + # le contrat exige un signalement — on refuse proprement. + raise HTTPException(400, "collection cible introuvable : match_by=collection_name") + with get_conn() as conn: + dto = TemplateService.create_template(conn, _v2_actor(user), spec) + conn.commit() + audit_log(user, "template.import", "template", dto["id"], "") + return {"id": dto["id"], "status": "imported"} diff --git a/app/routers/collections/views.py b/app/routers/collections/views.py index c7ffdc1..d73ae34 100644 --- a/app/routers/collections/views.py +++ b/app/routers/collections/views.py @@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "filter_conjunction", "date_property", "date_range_property", - "property_groups", "view_type"): + "property_groups", "view_type", + # Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4 + "version", "chart_type", "chart_property", "measure", "omit_zero", + "cumulative", "hidden_groups", "group_order", "style", "order", + "aggregate", "title", "center_value", "palette", "height", + "data_labels", "legend", "grid", "smooth", "gradient", + "color_by_value", "separate_end_property", "scale", "show_table", + "table_properties", "load_limit", "show_dependencies", "sub_items", + "row_order", "show_view_count", "excerpt", "place_property", + "cluster", "default_style", "global_filters", "row_heights", + "excluded_widget_ids", "sharing", "submit_screen", "notify_email", + "description", "form_view_id"): if key in body: config[key] = body[key] @@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default "status": "duplicated"} +# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ── + +def _load_view_collection(conn, collection_id: int, view_id: int): + view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() + if not view or int(view["collection_id"]) != int(collection_id): + raise HTTPException(status_code=404, detail="View not found") + try: + config = json.loads(view["config_json"] or "{}") + except (json.JSONDecodeError, TypeError): + config = {} + props = [dict(r) for r in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,)).fetchall()] + pages = [dict(r) for r in conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", + (collection_id,)).fetchall()] + return dict(view), config, props, pages + + +@router.get("/{collection_id}/views/{view_id}/rows") +def view_rows_api(request: Request, collection_id: int, view_id: int, + cursor: str | None = None, limit: int = 50, + window: str | None = None): + """Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...).""" + from app.services.view_query import query_view_rows + with get_conn() as conn: + view, config, props, pages = _load_view_collection(conn, collection_id, view_id) + if window: + # Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client. + config = dict(config) + limit = max(1, min(int(limit or 50), 1000)) + rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor) + undated = 0 + date_prop = (config.get("date_property") or config.get("date_range_property")) + if date_prop and (view.get("view_type") == "timeline"): + from app.services.view_query import prop_value, text_of + by_name = {p["name"]: p for p in props} + by_id = {str(p["id"]): p for p in props} + dp = by_name.get(date_prop) or by_id.get(str(date_prop)) + if dp: + for p in pages: + if not text_of(dp, prop_value(p, dp)): + undated += 1 + return {"rows": rows, "next_cursor": next_cursor, "total": len(pages), + "undated": undated, "view_type": view.get("view_type")} + + +@router.post("/{collection_id}/views/{view_id}/aggregate") +def view_aggregate_api(request: Request, collection_id: int, view_id: int, + body: dict = Body(default={})): + """Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3.""" + from app.services.view_aggregate import aggregate + with get_conn() as conn: + view, config, props, pages = _load_view_collection(conn, collection_id, view_id) + spec = { + "group_by": body.get("group_by", config.get("group_by")), + "sub_group_by": body.get("sub_group_by", config.get("sub_group_by")), + "measure": body.get("measure", config.get("measure", {"kind": "count"})), + "cumulative": body.get("cumulative", config.get("cumulative", False)), + "omit_zero": body.get("omit_zero", config.get("omit_zero", True)), + "hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])), + "order": body.get("order", config.get("order", config.get("group_order", "group_order"))), + "filters": (config.get("filters") or []) + (body.get("filters") or []), + "filter_conjunction": config.get("filter_conjunction", "and"), + "sorts": config.get("sorts"), + } + # Compat : ancienne config SSR {chart_property, aggregate} + if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")): + spec["group_by"] = config.get("chart_property") or body.get("chart_property") + if isinstance(spec["measure"], str): + spec["measure"] = {"kind": spec["measure"]} + if isinstance(spec["order"], str): + spec["order"] = {"by": spec["order"], "direction": "asc"} + return aggregate(pages, props, spec) + + +@router.get("/{collection_id}/views/{view_id}/drilldown") +def view_drilldown_api(request: Request, collection_id: int, view_id: int, + group: str = "", sub: str | None = None, limit: int = 100): + """Tableau restreint des lignes d'un segment de Chart (§8.4).""" + from app.services.view_query import query_view_rows + with get_conn() as conn: + view, config, props, pages = _load_view_collection(conn, collection_id, view_id) + group_by = config.get("group_by") or config.get("chart_property") + extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else [] + if sub and config.get("sub_group_by"): + extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub}) + rows, _ = query_view_rows(pages, props, config, extra_filters=extra, + limit=max(1, min(int(limit or 100), 500))) + return {"rows": rows, "group": group, "sub": sub, "count": len(rows)} + + +@router.get("/geocode/search") +def geocode_search_api(request: Request, q: str = "", limit: int = 5): + """Autocompletion d'adresses (editeur place). Provider none -> [].""" + from app.services import geocoding as _geo + with get_conn() as conn: + return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))} + + +@router.post("/geocode/resolve") +def geocode_resolve_api(request: Request, body: dict = Body(default={})): + """Adresse -> coordonnees (cache migration 45).""" + from app.services import geocoding as _geo + with get_conn() as conn: + result = _geo.resolve(conn, body.get("q") or body.get("address") or "") + if not result: + raise HTTPException(status_code=404, detail="Place not resolved") + return result + + +@router.get("/{collection_id}/views/{view_id}/widgets") +def dashboard_widgets_get(request: Request, collection_id: int, view_id: int): + with get_conn() as conn: + view, config, _, _ = _load_view_collection(conn, collection_id, view_id) + try: + rows = conn.execute( + "SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position", + (view_id,)).fetchall() + widgets = [dict(r) for r in rows] + except Exception: + widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or []) + return {"widgets": widgets, "config": config} + + +@router.put("/{collection_id}/views/{view_id}/widgets") +def dashboard_widgets_put(request: Request, collection_id: int, view_id: int, + body: dict = Body(default={})): + from app.routers.collections._common import _require_edit + from app.routers.collections._common import _session_user as _su + _require_edit(collection_id, _su(request)) + widgets = body.get("widgets") or [] + if len(widgets) > 12: + raise HTTPException(status_code=400, detail="12 widgets maximum") + with get_conn() as conn: + view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() + if not view or int(view["collection_id"]) != int(collection_id): + raise HTTPException(status_code=404, detail="View not found") + try: + conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,)) + for i, w in enumerate(widgets): + conn.execute( + """INSERT INTO dashboard_widgets + (view_id, position, row_index, col_span, height_units, + source_collection_id, source_view_id, inline_config_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1), + int(w.get("height_units", 2) or 2), + w.get("source_collection_id"), w.get("source_view_id"), + json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})), + ) + conn.commit() + except HTTPException: + raise + except Exception: + # Repli : stockage JSON dans le config (pre-migration 46). + config = json.loads(view["config_json"] or "{}") + config["widgets_json"] = widgets + config["global_filters"] = body.get("global_filters", config.get("global_filters", [])) + if body.get("global_filters") is not None: + config["global_filters"] = body["global_filters"] + conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", + (json.dumps(config), view_id)) + conn.commit() + return {"widgets": widgets, "fallback": "config_json"} + if body.get("global_filters") is not None: + config = json.loads(view["config_json"] or "{}") + config["global_filters"] = body["global_filters"] + conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", + (json.dumps(config), view_id)) + conn.commit() + try: + run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id})) + except Exception: + pass + return {"widgets": widgets, "status": "saved"} + + +@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data") +def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int): + from app.services.view_aggregate import aggregate + from app.services.view_query import query_view_rows + with get_conn() as conn: + try: + w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?", + (widget_id, view_id)).fetchone() + widget = dict(w) if w else None + except Exception: + widget = None + if widget is None: + raise HTTPException(status_code=404, detail="Widget not found") + src_cid = widget.get("source_collection_id") or collection_id + props = [dict(r) for r in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (src_cid,)).fetchall()] + pages = [dict(r) for r in conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", + (src_cid,)).fetchall()] + src_view = None + if widget.get("source_view_id"): + src_view = conn.execute("SELECT * FROM collection_views WHERE id=?", + (widget["source_view_id"],)).fetchone() + try: + inline = json.loads(widget.get("inline_config_json") or "{}") + except (json.JSONDecodeError, TypeError): + inline = {} + cfg = dict(inline) + if src_view: + try: + base = json.loads(src_view["config_json"] or "{}") + except (json.JSONDecodeError, TypeError): + base = {} + cfg = {**base, **inline} + vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table")) + if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None): + return aggregate(pages, props, { + "group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"), + "measure": cfg.get("measure", {"kind": "count"}), + "omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []), + "order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [], + }) + rows, _ = query_view_rows(pages, props, cfg, limit=50) + return {"rows": rows, "view_type": vtype or "table"} + + +@router.get("/{collection_id}/views/{view_id}/form") +def form_builder_get(request: Request, collection_id: int, view_id: int): + from app.services.form_projection import default_questions_for + with get_conn() as conn: + view, config, props, _ = _load_view_collection(conn, collection_id, view_id) + try: + qs = [dict(r) for r in conn.execute( + "SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()] + except Exception: + qs = [] + if not qs: + qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props]) + return {"config": config, "questions": qs} + + +@router.put("/{collection_id}/views/{view_id}/form") +def form_builder_put(request: Request, collection_id: int, view_id: int, + body: dict = Body(default={})): + from app.routers.collections._common import _require_edit + from app.routers.collections._common import _session_user as _su + from app.services.form_projection import project_form_config + _require_edit(collection_id, _su(request)) + questions = body.get("questions") or [] + form_cfg = body.get("config") or {} + with get_conn() as conn: + view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() + if not view or int(view["collection_id"]) != int(collection_id): + raise HTTPException(status_code=404, detail="View not found") + try: + base = json.loads(view["config_json"] or "{}") + except (json.JSONDecodeError, TypeError): + base = {} + base.update({k: v for k, v in form_cfg.items() + if k in ("title", "description", "sharing", "submit_screen", "notify_email")}) + base["version"] = 2 + try: + conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,)) + for i, q in enumerate(questions): + conn.execute( + """INSERT INTO form_questions + (view_id, property_name, position, label_override, sync_label, + help_text, required, widget, max_selections, conditional_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (view_id, q.get("property_name"), i, + q.get("label_override"), 1 if q.get("sync_label", True) else 0, + q.get("help_text", ""), 1 if q.get("required") else 0, + q.get("widget", "auto"), q.get("max_selections"), + json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")), + ) + stored_qs = [dict(r) for r in conn.execute( + "SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()] + except Exception: + base["form_questions_json"] = questions + stored_qs = questions + conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", + (json.dumps(base), view_id)) + conn.commit() + return {"questions": stored_qs, "status": "saved", "fallback": "config_json"} + projected = project_form_config(base, [{ + "property_name": r["property_name"], "position": r["position"], + "label_override": r["label_override"], "sync_label": r["sync_label"], + "help_text": r["help_text"], "required": r["required"], + "widget": r["widget"], "max_selections": r["max_selections"], + "conditional_json": r["conditional_json"], + } for r in stored_qs]) + base["projected_form_config"] = projected + # Synchro libelle -> nom de propriete (defaut Notion). + for r in stored_qs: + if r["sync_label"] and r["label_override"]: + conn.execute( + "UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?", + (r["label_override"], collection_id, r["property_name"])) + conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", + (json.dumps(base), view_id)) + # La projection alimente aussi le pipeline public /f/ existant (meme collection). + try: + conn.execute("UPDATE collections SET form_config_json=? WHERE id=?", + (json.dumps(projected), collection_id)) + except Exception: + pass + conn.commit() + return {"questions": stored_qs, "status": "saved", + "projected_fields": len(projected.get("fields", []))} + + +@router.post("/{collection_id}/views/{view_id}/form/preview-token") +def form_preview_token(request: Request, collection_id: int, view_id: int): + """Jeton d'apercu non-soumissible (soumissions refusees cote serveur).""" + import secrets + return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False} + + # ── v1.8.0 Sub-items & Dependencies ── diff --git a/app/routers/fd_templates.py b/app/routers/fd_templates.py new file mode 100644 index 0000000..c06e06c --- /dev/null +++ b/app/routers/fd_templates.py @@ -0,0 +1,480 @@ +"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie). + +Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1. +Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même +``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0). +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, Query, Request +from fastapi.exceptions import HTTPException +from fastapi.responses import HTMLResponse + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.fd_templates import TemplateService, ensure_registry + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["fd-templates"]) + + +def _actor(request: Request) -> dict | None: + try: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + except Exception: + user = None + return user + + +def _require_actor(request: Request) -> dict: + user = _actor(request) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + return user + + +def _as_http(exc: Exception) -> HTTPException: + if isinstance(exc, LookupError): + return HTTPException(404, str(exc) or "Template introuvable") + if isinstance(exc, PermissionError): + return HTTPException(403, str(exc) or "Accès refusé") + if isinstance(exc, ValueError): + return HTTPException(400, str(exc) or "Requête invalide") + logger.exception("templates API") + return HTTPException(500, "Erreur interne") + + +# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════ + +@router.get("/templates", response_class=HTMLResponse) +def templates_manager_page(request: Request): + """Gestionnaire de templates : liste filtrable + side peek d'aperçu.""" + from app.routers.board import _sidebar_data as board_sidebar + from app.templating import ENV + + sidebar = board_sidebar(request, "", "") + try: + with get_conn() as conn: + ensure_registry(conn) + except Exception: + logger.exception("ensure_registry /templates") + template = ENV.get_template("fd_templates_manager.html") + return template.render(**sidebar) + + +# ═══════════ API interne — lecture ═══════════ + +@router.get("/api/templates") +def list_templates( + request: Request, + kind: str | None = Query(default=None), + scope: str | None = Query(default=None), + target_collection_id: int | None = Query(default=None), + q: str | None = Query(default=None), + include_archived: bool = Query(default=False), +): + actor = _actor(request) + with get_conn() as conn: + try: + items = TemplateService.list_templates( + conn, actor, kind=kind, scope=scope, + target_collection_id=target_collection_id, query=q, + include_archived=include_archived, + ) + except Exception as exc: + raise _as_http(exc) from None + return {"templates": items} + + +@router.get("/api/templates/{template_id}") +def get_template(request: Request, template_id: str): + actor = _actor(request) + with get_conn() as conn: + try: + row = TemplateService.get_template(conn, actor, template_id) + from app.services.fd_templates import _row_to_dto + + dto = _row_to_dto(conn, row) + preview = TemplateService.preview(conn, actor, template_id) + dto["blocks_preview"] = preview.get("blocks_preview", []) + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.get("/api/collections/{collection_id}/templates") +def list_collection_templates(request: Request, collection_id: int): + """Menu *New ▾* d'une base : templates de lignes + défaut (§7.3).""" + actor = _actor(request) + with get_conn() as conn: + try: + items = TemplateService.list_templates( + conn, actor, kind="row", target_collection_id=collection_id + ) + default_id = next( + (t["id"] for t in items if t.get("is_default")), None + ) + except Exception as exc: + raise _as_http(exc) from None + return {"templates": items, "default_id": default_id} + + +@router.get("/api/templates/{template_id}/runs") +def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)): + actor = _require_actor(request) + with get_conn() as conn: + try: + TemplateService.get_template(conn, actor, template_id) + rows = conn.execute( + "SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?", + (template_id, max(1, min(int(limit), 100))), + ).fetchall() + except Exception as exc: + raise _as_http(exc) from None + return {"runs": [dict(r) for r in rows]} + + +# ═══════════ API interne — écriture ═══════════ + +@router.post("/api/templates") +def create_template(request: Request, body: dict = Body(default={})): + actor = _require_actor(request) + with get_conn() as conn: + try: + # Création depuis une source existante (create_from_*, §2.1). + if body.get("from_page_id"): + dto = _create_from_page(conn, actor, int(body["from_page_id"]), body) + elif body.get("from_collection_id"): + dto = _create_from_collection( + conn, actor, int(body["from_collection_id"]), body + ) + elif body.get("from_row"): + dto = _create_from_row(conn, actor, body["from_row"], body) + else: + dto = TemplateService.create_template(conn, actor, body) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.patch("/api/templates/{template_id}") +def update_template(request: Request, template_id: str, body: dict = Body(default={})): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.update_template(conn, actor, template_id, body) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.post("/api/templates/{template_id}/duplicate") +def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.duplicate_template(conn, actor, template_id, body) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.delete("/api/templates/{template_id}") +def delete_template(request: Request, template_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + result = TemplateService.delete_template(conn, actor, template_id) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return result + + +@router.post("/api/templates/{template_id}/archive") +def archive_template(request: Request, template_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.archive_template(conn, actor, template_id, archived=True) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.post("/api/templates/{template_id}/restore") +def restore_template(request: Request, template_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.archive_template(conn, actor, template_id, archived=False) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.put("/api/templates/{template_id}/property-defaults") +def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.update_template( + conn, actor, template_id, + {"property_defaults": body.get("property_defaults") or body}, + ) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.put("/api/templates/{template_id}/variables") +def set_variables(request: Request, template_id: str, body: dict = Body(default={})): + actor = _require_actor(request) + variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json") + if variables is None and isinstance(body, list): + variables = body + with get_conn() as conn: + try: + dto = TemplateService.update_template( + conn, actor, template_id, {"variables": variables or []} + ) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.post("/api/templates/{template_id}/set-default") +def set_default(request: Request, template_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.set_default(conn, actor, template_id) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.post("/api/templates/{template_id}/unset-default") +def unset_default(request: Request, template_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + dto = TemplateService.unset_default(conn, actor, template_id) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return dto + + +@router.put("/api/templates/{template_id}/recurrence") +def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})): + actor = _require_actor(request) + spec = None + if body and body.get("rrule"): + spec = { + "rrule": body.get("rrule"), + "timezone": body.get("timezone") or "", + "enabled": body.get("enabled", True), + } + with get_conn() as conn: + try: + recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec) + conn.commit() + except Exception as exc: + raise _as_http(exc) from None + return {"template_id": template_id, "recurrence": recurrence} + + +@router.post("/api/templates/{template_id}/preview") +def preview_template(request: Request, template_id: str, body: dict = Body(default={})): + actor = _actor(request) + with get_conn() as conn: + try: + result = TemplateService.preview( + conn, actor, template_id, body.get("variables") or {}, + title=(body.get("title") or ""), + ) + except Exception as exc: + raise _as_http(exc) from None + return result + + +@router.post("/api/templates/{template_id}/instantiate") +def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})): + actor = _actor(request) + idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key") + context = body.get("context") or {} + # La cible « page courante » porte l'id de page pour ajout/remplacement. + if body.get("page_id") and "target" not in context: + context["target"] = {"type": "current_page", "page_id": body.get("page_id"), + "mode": body.get("mode") or "fill"} + if body.get("collection_id") and "collection_id" not in context: + context["collection_id"] = body.get("collection_id") + if body.get("parent_page_id") and "parent_page_id" not in context: + context["parent_page_id"] = body.get("parent_page_id") + with get_conn() as conn: + try: + result = TemplateService.instantiate( + conn, actor, template_id, body.get("variables") or {}, context, + idempotency_key=idempotency_key, + run_source="api" if request.headers.get("authorization") else "ui", + ) + except Exception as exc: + raise _as_http(exc) from None + return result + + +@router.post("/api/templates/runs/{run_id}/undo") +def undo_run(request: Request, run_id: str): + actor = _require_actor(request) + with get_conn() as conn: + try: + result = TemplateService.mark_undone(conn, actor, run_id) + except Exception as exc: + raise _as_http(exc) from None + return result + + +# ═══════════ Création depuis l'existant (create_from_*) ═══════════ + +def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise LookupError("Page source introuvable") + blocks: list = [] + try: + if (row["content_format"] or "") == "blocks" and row["content"]: + blocks = json.loads(row["content"]) or [] + except (json.JSONDecodeError, TypeError): + blocks = [] + return TemplateService.create_template( + conn, actor, + { + "kind": spec.get("kind") or "page", + "name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)", + "description": spec.get("description") or "", + "icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄", + "category": spec.get("category") or "", + "scope": spec.get("scope") or "personal", + "workspace_id": spec.get("workspace_id"), + "target_collection_id": spec.get("target_collection_id"), + "blocks": blocks if isinstance(blocks, list) else [], + "variables": spec.get("variables") or [], + "title_template": spec.get("title_template") or f"{row['title'] or ''}", + }, + ) + + +def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict: + collection = conn.execute( + "SELECT * FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not collection: + raise LookupError("Base source introuvable") + props = [ + dict(p) + for p in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + ] + schema = [ + { + "name": p["name"], + "type": p["prop_type"] if "prop_type" in p else p.get("type", "text"), + "options": json.loads(p.get("options_json") or "[]"), + } + for p in props + ] + manifest = { + "format": "flowdeck-template", + "version": 1, + "collection": { + "name": collection["name"], + "icon": collection["icon"] if "icon" in collection.keys() else "📋", + "schema": schema, + }, + } + if spec.get("with_seed_rows"): + manifest["collection"]["seed_rows"] = [] + return TemplateService.create_template( + conn, actor, + { + "kind": "database", + "name": spec.get("name") or f"{collection['name']} (template)", + "description": spec.get("description") or (collection["description"] or ""), + "icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂", + "category": spec.get("category") or "Base", + "scope": spec.get("scope") or "personal", + "workspace_id": spec.get("workspace_id"), + "manifest": manifest, + "variables": spec.get("variables") or [], + }, + ) + + +def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict: + if isinstance(from_row, dict): + collection_id = from_row.get("collection_id") + row_id = from_row.get("row_id") + else: + collection_id, row_id = spec.get("target_collection_id"), from_row + if not collection_id or not row_id: + raise ValueError("from_row exige {collection_id, row_id}") + source = conn.execute( + "SELECT * FROM collection_pages WHERE id=? AND collection_id=?", + (row_id, collection_id), + ).fetchone() + if not source: + raise LookupError("Ligne source introuvable") + try: + pv = json.loads(source["property_values_json"] or "{}") + except (json.JSONDecodeError, TypeError): + pv = {} + # Les valeurs sont re-cléées par nom de propriété pour survivre à l'export. + props = { + str(p["id"]): p["name"] + for p in conn.execute( + "SELECT id, name FROM collection_properties WHERE collection_id=?", + (collection_id,), + ).fetchall() + } + defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()} + shadow_blocks: list = [] + try: + shadow = conn.execute( + "SELECT content, content_format FROM pages WHERE collection_row_id=?", + (row_id,), + ).fetchone() + if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]: + shadow_blocks = json.loads(shadow["content"]) or [] + except Exception: + shadow_blocks = [] + return TemplateService.create_template( + conn, actor, + { + "kind": "row", + "name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)", + "description": spec.get("description") or "", + "icon": spec.get("icon") or "📄", + "category": spec.get("category") or "", + "scope": spec.get("scope") or "personal", + "workspace_id": spec.get("workspace_id"), + "target_collection_id": collection_id, + "blocks": shadow_blocks if isinstance(shadow_blocks, list) else [], + "manifest": {"property_defaults": defaults}, + "title_template": spec.get("title_template") or (source["title"] or ""), + "variables": spec.get("variables") or [], + }, + ) diff --git a/app/services/fd_templates.py b/app/services/fd_templates.py new file mode 100644 index 0000000..a8f1694 --- /dev/null +++ b/app/services/fd_templates.py @@ -0,0 +1,3448 @@ +"""FlowDeck — Templates unifiés façon Notion (registre + instanciation unique). + +Phases 0-1 du cahier des charges ``docs/architecture-templates-notion-flowdeck.md`` : +le registre ``templates`` (migration 48) catalogue les trois silos legacy +(``database_templates``, ``page_templates``, ``page_global_templates``) sans +les détruire, et ``TemplateService.instantiate()`` est l'UNIQUE chemin de +création depuis un template (UI, API v2, agent, scheduler — P4). + +Conventions anti-dérive de schéma : les INSERT sur ``pages`` / +``collection_pages`` / ``collections`` n'utilisent que les colonnes qui +existent réellement (``PRAGMA table_info``), pour rester compatibles avec +toutes les bases (anciennes comme futures). +""" +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +logger = logging.getLogger(__name__) + +KINDS = ("page", "row", "database", "blocks") +SCOPES = ("personal", "workspace", "teamspace", "system") +RUN_SOURCES = ("ui", "api", "agent", "automation", "recurrence") + +_VAR_RE = re.compile(r"\{\{\s*([\w:+\-.]+)\s*\}\}") + +# Présence des tables du registre ( False tant que les migrations 48/49 ne +# sont pas appliquées — le service se dégrade alors vers les silos legacy). +_registry_ready: bool | None = None + + +def _now_iso() -> str: + return datetime.now(UTC).replace(tzinfo=None).isoformat() + + +def _new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +def _cols(conn: sqlite3.Connection, table: str) -> set[str]: + try: + return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + except Exception: + return set() + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + try: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() + ) + except Exception: + return False + + +def registry_ready(conn: sqlite3.Connection) -> bool: + """True si les migrations 48/49 sont appliquées.""" + global _registry_ready + if _registry_ready is None: + _registry_ready = _table_exists(conn, "templates") and _table_exists( + conn, "template_runs" + ) + return _registry_ready + + +def reset_registry_cache() -> None: + global _registry_ready + _registry_ready = None + + +def _actor_id(actor: dict | None) -> int | None: + if not actor: + return None + for key in ("id", "user_id", "owner_id"): + try: + if actor.get(key) is not None: + return int(actor[key]) + except (TypeError, ValueError): + continue + return None + + +def _actor_name(conn: sqlite3.Connection, actor: dict | None) -> str: + if actor and actor.get("login"): + return str(actor["login"]) + if actor and actor.get("full_name"): + return str(actor["full_name"]) + uid = _actor_id(actor) + if uid: + try: + row = conn.execute( + "SELECT full_name, login FROM users WHERE id=?", (uid,) + ).fetchone() + if row: + return row["full_name"] or row["login"] or f"user-{uid}" + except Exception: + pass + return "Moi" + + +def _actor_workspace_id(conn: sqlite3.Connection, actor: dict | None) -> int: + """Workspace de travail de l'acteur (défaut : son premier workspace).""" + if actor and actor.get("workspace_id"): + try: + return int(actor["workspace_id"]) + except (TypeError, ValueError): + pass + uid = _actor_id(actor) + try: + if uid: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", + (uid,), + ).fetchone() + if row: + return int(row["id"]) + row = conn.execute("SELECT id FROM workspaces ORDER BY id LIMIT 1").fetchone() + if row: + return int(row["id"]) + except Exception: + pass + return 1 + + +def _valid_workspace( + conn: sqlite3.Connection, actor: dict | None, *candidates: Any +) -> int | None: + """Premier candidat existant, sinon workspace garanti (ou None). + + Un template peut référencer un workspace disparu (import, base + restaurée) : utiliser son id tel quel casserait la FK de ``pages``. + """ + for candidate in candidates: + try: + cid = int(candidate) # type: ignore[arg-type] + except (TypeError, ValueError): + continue + try: + if conn.execute( + "SELECT 1 FROM workspaces WHERE id=?", (cid,) + ).fetchone(): + return cid + except Exception: + return cid + ensured = _ensure_workspace(conn, actor) + if ensured is not None: + return ensured + for candidate in candidates: + try: + return int(candidate) # type: ignore[arg-type] + except (TypeError, ValueError): + continue + return None + + +def _ensure_workspace(conn: sqlite3.Connection, actor: dict | None) -> int | None: + """Retourne un workspace_id valide (FK) — crée « Mon espace » si besoin. + + Sans cela, toute création échouerait par ``FOREIGN KEY constraint`` + sur une base fraîche sans workspace (tests, nouvelle instance). + Retourne ``None`` s'il n'existe encore aucun utilisateur (boot avant le + seed admin) : les pages-supports sont alors créées sans workspace + (colonne nullable), le registre garde un workspace_id numérique + (sans FK) en attendant la première connexion. + """ + ws_id = _actor_workspace_id(conn, actor) + try: + if conn.execute( + "SELECT 1 FROM workspaces WHERE id=?", (ws_id,) + ).fetchone(): + return ws_id + except Exception: + return ws_id + uid = _actor_id(actor) + if uid is None: + try: + first = conn.execute( + "SELECT id FROM users ORDER BY id LIMIT 1" + ).fetchone() + uid = int(first["id"]) if first else None + except Exception: + uid = None + if uid is None: + return None + try: + cur = conn.execute( + "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?,?,?)", + ("Mon espace", uid, "{}"), + ) + return int(cur.lastrowid) + except Exception: + logger.exception("création workspace templates") + return ws_id + + +def _workspace_name(conn: sqlite3.Connection, workspace_id: int) -> str: + try: + row = conn.execute( + "SELECT name FROM workspaces WHERE id=?", (workspace_id,) + ).fetchone() + if row and row["name"]: + return str(row["name"]) + except Exception: + pass + return "" + + +# ── Variables dynamiques (§8.4) ───────────────────────────────────────────── + +def _shift_date(base: datetime, spec: str) -> datetime: + """``+7d`` / ``-1w`` / ``+1mo`` / ``+2y``.""" + m = re.fullmatch(r"([+-]?\d+)\s*(d|w|mo|y)", spec.strip()) + if not m: + return base + n, unit = int(m.group(1)), m.group(2) + if unit == "d": + return base + timedelta(days=n) + if unit == "w": + return base + timedelta(weeks=n) + if unit == "mo": + return base + timedelta(days=30 * n) + return base + timedelta(days=365 * n) + + +def _system_variables( + conn: sqlite3.Connection, + actor: dict | None, + *, + title: str = "", + collection_name: str = "", + now: datetime | None = None, +) -> dict[str, Any]: + moment = now or datetime.now() + ws_id = _actor_workspace_id(conn, actor) + me = _actor_name(conn, actor) + return { + "today": moment.strftime("%Y-%m-%d"), + "now": moment.strftime("%Y-%m-%d %H:%M"), + "me": me, + "title": title, + "workspace": _workspace_name(conn, ws_id), + "collection": collection_name, + } + + +def _resolve_string( + text: str, values: dict[str, Any], *, strict: bool = True +) -> tuple[str, list[str]]: + """Résout les ``{{tokens}}`` dans un texte. Retourne (texte, avertissements). + + En mode strict, un token inconnu lève ``ValueError`` (échec avant écriture). + """ + warnings: list[str] = [] + + def _sub(match: re.Match) -> str: + token = match.group(1) + if token.startswith("date:"): + base = datetime.now() + shifted = _shift_date(base, token[5:]) + return shifted.strftime("%Y-%m-%d") + if token in values and values[token] not in (None, ""): + value = values[token] + if isinstance(value, list): + return ", ".join(str(v) for v in value) + return str(value) + if strict: + raise ValueError(f"variable de template non résolue : {{{{{token}}}}}") + warnings.append(f"Variable non résolue ignorée : {token}") + return match.group(0) + + try: + return _VAR_RE.sub(_sub, text), warnings + except ValueError: + raise + + +def _resolve_value( + value: Any, values: dict[str, Any], *, strict: bool = True +) -> tuple[Any, list[str]]: + """Résout récursivement (une référence exacte ``{{nom}}`` garde son type).""" + warnings: list[str] = [] + if isinstance(value, str): + exact = re.fullmatch(r"\{\{\s*([\w:+\-.]+)\s*\}\}", value.strip()) + if exact and exact.group(1) in values: + return values[exact.group(1)], warnings + return _resolve_string(value, values, strict=strict) + if isinstance(value, list): + out = [] + for item in value: + resolved, warns = _resolve_value(item, values, strict=strict) + warnings.extend(warns) + out.append(resolved) + return out, warnings + if isinstance(value, dict): + out_d = {} + for key, item in value.items(): + resolved, warns = _resolve_value(item, values, strict=strict) + warnings.extend(warns) + out_d[key] = resolved + return out_d, warnings + return value, warnings + + +def _declared_variables(template_row: sqlite3.Row) -> list[dict]: + try: + variables = json.loads(template_row["variables_json"] or "[]") + return variables if isinstance(variables, list) else [] + except (json.JSONDecodeError, TypeError): + return [] + + +def _manifest(template_row: sqlite3.Row) -> dict: + try: + manifest = json.loads(template_row["manifest_json"] or "{}") + return manifest if isinstance(manifest, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + +# ── Blocs ────────────────────────────────────────────────────────────────── + +def _fresh_block_ids(blocks: list) -> list: + """Copie profonde des blocs avec de nouveaux identifiants (§8.3 pas 5). + + Les blocs synchronisés sont matérialisés en blocs ordinaires : éditer la + copie ne doit jamais modifier la source. + """ + try: + clone = json.loads(json.dumps(blocks)) + except (TypeError, ValueError): + return [] + for block in clone: + if isinstance(block, dict): + block["id"] = uuid.uuid4().hex[:8] + block.pop("synced_block_id", None) + if block.get("type") == "synced": + block["type"] = "paragraph" + children = block.get("children") + if isinstance(children, list): + block["children"] = _fresh_block_ids(children) + return clone + + +def _read_holder_blocks( + conn: sqlite3.Connection, template_row: sqlite3.Row +) -> tuple[list, str]: + """Blocs de la page-support (ou du silo legacy pour les adaptateurs).""" + holder_id = template_row["content_page_id"] + if holder_id: + try: + row = conn.execute( + "SELECT content, content_format FROM pages WHERE id=?", (holder_id,) + ).fetchone() + if row: + if (row["content_format"] or "") == "blocks": + try: + blocks = json.loads(row["content"] or "[]") + return (blocks if isinstance(blocks, list) else [], "") + except (json.JSONDecodeError, TypeError): + return [], "Contenu du template illisible (JSON corrompu)" + if row["content"]: + return [ + { + "id": uuid.uuid4().hex[:8], + "type": "paragraph", + "content": row["content"], + } + ], "" + except Exception as exc: + logger.exception("lecture page-support template %s", template_row["id"]) + return [], f"Contenu du template illisible : {exc}" + # Adaptateurs legacy : le registre catalogue, le contenu reste au silo. + source_kind = template_row["source_kind"] + source_id = template_row["source_id"] + try: + if source_kind == "legacy_global" and source_id and _table_exists( + conn, "page_global_templates" + ): + row = conn.execute( + "SELECT blocks_json FROM page_global_templates WHERE id=?", + (int(source_id),), + ).fetchone() + if row: + blocks = json.loads(row["blocks_json"] or "[]") + return (blocks if isinstance(blocks, list) else [], "") + if source_kind == "legacy_page" and source_id and _table_exists( + conn, "page_templates" + ): + row = conn.execute( + "SELECT content_json FROM page_templates WHERE id=?", + (int(source_id),), + ).fetchone() + if row and row["content_json"]: + blocks = json.loads(row["content_json"]) + return (blocks if isinstance(blocks, list) else [], "") + except (ValueError, json.JSONDecodeError, TypeError): + pass + except Exception: + logger.exception("lecture adaptateur legacy %s/%s", source_kind, source_id) + return [], "" + + +# ── Permissions (§14, best-effort) ────────────────────────────────────────── + +def _is_teamspace_member(conn: sqlite3.Connection, teamspace_id: int, user_id: int) -> bool: + try: + if not _table_exists(conn, "teamspace_members"): + return True + row = conn.execute( + "SELECT private FROM teamspaces WHERE id=?", (teamspace_id,) + ).fetchone() if _table_exists(conn, "teamspaces") else None + if not row or not row["private"]: + return True + return bool( + conn.execute( + "SELECT 1 FROM teamspace_members WHERE teamspace_id=? AND user_id=?", + (teamspace_id, user_id), + ).fetchone() + ) + except Exception: + return True + + +def template_visible( + conn: sqlite3.Connection, template_row: sqlite3.Row, actor: dict | None +) -> bool: + """Un template soft-deleted/archivé n'est jamais visible en sélecteur.""" + if template_row["deleted_at"] or template_row["archived_at"]: + return False + scope = template_row["scope"] + if scope == "system": + return True + uid = _actor_id(actor) + if scope == "personal": + return bool(uid) and int(template_row["owner_id"] or 0) == uid + if scope == "teamspace" and template_row["teamspace_id"]: + if not uid: + return False + try: + return _is_teamspace_member(conn, int(template_row["teamspace_id"]), uid) + except (TypeError, ValueError): + return False + # workspace (+ repli historique) : visible par tout utilisateur connecté. + return True + + +def _row_to_dto( + conn: sqlite3.Connection, row: sqlite3.Row, *, usage: int = 0 +) -> dict: + recurrence = None + try: + if _table_exists(conn, "template_recurrences"): + rec = conn.execute( + "SELECT rrule, timezone, enabled, next_run_at FROM template_recurrences WHERE template_id=?", + (row["id"],), + ).fetchone() + if rec: + recurrence = dict(rec) + except Exception: + recurrence = None + return { + "id": row["id"], + "kind": row["kind"], + "name": row["name"], + "description": row["description"] or "", + "icon": row["icon"] or "📄", + "category": row["category"] or "", + "scope": row["scope"], + "teamspace_id": row["teamspace_id"], + "target_collection_id": row["target_collection_id"], + "is_default": bool(row["is_default"]), + "is_system": bool(row["is_system"]), + "position": row["position"] or 0, + "variables": _declared_variables(row), + "manifest": _manifest(row), + "recurrence": recurrence, + "usage_count": usage, + "updated_at": row["updated_at"] or "", + } + + +class TemplateService: + """Registre unifié + instanciation unique (§8).""" + + # ── Lecture ────────────────────────────────────────────────────────── + + @staticmethod + def list_templates( + conn: sqlite3.Connection, + actor: dict | None, + *, + kind: str | None = None, + scope: str | None = None, + target_collection_id: int | None = None, + query: str | None = None, + include_archived: bool = False, + workspace_id: int | None = None, + ) -> list[dict]: + if not registry_ready(conn): + return TemplateService._list_legacy(conn, actor, kind=kind, query=query) + ws_id = workspace_id or _actor_workspace_id(conn, actor) + sql = ( + "SELECT t.*, COALESCE((SELECT COUNT(*) FROM template_runs r " + "WHERE r.template_id=t.id AND r.status='ok'), 0) AS usage_count " + "FROM templates t WHERE t.deleted_at IS NULL" + ) + params: list[Any] = [] + if not include_archived: + sql += " AND t.archived_at IS NULL" + # Portée : personnel de l'acteur + workspace/système (+ teamspace). + # Les templates d'autres workspaces restent invisibles (P6). + sql += " AND (t.scope IN ('workspace','system') OR t.workspace_id=? OR t.owner_id=?)" + uid = _actor_id(actor) + params += [ws_id, uid or -1] + if kind and kind in KINDS: + sql += " AND t.kind=?" + params.append(kind) + if scope and scope in SCOPES: + sql += " AND t.scope=?" + params.append(scope) + if target_collection_id is not None: + sql += " AND t.target_collection_id=?" + params.append(target_collection_id) + if query: + sql += " AND (t.name LIKE ? OR t.description LIKE ? OR t.category LIKE ?)" + like = f"%{query}%" + params += [like, like, like] + sql += " ORDER BY t.is_system DESC, t.position, t.updated_at DESC" + out = [] + for row in conn.execute(sql, params).fetchall(): + if not template_visible(conn, row, actor): + continue + out.append(_row_to_dto(conn, row, usage=row["usage_count"])) + return out + + @staticmethod + def _list_legacy( + conn: sqlite3.Connection, + actor: dict | None, + *, + kind: str | None = None, + query: str | None = None, + ) -> list[dict]: + """Avant la migration 48 : expose les silos tels quels (dégradation).""" + out: list[dict] = [] + uid = _actor_id(actor) + if kind in (None, "database") and _table_exists(conn, "database_templates"): + for row in conn.execute( + "SELECT * FROM database_templates ORDER BY name" + ).fetchall(): + if query and query.lower() not in (row["name"] or "").lower(): + continue + out.append( + { + "id": f"legacy_database:{row['id']}", + "kind": "database", + "name": row["name"], + "description": row["description"] or "", + "icon": row["icon"] if "icon" in row.keys() else "📋", + "category": "", + "scope": "system", + "teamspace_id": None, + "target_collection_id": None, + "is_default": False, + "is_system": True, + "position": 0, + "variables": [], + "manifest": {}, + "recurrence": None, + "usage_count": 0, + "updated_at": "", + } + ) + if kind in (None, "blocks") and _table_exists(conn, "page_global_templates"): + for row in conn.execute( + "SELECT * FROM page_global_templates WHERE created_by IS NULL OR created_by=? ORDER BY name", + (uid,), + ).fetchall(): + if query and query.lower() not in (row["name"] or "").lower(): + continue + out.append( + { + "id": f"legacy_global:{row['id']}", + "kind": "blocks", + "name": row["name"], + "description": row["description"] or "", + "icon": row["icon"] or "📄", + "category": "", + "scope": "personal", + "teamspace_id": None, + "target_collection_id": None, + "is_default": False, + "is_system": False, + "position": 0, + "variables": [], + "manifest": {}, + "recurrence": None, + "usage_count": 0, + "updated_at": "", + } + ) + return out + + @staticmethod + def get_template( + conn: sqlite3.Connection, actor: dict | None, template_id: str + ) -> sqlite3.Row: + if not registry_ready(conn) or ":" in str(template_id): + raise LookupError("Template introuvable") + row = conn.execute( + "SELECT * FROM templates WHERE id=? AND deleted_at IS NULL", (template_id,) + ).fetchone() + if not row or not template_visible(conn, row, actor): + raise LookupError("Template introuvable") + return row + + # ── Écriture ───────────────────────────────────────────────────────── + + @staticmethod + def create_template( + conn: sqlite3.Connection, actor: dict | None, spec: dict + ) -> dict: + kind = spec.get("kind") or "page" + if kind not in KINDS: + raise ValueError(f"genre inconnu : {kind!r}") + uid = _actor_id(actor) + _ws = _valid_workspace(conn, actor, spec.get("workspace_id")) + ws_id = _ws if _ws is not None else 1 + scope = spec.get("scope") or "personal" + if scope not in SCOPES: + raise ValueError(f"portée inconnue : {scope!r}") + if scope == "system" and not (actor and actor.get("is_admin")): + raise PermissionError("Seul un admin peut créer un template système") + if scope == "personal" and not uid: + raise PermissionError("Connexion requise pour un template personnel") + target_collection_id = spec.get("target_collection_id") + if kind == "row" and not target_collection_id: + raise ValueError("un template de ligne exige target_collection_id") + now = _now_iso() + tid = _new_id("tpl") + # Page-support : le contenu vit dans une page ordinaire masquée (§9.3). + holder_id = None + if kind in ("page", "row", "blocks"): + holder_id = TemplateService._create_holder( + conn, + actor, + ws_id, + title=spec.get("name") or "Sans titre", + blocks=spec.get("blocks") or [], + content_format=spec.get("content_format") or "blocks", + ) + manifest = dict(spec.get("manifest") or {}) + manifest.setdefault("format", "flowdeck-template") + manifest.setdefault("version", 1) + if spec.get("title_template"): + manifest["title_template"] = spec["title_template"] + if kind == "row" and spec.get("property_defaults"): + manifest["property_defaults"] = spec["property_defaults"] + conn.execute( + """INSERT INTO templates (id, workspace_id, teamspace_id, kind, name, + description, icon, cover_url, category, scope, owner_id, + target_collection_id, content_page_id, source_kind, source_id, + manifest_json, variables_json, is_default, is_system, position, + created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tid, + ws_id, + spec.get("teamspace_id"), + kind, + (spec.get("name") or "Sans titre").strip(), + spec.get("description") or "", + spec.get("icon") or "📄", + spec.get("cover_url") or "", + spec.get("category") or "", + scope, + uid if scope == "personal" else spec.get("owner_id"), + target_collection_id, + holder_id, + "native", + "", + json.dumps(manifest), + json.dumps(spec.get("variables") or []), + 0, + 1 if scope == "system" else 0, + int(spec.get("position") or 0), + uid, + now, + now, + ), + ) + TemplateService._emit("template.created", {"template_id": tid, "kind": kind}) + row = conn.execute("SELECT * FROM templates WHERE id=?", (tid,)).fetchone() + return _row_to_dto(conn, row) + + @staticmethod + def _create_holder( + conn: sqlite3.Connection, + actor: dict | None, + workspace_id: int | None, + *, + title: str, + blocks: list, + content_format: str = "blocks", + ) -> int | None: + """Crée la page-support masquée (jamais listée : voir _holder_filters).""" + cols = _cols(conn, "pages") + ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private" + try: + uid = _actor_id(actor) + data: dict[str, Any] = {} + if "workspace" in cols: + data["workspace"] = ws_name + if "workspace_id" in cols and workspace_id is not None: + data["workspace_id"] = workspace_id + if "title" in cols: + data["title"] = f"🧩 {title}" + if "content" in cols: + data["content"] = ( + json.dumps(blocks or []) if content_format == "blocks" else "" + ) + if "content_format" in cols: + data["content_format"] = content_format + if "parent_section" in cols: + data["parent_section"] = "Private" + if "search_excluded" in cols: + data["search_excluded"] = 1 + if not data: + return None + keys = ", ".join(data.keys()) + placeholders = ", ".join("?" for _ in data) + cur = conn.execute( + f"INSERT INTO pages ({keys}) VALUES ({placeholders})", + tuple(data.values()), + ) + void = uid # traçabilité future (réservé) + return int(cur.lastrowid) + except Exception: + logger.exception("création page-support template") + return None + + @staticmethod + def update_template( + conn: sqlite3.Connection, actor: dict | None, template_id: str, patch: dict + ) -> dict: + row = TemplateService.get_template(conn, actor, template_id) + TemplateService._require_write(conn, actor, row) + if row["is_system"]: + raise PermissionError("Template système : dupliquez-le pour le modifier") + updates: dict[str, Any] = {} + for field in ( + "name", + "description", + "icon", + "cover_url", + "category", + "scope", + "teamspace_id", + "position", + ): + if field in patch: + updates[field] = patch[field] + if "scope" in updates and updates["scope"] not in SCOPES: + raise ValueError("portée inconnue") + if "property_defaults" in patch and row["kind"] == "row": + manifest = _manifest(row) + manifest["property_defaults"] = patch["property_defaults"] or {} + updates["manifest_json"] = json.dumps(manifest) + if "title_template" in patch: + manifest = _manifest(row) + manifest["title_template"] = patch["title_template"] or "" + updates["manifest_json"] = json.dumps(manifest) + if "variables" in patch: + updates["variables_json"] = json.dumps(patch["variables"] or []) + if "manifest" in patch and isinstance(patch["manifest"], dict): + merged = _manifest(row) + merged.update(patch["manifest"]) + updates["manifest_json"] = json.dumps(merged) + if updates: + updates["updated_at"] = _now_iso() + sets = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE templates SET {sets} WHERE id=?", + (*updates.values(), template_id), + ) + TemplateService._emit("template.updated", {"template_id": template_id}) + return _row_to_dto( + conn, + conn.execute( + "SELECT * FROM templates WHERE id=?", (template_id,) + ).fetchone(), + ) + + @staticmethod + def duplicate_template( + conn: sqlite3.Connection, actor: dict | None, template_id: str, spec: dict | None = None + ) -> dict: + row = TemplateService.get_template(conn, actor, template_id) + spec = spec or {} + uid = _actor_id(actor) + blocks, _warn = _read_holder_blocks(conn, row) + manifest = _manifest(row) + dto = TemplateService.create_template( + conn, + actor, + { + "kind": row["kind"], + "name": spec.get("name") or f"{row['name']} (copie)", + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "scope": spec.get("scope") or ("personal" if uid else "workspace"), + "teamspace_id": spec.get("teamspace_id") or row["teamspace_id"], + "workspace_id": spec.get("workspace_id") or row["workspace_id"], + "target_collection_id": row["target_collection_id"], + "blocks": blocks, + "manifest": manifest, + "variables": _declared_variables(row), + "title_template": manifest.get("title_template", ""), + }, + ) + return dto + + @staticmethod + def archive_template(conn, actor, template_id: str, *, archived: bool = True) -> dict: + row = TemplateService.get_template(conn, actor, template_id) + TemplateService._require_write(conn, actor, row) + now = _now_iso() if archived else None + conn.execute( + "UPDATE templates SET archived_at=?, updated_at=? WHERE id=?", + (now, _now_iso(), template_id), + ) + return _row_to_dto( + conn, + conn.execute("SELECT * FROM templates WHERE id=?", (template_id,)).fetchone(), + ) + + @staticmethod + def delete_template(conn, actor, template_id: str) -> dict: + """Soft-delete (corbeille unifiée) ; la page-support suit.""" + row = TemplateService.get_template(conn, actor, template_id) + TemplateService._require_write(conn, actor, row) + now = _now_iso() + conn.execute( + "UPDATE templates SET deleted_at=?, updated_at=? WHERE id=?", + (now, now, template_id), + ) + if row["content_page_id"]: + try: + cols = _cols(conn, "pages") + if "deleted_at" in cols: + conn.execute( + "UPDATE pages SET deleted_at=? WHERE id=?", + (now, row["content_page_id"]), + ) + except Exception: + logger.exception("soft-delete page-support %s", row["content_page_id"]) + TemplateService._emit("template.deleted", {"template_id": template_id}) + return {"id": template_id, "status": "deleted"} + + @staticmethod + def set_default(conn, actor, template_id: str) -> dict: + """Défaut exclusif par base, en une transaction (§11.1).""" + row = TemplateService.get_template(conn, actor, template_id) + if row["kind"] != "row" or not row["target_collection_id"]: + raise ValueError("seul un template de ligne a un défaut") + TemplateService._require_write(conn, actor, row) + conn.execute( + "UPDATE templates SET is_default=0 WHERE target_collection_id=? AND is_default=1", + (row["target_collection_id"],), + ) + conn.execute( + "UPDATE templates SET is_default=1, updated_at=? WHERE id=?", + (_now_iso(), template_id), + ) + return _row_to_dto( + conn, + conn.execute("SELECT * FROM templates WHERE id=?", (template_id,)).fetchone(), + ) + + @staticmethod + def unset_default(conn, actor, template_id: str) -> dict: + row = TemplateService.get_template(conn, actor, template_id) + TemplateService._require_write(conn, actor, row) + conn.execute( + "UPDATE templates SET is_default=0, updated_at=? WHERE id=?", + (_now_iso(), template_id), + ) + return _row_to_dto( + conn, + conn.execute("SELECT * FROM templates WHERE id=?", (template_id,)).fetchone(), + ) + + @staticmethod + def set_recurrence( + conn, actor, template_id: str, spec: dict | None + ) -> dict | None: + row = TemplateService.get_template(conn, actor, template_id) + if row["kind"] != "row": + raise ValueError("seuls les templates de ligne sont récurrents") + TemplateService._require_write(conn, actor, row) + if not spec or not spec.get("rrule"): + conn.execute( + "DELETE FROM template_recurrences WHERE template_id=?", (template_id,) + ) + return None + now = _now_iso() + next_run = _compute_next_run( + spec["rrule"], spec.get("timezone") or "", start=now + ) + conn.execute( + """INSERT INTO template_recurrences + (template_id, rrule, timezone, enabled, next_run_at, updated_at) + VALUES (?,?,?,?,?,?) + ON CONFLICT(template_id) DO UPDATE SET + rrule=excluded.rrule, timezone=excluded.timezone, + enabled=excluded.enabled, next_run_at=excluded.next_run_at, + updated_at=excluded.updated_at""", + ( + template_id, + spec["rrule"], + spec.get("timezone") or "", + 1 if spec.get("enabled", True) else 0, + next_run, + now, + ), + ) + rec = conn.execute( + "SELECT rrule, timezone, enabled, next_run_at FROM template_recurrences WHERE template_id=?", + (template_id,), + ).fetchone() + return dict(rec) if rec else None + + @staticmethod + def _require_write(conn, actor, row: sqlite3.Row) -> None: + uid = _actor_id(actor) + if actor and actor.get("is_admin"): + return + if row["scope"] == "system": + raise PermissionError("Template système en lecture seule") + if row["scope"] == "personal": + if not uid or int(row["owner_id"] or 0) != uid: + raise PermissionError("Template personnel d'un autre utilisateur") + return + # workspace/teamspace : il faut être connecté ; l'édition du défaut et + # de la récurrence d'un template de ligne exige l'édition de la base. + if not uid: + raise PermissionError("Connexion requise") + if row["kind"] == "row" and row["target_collection_id"]: + try: + if not conn.execute( + "SELECT id FROM collections WHERE id=?", (row["target_collection_id"],) + ).fetchone(): + raise PermissionError("Base cible introuvable") + except PermissionError: + raise + except Exception: + pass + + # ── Aperçu ─────────────────────────────────────────────────────────── + + @staticmethod + def preview( + conn, + actor: dict | None, + template_id: str, + variables: dict | None = None, + *, + title: str = "", + ) -> dict: + row = TemplateService.get_template(conn, actor, template_id) + # L'aperçu ne bloque JAMAIS : les variables requises manquantes sont + # montrées telles quelles ({{nom}}), seule l'instanciation exige. + resolved, warnings = TemplateService._resolve_all( + conn, actor, row, variables or {}, title=title, strict=False + ) + blocks, warn = _read_holder_blocks(conn, row) + if warn: + warnings.append(warn) + preview_blocks = [] + for block in _fresh_block_ids(blocks)[:12]: + if isinstance(block, dict) and isinstance(block.get("content"), str): + try: + text, _w = _resolve_string(block["content"], resolved, strict=False) + block = dict(block) + block["content"] = text + except ValueError: + pass + preview_blocks.append(block) + return { + "template_id": template_id, + "title": resolved.get("__title__", row["name"]), + "property_defaults": resolved.get("__properties__", {}), + "blocks_preview": preview_blocks, + "variables": _declared_variables(row), + "warnings": warnings, + } + + @staticmethod + def _resolve_all( + conn, + actor: dict | None, + row: sqlite3.Row, + provided: dict, + *, + title: str = "", + collection_name: str = "", + strict: bool = True, + ) -> tuple[dict, list[str]]: + """Résout système puis déclarées. + + En mode strict (instanciation) : erreur AVANT toute écriture si une + variable requise manque ou si un token survit (§8.3 pas 3, §8.4). + En mode aperçu : jamais d'erreur — les tokens non résolus restent + visibles tels quels dans l'aperçu. + """ + warnings: list[str] = [] + manifest = _manifest(row) + values = _system_variables( + conn, actor, title=title or (provided.get("title") or ""), collection_name=collection_name + ) + for var in _declared_variables(row): + name = var.get("name") + if not name: + continue + if name == "title": + # « title » est une variable SYSTÈME réservée : jamais requise. + # Une valeur fournie prime, sinon la résolution retombe sur le + # titre de la page cible ou le nom du template (§8.4). + if provided.get("title") not in (None, ""): + values["title"] = provided["title"] + continue + if name in provided and provided[name] not in (None, ""): + values[name] = provided[name] + elif var.get("default") not in (None, ""): + values[name] = var["default"] + elif var.get("required") and strict: + raise ValueError(f"variable requise manquante : {name}") + elif name not in values: + values[name] = "" + # Titre : title_template > titre fourni > nom du template. + title_tpl = manifest.get("title_template") or "{{title}}" + try: + resolved_title, _w = _resolve_string( + title_tpl if "{{" in title_tpl else title_tpl, + {**values, "title": values.get("title") or title or row["name"]}, + strict=strict, + ) + except ValueError as exc: + raise ValueError(str(exc)) from None + values["__title__"] = resolved_title or title or row["name"] + # Propriétés par défaut (références typées conservées si exactes). + raw_defaults = manifest.get("property_defaults") or {} + # Adaptateur legacy : les valeurs vivent dans le silo d'origine. + if not raw_defaults and row["source_kind"] == "legacy_page" and row["source_id"]: + try: + legacy = conn.execute( + "SELECT property_values_json FROM page_templates WHERE id=?", + (int(row["source_id"]),), + ).fetchone() + if legacy and legacy["property_values_json"]: + raw_defaults = json.loads(legacy["property_values_json"]) or {} + except (ValueError, json.JSONDecodeError, TypeError): + raw_defaults = {} + except Exception: + raw_defaults = {} + resolved_props, warns = _resolve_value(raw_defaults, values, strict=strict) + warnings.extend(warns) + values["__properties__"] = ( + resolved_props if isinstance(resolved_props, dict) else {} + ) + # Garde-fou §8.4 (instanciation uniquement) : aucun {{token}} ne + # survit dans le titre/propriétés. En aperçu, les tokens restants + # sont affichés tels quels pour guider la saisie. + leftovers = _VAR_RE.findall( + json.dumps( + {"t": values["__title__"], "p": values["__properties__"]}, + default=str, + ) + ) + if leftovers and strict: + raise ValueError( + "variables non résolues : " + ", ".join(sorted(set(leftovers))) + ) + if leftovers: + warnings.append( + "À compléter : " + ", ".join(sorted(set(leftovers))) + ) + return values, warnings + + # ── Instanciation unique (§8.3, P4) ────────────────────────────────── + + @staticmethod + def instantiate( + conn, + actor: dict | None, + template_id: str, + variables: dict | None = None, + context: dict | None = None, + *, + idempotency_key: str | None = None, + run_source: str = "ui", + ) -> dict: + if run_source not in RUN_SOURCES: + run_source = "ui" + # Idempotence : rejouer la même clé ne crée jamais deux objets. + if idempotency_key and registry_ready(conn): + existing = conn.execute( + "SELECT * FROM template_runs WHERE idempotency_key=?", + (idempotency_key,), + ).fetchone() + if existing and existing["status"] == "ok": + return { + "template_id": template_id, + "created_type": existing["created_type"], + "created_id": existing["created_id"], + "url": TemplateService._url_for( + existing["created_type"], existing["created_id"] + ), + "warnings": json.loads(existing["warnings_json"] or "[]"), + "run_id": existing["id"], + "replayed": True, + } + # Registre vide + id legacy → instanciation directe du silo. + if not registry_ready(conn) or ":" in str(template_id): + return TemplateService._instantiate_legacy( + conn, actor, str(template_id), variables or {}, context or {}, + idempotency_key=idempotency_key, run_source=run_source, + ) + row = TemplateService.get_template(conn, actor, template_id) + context = context or {} + target = (context.get("target") or {}).get("type") or "new_page" + uid = _actor_id(actor) + _ws = _valid_workspace(conn, actor, context.get("workspace_id"), row["workspace_id"]) + ws_id = _ws if _ws is not None else 1 + title_hint = (context.get("target") or {}).get("title") or (variables or {}).get("title", "") + collection_name = "" + target_collection = None + if row["kind"] == "row": + cid = context.get("collection_id") or row["target_collection_id"] + target_collection = None + if cid: + target_collection = conn.execute( + "SELECT * FROM collections WHERE id=?", (cid,) + ).fetchone() + if target_collection is None: + # Presets de ligne : la base cible est résolue par nom dans le + # workspace (cf. manifest.target_collection_name), jamais par + # un id externe qui ne voyagerait pas entre instances. + hint_name = _manifest(row).get("target_collection_name") + if hint_name: + target_collection = conn.execute( + "SELECT * FROM collections WHERE name=? " + "AND (workspace_id=? OR workspace_id IS NULL) " + "ORDER BY id LIMIT 1", + (hint_name, ws_id), + ).fetchone() + if target_collection is None: + hint_name = _manifest(row).get("target_collection_name") or "" + raise ValueError( + "Ce template de ligne doit être appliqué dans une base" + + (f" (« {hint_name} »)" if hint_name else "") + + " — créez-la d'abord ou ouvrez-la." + ) + collection_name = target_collection["name"] or "" + resolved, warnings = TemplateService._resolve_all( + conn, actor, row, variables or {}, title=title_hint, collection_name=collection_name + ) + created_type, created_id = "", "" + try: + if row["kind"] == "page": + created_id = str( + TemplateService._create_page( + conn, actor, ws_id, resolved, row, + parent_id=(context.get("target") or {}).get("page_id") + or context.get("parent_id"), + update_page_id=(context.get("target") or {}).get("page_id") + if target == "current_page" else None, + mode=(context.get("target") or {}).get("mode") or "fill", + ) + ) + created_type = "page" + elif row["kind"] == "row": + assert target_collection is not None + created_id = str( + TemplateService._create_row( + conn, actor, target_collection, resolved, row, + view_hints=context.get("view_hints") or {}, + ) + ) + created_type = "row" + elif row["kind"] == "database": + try: + _ppid = context.get("parent_page_id") + parent_pid = int(_ppid) if _ppid not in (None, "", 0, "0") else None + except (TypeError, ValueError): + parent_pid = None + created_id = str( + TemplateService._create_database( + conn, actor, ws_id, resolved, row, + parent_page_id=parent_pid, + ) + ) + created_type = "collection" + else: # blocks → insertion côté client ; le run journalise l'usage. + created_type = "insertion" + created_id = "" + # Les créateurs ajoutent leurs avertissements (propriétés + # disparues, mentions dégradées) dans resolved["__warnings__"]. + warnings = list(resolved.get("__warnings__") or warnings) + run_id = TemplateService._log_run( + conn, row["id"], run_source, uid, created_type, created_id, + warnings, idempotency_key=idempotency_key, + occurrence_key=context.get("occurrence_key"), + ) + conn.commit() + except Exception as exc: + try: + conn.rollback() + except Exception: + pass + try: + TemplateService._log_run( + conn, row["id"], run_source, uid, "", "", + warnings, error=str(exc), + idempotency_key=(idempotency_key + ":err") if idempotency_key else None, + occurrence_key=context.get("occurrence_key"), + ) + conn.commit() + except Exception: + pass + raise + TemplateService._emit( + "template.applied", + {"template_id": row["id"], "created_type": created_type, "created_id": created_id}, + ) + result: dict[str, Any] = { + "template_id": row["id"], + "created_type": created_type, + "created_id": created_id, + "url": TemplateService._url_for(created_type, created_id), + "warnings": warnings, + "run_id": run_id, + } + if row["kind"] == "blocks": + blocks, warn = _read_holder_blocks(conn, row) + if warn: + warnings.append(warn) + fresh = _fresh_block_ids(blocks) + resolved_blocks = [] + for block in fresh: + if isinstance(block, dict) and isinstance(block.get("content"), str): + try: + text, _w = _resolve_string(block["content"], resolved, strict=False) + block = dict(block) + block["content"] = text + except ValueError: + pass + resolved_blocks.append(block) + result["blocks"] = resolved_blocks + result["warnings"] = warnings + # Page courante remplie en place : renvoyer le contenu à jour pour + # que le front se rafraîchisse sans rechargement (T1-5). + if row["kind"] == "page" and (context.get("target") or {}).get("page_id"): + try: + current = conn.execute( + "SELECT content, content_format, title FROM pages WHERE id=?", + (int((context.get("target") or {})["page_id"]),), + ).fetchone() + if current: + if (current["content_format"] or "") == "blocks": + result["blocks"] = json.loads(current["content"] or "[]") + result["title"] = current["title"] or resolved.get("__title__") + except (ValueError, TypeError, json.JSONDecodeError): + pass + except Exception: + logger.exception("relecture page après instanciation") + return result + + # ── Briques de création ────────────────────────────────────────────── + + @staticmethod + def _create_page( + conn, actor, workspace_id: int, resolved: dict, row: sqlite3.Row, + *, parent_id: int | None = None, update_page_id: int | None = None, + mode: str = "fill", + ) -> int: + blocks, warn = _read_holder_blocks(conn, row) + warnings_holder = resolved.setdefault("__warnings__", []) + if warn: + warnings_holder.append(warn) + fresh = _fresh_block_ids(blocks) + out_blocks = [] + for block in fresh: + if isinstance(block, dict) and isinstance(block.get("content"), str): + try: + text, _w = _resolve_string(block["content"], resolved, strict=False) + block = dict(block) + block["content"] = text + except ValueError: + pass + out_blocks.append(block) + title = resolved.get("__title__") or row["name"] + cols = _cols(conn, "pages") + ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private" + # Application à la page courante : ajout (défaut non destructif) ou + # remplacement explicite (confirmation côté UI, T3-5). + if update_page_id: + current = conn.execute( + "SELECT * FROM pages WHERE id=?", (update_page_id,) + ).fetchone() + if not current: + raise LookupError("Page cible introuvable") + if mode == "replace": + if "title" in cols: + conn.execute( + "UPDATE pages SET title=? WHERE id=?", (title, update_page_id) + ) + if "content" in cols: + conn.execute( + "UPDATE pages SET content=? WHERE id=?", + (json.dumps(out_blocks), update_page_id), + ) + else: + # Remplir la page courante : on ajuste aussi le titre selon le + # template (résolu avec {{title}} = sujet fourni, sinon titre de + # la page, sinon nom du template) — c'est le comportement + # attendu « le titre suit le template choisi ». + if "title" in cols and title: + conn.execute( + "UPDATE pages SET title=? WHERE id=?", (title, update_page_id) + ) + if "content" in cols: + try: + existing = ( + json.loads(current["content"] or "[]") + if (current["content_format"] or "") == "blocks" + else [] + ) + except (json.JSONDecodeError, TypeError): + existing = [] + merged = (existing if isinstance(existing, list) else []) + out_blocks + conn.execute( + "UPDATE pages SET content=? WHERE id=?", + (json.dumps(merged), update_page_id), + ) + return int(update_page_id) + data: dict[str, Any] = {} + if "workspace" in cols: + data["workspace"] = ws_name + if "workspace_id" in cols and workspace_id is not None: + data["workspace_id"] = workspace_id + if "title" in cols: + data["title"] = title + if "content" in cols: + data["content"] = json.dumps(out_blocks) + if "content_format" in cols: + data["content_format"] = "blocks" + if "parent_section" in cols: + data["parent_section"] = "Private" + if "parent_id" in cols and parent_id: + data["parent_id"] = parent_id + if "page_icon" in cols and row["icon"]: + data["page_icon"] = row["icon"] + if "cover_url" in cols and row["cover_url"]: + data["cover_url"] = row["cover_url"] + manifest = _manifest(row) + if "full_width" in cols and manifest.get("full_width"): + data["full_width"] = 1 + if "font_small" in cols and manifest.get("font_small"): + data["font_small"] = 1 + if "collection_row_id" in cols: + data["collection_row_id"] = None + try: + sort_col = "sort_order" in cols + if sort_col: + if parent_id: + nxt = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id=?", + (parent_id,), + ).fetchone()[0] + else: + nxt = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id IS NULL" + ).fetchone()[0] + data["sort_order"] = nxt if nxt is not None else 0 + except Exception: + pass + keys = ", ".join(data.keys()) + placeholders = ", ".join("?" for _ in data) + cur = conn.execute( + f"INSERT INTO pages ({keys}) VALUES ({placeholders})", tuple(data.values()) + ) + return int(cur.lastrowid) + + @staticmethod + def _create_row( + conn, actor, collection: sqlite3.Row, resolved: dict, row: sqlite3.Row, + *, view_hints: dict | None = None, + ) -> int: + view_hints = view_hints or {} + props = { + p["name"]: dict(p) + for p in conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=?", + (collection["id"],), + ).fetchall() + } + try: + from app.services.property_types import validate_property_value + except Exception: + validate_property_value = None # type: ignore + warnings: list[str] = resolved.setdefault("__warnings__", []) + values: dict[str, Any] = {} + for name, raw in (resolved.get("__properties__") or {}).items(): + target = props.get(name) + if target is None: + # Compat : les anciens templates stockent parfois l'id. + by_id = next( + (p for p in props.values() if str(p["id"]) == str(name)), None + ) + if by_id is None: + warnings.append(f"Propriété « {name} » introuvable : ignorée") + continue + target = by_id + options = [] + try: + options = json.loads(target.get("options_json") or "[]") or [] + except (json.JSONDecodeError, TypeError): + options = [] + if validate_property_value is not None: + try: + ok, err = validate_property_value( + target.get("prop_type") or "text", raw, options or None + ) + except Exception: + ok, err = True, "" + if not ok: + warnings.append(f"Propriété « {target['name']} » : {err} — ignorée") + continue + values[str(target["id"])] = raw + # Les hints de vue priment sur le défaut (§7.3 : regroupement, date). + for hint_key in ("group_property", "date_property"): + hint = view_hints.get(hint_key) + if isinstance(hint, dict) and hint.get("property") and hint.get("value") not in (None, ""): + target = props.get(hint["property"]) + if target is not None: + values[str(target["id"])] = hint["value"] + warnings.append( + f"« {target['name']} » imposée par la vue : {hint['value']}" + ) + cp_cols = _cols(conn, "collection_pages") + insert: dict[str, Any] = {} + if "collection_id" in cp_cols: + insert["collection_id"] = collection["id"] + if "title" in cp_cols: + insert["title"] = resolved.get("__title__") or row["name"] + if "position" in cp_cols: + try: + insert["position"] = conn.execute( + "SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", + (collection["id"],), + ).fetchone()[0] + except Exception: + insert["position"] = 0 + if "property_values_json" in cp_cols: + insert["property_values_json"] = json.dumps(values) + keys = ", ".join(insert.keys()) + placeholders = ", ".join("?" for _ in insert) + cur = conn.execute( + f"INSERT INTO collection_pages ({keys}) VALUES ({placeholders})", + tuple(insert.values()), + ) + row_id = int(cur.lastrowid) + # Page-ombre : le corps du template devient le contenu de la ligne. + blocks, warn = _read_holder_blocks(conn, row) + if warn: + warnings.append(warn) + if blocks and "collection_row_id" in _cols(conn, "pages"): + fresh = _fresh_block_ids(blocks) + for block in fresh: + if isinstance(block, dict) and isinstance(block.get("content"), str): + try: + text, _w = _resolve_string(block["content"], resolved, strict=False) + block["content"] = text + except ValueError: + pass + try: + page_data: dict[str, Any] = {"collection_row_id": row_id} + pcols = _cols(conn, "pages") + if "title" in pcols: + page_data["title"] = resolved.get("__title__") or row["name"] + if "content" in pcols: + page_data["content"] = json.dumps(fresh) + if "content_format" in pcols: + page_data["content_format"] = "blocks" + if "workspace" in pcols: + page_data["workspace"] = "Private" + if "search_excluded" in pcols: + page_data["search_excluded"] = 0 + keys = ", ".join(page_data.keys()) + placeholders = ", ".join("?" for _ in page_data) + conn.execute( + f"INSERT INTO pages ({keys}) VALUES ({placeholders})", + tuple(page_data.values()), + ) + except Exception: + logger.exception("création page-ombre ligne %s", row_id) + warnings.append("Corps du template non reporté sur la page de ligne") + resolved["__warnings__"] = warnings + return row_id + + @staticmethod + def _create_database( + conn, actor, workspace_id: int, resolved: dict, row: sqlite3.Row, + *, parent_page_id: int | None = None, + ) -> int: + """Crée une collection depuis le template. + + Avec ``parent_page_id`` : base INLINE attachée au document courant + (même patron que la pilule « Database ») — le front insère ensuite + le bloc embed qui l'affiche en place, sans navigation. + """ + manifest = _manifest(row) + # Adaptateur legacy : le schéma vit dans database_templates. + schema = (manifest.get("collection") or {}).get("schema") + if schema is None and row["source_kind"] == "legacy_database" and row["source_id"]: + try: + legacy = conn.execute( + "SELECT * FROM database_templates WHERE id=?", + (int(row["source_id"]),), + ).fetchone() + if not legacy: + raise LookupError("Template de base d'origine introuvable") + schema = json.loads(legacy["schema_json"] or "[]") + name = resolved.get("__title__") or legacy["name"] + icon = row["icon"] or (legacy["icon"] if "icon" in legacy.keys() else "📋") + description = row["description"] or (legacy["description"] or "") + except (ValueError, json.JSONDecodeError, TypeError) as exc: + raise ValueError(f"schéma du template illisible : {exc}") from None + else: + schema = schema or [] + name = resolved.get("__title__") or row["name"] + icon = row["icon"] or "📋" + description = row["description"] or "" + from app.services.db_templates import create_from_template + + title = name + uid = _actor_id(actor) + ccols = _cols(conn, "collections") + # create_from_template() historique : on prépare la collection ici + # pour maîtriser workspace_id / created_by sur tous les schémas. + insert_cols = ["name", "description", "icon", "schema_json"] + insert_vals: list[Any] = [title, description, icon, json.dumps(schema)] + if "workspace_id" in ccols: + insert_cols.append("workspace_id") + insert_vals.append(workspace_id) + if parent_page_id and "is_inline" in ccols: + insert_cols.append("is_inline") + insert_vals.append(1) + if parent_page_id and "parent_page_id" in ccols: + insert_cols.append("parent_page_id") + insert_vals.append(parent_page_id) + cur = conn.execute( + f"INSERT INTO collections ({', '.join(insert_cols)}) " + f"VALUES ({', '.join('?' for _ in insert_cols)})", + tuple(insert_vals), + ) + collection_id = int(cur.lastrowid) + try: + if "created_by" in ccols and uid: + conn.execute( + "UPDATE collections SET created_by=? WHERE id=?", (uid, collection_id) + ) + except Exception: + pass + from app.services.db_templates import materialize_properties + + materialize_properties(conn, collection_id, schema) + try: + if _table_exists(conn, "collection_views"): + conn.execute( + "INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", + (collection_id, "Default View", "table", json.dumps({ + "visible_properties": ["Title"], "sorts": [], "filters": [], + })), + ) + except Exception: + logger.exception("vue par défaut base depuis template %s", row["id"]) + # Lignes d'exemple (optionnelles, seed_rows = références de templates). + for seed in (manifest.get("collection") or {}).get("seed_rows") or []: + try: + TemplateService.instantiate( + conn, actor, seed.get("template_ref", ""), + seed.get("variables") or {}, + {"collection_id": collection_id}, + run_source="automation", + ) + except Exception: + logger.exception("seed row template base %s", row["id"]) + void = create_from_template # compat : le helper historique reste l'API bas niveau + return collection_id + + @staticmethod + def _instantiate_legacy( + conn, actor, template_id: str, variables: dict, context: dict, + *, idempotency_key: str | None, run_source: str, + ) -> dict: + """Avant la migration 48 : instancie directement depuis les silos.""" + uid = _actor_id(actor) + if template_id.startswith("legacy_database:"): + tid = int(template_id.split(":", 1)[1]) + legacy = conn.execute( + "SELECT * FROM database_templates WHERE id=?", (tid,) + ).fetchone() + if not legacy: + raise LookupError("Template introuvable") + from app.services.db_templates import create_from_template + + _ws = _valid_workspace(conn, actor, context.get("workspace_id")) + ws_id = _ws if _ws is not None else 1 + cid = create_from_template( + conn, + (variables.get("title") or legacy["name"]), + dict(legacy), + workspace_id=ws_id, + ) + conn.commit() + return { + "template_id": template_id, "created_type": "collection", + "created_id": str(cid), "url": f"/db/{cid}", + "warnings": [], "run_id": None, + } + if template_id.startswith("legacy_global:"): + tid = int(template_id.split(":", 1)[1]) + row = conn.execute( + "SELECT * FROM page_global_templates WHERE id=?", (tid,) + ).fetchone() + if not row: + raise LookupError("Template introuvable") + try: + blocks = json.loads(row["blocks_json"] or "[]") + except (json.JSONDecodeError, TypeError): + blocks = [] + conn.commit() + return { + "template_id": template_id, "created_type": "insertion", + "created_id": "", "url": "", + "blocks": _fresh_block_ids(blocks if isinstance(blocks, list) else []), + "warnings": [], "run_id": None, + } + raise LookupError("Template introuvable") + + # ── Journal ────────────────────────────────────────────────────────── + + @staticmethod + def _log_run( + conn, template_id: str, run_source: str, actor_id: int | None, + created_type: str, created_id: str, warnings: list[str], + *, error: str = "", idempotency_key: str | None = None, + occurrence_key: str | None = None, + ) -> str | None: + if not registry_ready(conn): + return None + # Les templates legacy (":") n'ont pas de ligne au registre : pas de run. + if ":" in str(template_id): + return None + run_id = _new_id("trun") + try: + conn.execute( + """INSERT INTO template_runs (id, template_id, run_source, actor_id, + occurrence_key, created_type, created_id, status, warnings_json, + error, idempotency_key, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + run_id, template_id, run_source, actor_id, occurrence_key, + created_type, str(created_id or ""), + "error" if error else "ok", json.dumps(warnings or []), + error or "", idempotency_key, _now_iso(), + ), + ) + return run_id + except sqlite3.IntegrityError: + # Dédup récurrence/idempotence : le run existe déjà, on le renvoie. + logger.info("run template dédupliqué (%s)", occurrence_key or idempotency_key) + try: + key_col = "occurrence_key" if occurrence_key else "idempotency_key" + key_val = occurrence_key or idempotency_key + existing = conn.execute( + f"SELECT id FROM template_runs WHERE {key_col}=?", (key_val,) + ).fetchone() + return existing["id"] if existing else None + except Exception: + return None + + @staticmethod + def mark_undone(conn, actor, run_id: str) -> dict: + """Annule une instanciation (toast « Annuler ») : soft-delete + statut.""" + if not registry_ready(conn): + raise LookupError("Journal indisponible") + run = conn.execute( + "SELECT * FROM template_runs WHERE id=?", (run_id,) + ).fetchone() + if not run or run["status"] != "ok": + raise LookupError("Run introuvable ou déjà annulé") + ctype, cid = run["created_type"], run["created_id"] + try: + if ctype == "page" and cid: + cols = _cols(conn, "pages") + if "deleted_at" in cols: + conn.execute( + "UPDATE pages SET deleted_at=? WHERE id=?", (_now_iso(), int(cid)) + ) + else: + conn.execute("DELETE FROM pages WHERE id=?", (int(cid),)) + elif ctype == "row" and cid: + conn.execute( + "DELETE FROM collection_pages WHERE id=?", (int(cid),) + ) + elif ctype == "collection" and cid: + conn.execute("DELETE FROM collections WHERE id=?", (int(cid),)) + except (ValueError, TypeError): + pass + conn.execute("UPDATE template_runs SET status='undone' WHERE id=?", (run_id,)) + conn.commit() + return {"run_id": run_id, "status": "undone"} + + # ── Backfill (P8) ──────────────────────────────────────────────────── + + @staticmethod + def backfill_registry(conn: sqlite3.Connection) -> dict[str, int]: + """Catalogue les silos legacy vers le registre — idempotent (rejouable). + + Clé d'idempotence : ``source_kind + source_id``. Ne déplace aucun + contenu : l'instanciation lit le silo d'origine tant que le template + n'est pas édité (convergence à la première édition). + """ + counts = {"legacy_database": 0, "legacy_page": 0, "legacy_global": 0} + if not registry_ready(conn): + return counts + now = _now_iso() + if _table_exists(conn, "database_templates"): + for r in conn.execute("SELECT * FROM database_templates").fetchall(): + exists = conn.execute( + "SELECT 1 FROM templates WHERE source_kind='legacy_database' AND source_id=?", + (str(r["id"]),), + ).fetchone() + if exists: + continue + conn.execute( + """INSERT INTO templates (id, workspace_id, kind, name, description, + icon, category, scope, source_kind, source_id, manifest_json, + is_system, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + _new_id("tpl"), 1, "database", r["name"], + r["description"] or "", + r["icon"] if "icon" in r.keys() else "📋", + "Base", "system", "legacy_database", str(r["id"]), + json.dumps({"format": "flowdeck-template", "version": 1}), + 1, now, now, + ), + ) + counts["legacy_database"] += 1 + if _table_exists(conn, "page_templates"): + for r in conn.execute("SELECT * FROM page_templates").fetchall(): + exists = conn.execute( + "SELECT 1 FROM templates WHERE source_kind='legacy_page' AND source_id=?", + (str(r["id"]),), + ).fetchone() + if exists: + continue + manifest: dict[str, Any] = { + "format": "flowdeck-template", "version": 1, + } + try: + manifest["property_defaults"] = json.loads( + r["property_values_json"] or "{}" + ) + except (json.JSONDecodeError, TypeError): + manifest["property_defaults"] = {} + keys = r.keys() if hasattr(r, "keys") else [] + tid = _new_id("tpl") + conn.execute( + """INSERT INTO templates (id, workspace_id, kind, name, description, + scope, target_collection_id, source_kind, source_id, + manifest_json, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tid, 1, "row", r["name"] or "Sans titre", + (r["description"] if "description" in keys else "") or "", + "workspace", r["collection_id"], + "legacy_page", str(r["id"]), json.dumps(manifest), now, now, + ), + ) + counts["legacy_page"] += 1 + # Récurrence legacy → template_recurrences (transcription RRULE). + try: + if ( + "is_recurring" in keys and r["is_recurring"] + and "recurrence_rule" in keys and r["recurrence_rule"] + ): + conn.execute( + """INSERT OR IGNORE INTO template_recurrences + (template_id, rrule, timezone, enabled, next_run_at, updated_at) + VALUES (?,?,?,?,?,?)""", + ( + tid, r["recurrence_rule"], "", 1, + _compute_next_run(r["recurrence_rule"], "", start=now), + now, + ), + ) + except Exception: + logger.exception("transcription récurrence legacy %s", r["id"]) + if _table_exists(conn, "page_global_templates"): + for r in conn.execute("SELECT * FROM page_global_templates").fetchall(): + exists = conn.execute( + "SELECT 1 FROM templates WHERE source_kind='legacy_global' AND source_id=?", + (str(r["id"]),), + ).fetchone() + if exists: + continue + conn.execute( + """INSERT INTO templates (id, workspace_id, kind, name, description, + icon, scope, owner_id, source_kind, source_id, manifest_json, + created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + _new_id("tpl"), 1, "blocks", r["name"], + r["description"] or "", r["icon"] or "📄", "personal", + r["created_by"] if "created_by" in r.keys() else None, + "legacy_global", str(r["id"]), + json.dumps({"format": "flowdeck-template", "version": 1}), + now, now, + ), + ) + counts["legacy_global"] += 1 + conn.commit() + logger.info("backfill registre templates : %s", counts) + return counts + + # ── Presets système (§12) ──────────────────────────────────────────── + + @staticmethod + def ensure_system_presets(conn: sqlite3.Connection) -> int: + """Installe / rafraîchit les presets fournis (idempotent). + + Idempotence par (scope='system', kind, name). Un bump de + ``PRESET_VERSION`` rafraîchit les presets système existants dont les + types de blocs ou le manifeste ont été corrigés entre deux versions + (les presets système ne sont jamais édités en place par l'utilisateur). + """ + if not registry_ready(conn): + return 0 + created = 0 + holder_ws = _ensure_workspace(conn, None) + for preset in SYSTEM_PRESETS: + existing = conn.execute( + "SELECT id, content_page_id, manifest_json FROM templates " + "WHERE scope='system' AND kind=? AND name=? AND deleted_at IS NULL", + (preset["kind"], preset["name"]), + ).fetchone() + if existing is not None: + try: + cur_manifest = json.loads(existing["manifest_json"] or "{}") + except (json.JSONDecodeError, TypeError): + cur_manifest = {} + if cur_manifest.get("preset_version") == PRESET_VERSION: + continue + TemplateService._refresh_system_preset(conn, preset, existing, holder_ws) + continue + now = _now_iso() + tid = _new_id("tpl") + holder_id = None + if preset["kind"] in ("page", "row", "blocks"): + holder_id = TemplateService._create_holder( + conn, None, holder_ws, title=preset["name"], + blocks=preset.get("blocks") or [], + ) + manifest = {"format": "flowdeck-template", "version": 1, + "preset_version": PRESET_VERSION} + if preset.get("manifest"): + manifest.update(preset["manifest"]) + if preset.get("title_template"): + manifest["title_template"] = preset["title_template"] + if preset.get("property_defaults"): + manifest["property_defaults"] = preset["property_defaults"] + conn.execute( + """INSERT INTO templates (id, workspace_id, kind, name, description, + icon, category, scope, content_page_id, source_kind, manifest_json, + variables_json, is_system, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tid, 1, preset["kind"], preset["name"], + preset.get("description") or "", preset.get("icon") or "📄", + preset.get("category") or "", "system", holder_id, "native", + json.dumps(manifest), + json.dumps(preset.get("variables") or []), + 1, now, now, + ), + ) + created += 1 + if created: + conn.commit() + return created + + @staticmethod + def _refresh_system_preset(conn, preset: dict, existing: sqlite3.Row, + holder_ws: int | None) -> None: + """Met à jour un preset système existant (blocs + manifeste).""" + manifest = {"format": "flowdeck-template", "version": 1, + "preset_version": PRESET_VERSION} + if preset.get("manifest"): + manifest.update(preset["manifest"]) + if preset.get("title_template"): + manifest["title_template"] = preset["title_template"] + if preset.get("property_defaults"): + manifest["property_defaults"] = preset["property_defaults"] + holder_id = existing["content_page_id"] + if preset["kind"] in ("page", "row", "blocks"): + blocks = preset.get("blocks") or [] + if holder_id: + cols = _cols(conn, "pages") + if "content" in cols: + conn.execute( + "UPDATE pages SET content=? WHERE id=?", + (json.dumps(blocks), holder_id), + ) + else: + holder_id = TemplateService._create_holder( + conn, None, holder_ws, title=preset["name"], blocks=blocks, + ) + conn.execute( + "UPDATE templates SET description=?, icon=?, category=?, " + "content_page_id=?, manifest_json=?, variables_json=?, updated_at=? " + "WHERE id=?", + ( + preset.get("description") or "", preset.get("icon") or "📄", + preset.get("category") or "", holder_id, json.dumps(manifest), + json.dumps(preset.get("variables") or []), _now_iso(), + existing["id"], + ), + ) + + # ── Récurrences dues (§11.2) ───────────────────────────────────────── + + @staticmethod + def run_due_recurrences( + conn: sqlite3.Connection, *, now_iso: str | None = None + ) -> list[dict]: + """Exécute les récurrences dues — dédupliqué par ``occurrence_key``.""" + out: list[dict] = [] + if not registry_ready(conn): + return out + now_iso = now_iso or _now_iso() + due = conn.execute( + """SELECT t.*, r.rrule, r.timezone, r.enabled, r.next_run_at FROM templates t + JOIN template_recurrences r ON r.template_id=t.id + WHERE r.enabled=1 AND (r.next_run_at IS NULL OR r.next_run_at<=?) + AND t.deleted_at IS NULL AND t.archived_at IS NULL + AND t.kind='row'""", + (now_iso,), + ).fetchall() + for row in due: + occurrence = (row["next_run_at"] or now_iso)[:10] + occurrence_key = f"{row['id']}@{occurrence}" + # Base archivée/supprimée → la récurrence se désactive elle-même. + target_ok = True + try: + if row["target_collection_id"] and not conn.execute( + "SELECT 1 FROM collections WHERE id=?", (row["target_collection_id"],) + ).fetchone(): + target_ok = False + except Exception: + target_ok = True + if not target_ok: + conn.execute( + "UPDATE template_recurrences SET enabled=0, updated_at=? WHERE template_id=?", + (_now_iso(), row["id"]), + ) + TemplateService._log_run( + conn, row["id"], "recurrence", None, "", "", + [f"Base cible disparue : récurrence désactivée ({occurrence})"], + occurrence_key=occurrence_key, + ) + continue + already = conn.execute( + "SELECT 1 FROM template_runs WHERE occurrence_key=?", (occurrence_key,) + ).fetchone() + if already: + pass + else: + try: + TemplateService.instantiate( + conn, {"id": row["created_by"], "login": ""}, + row["id"], {}, + {"collection_id": row["target_collection_id"], + "occurrence_key": occurrence_key}, + run_source="recurrence", + ) + out.append({"template_id": row["id"], "occurrence": occurrence, "status": "ok"}) + except Exception as exc: + logger.exception("récurrence template %s", row["id"]) + TemplateService._emit( + "template.recurrence_failed", + {"template_id": row["id"], "error": str(exc)}, + ) + out.append({"template_id": row["id"], "occurrence": occurrence, + "status": "error", "error": str(exc)}) + try: + nxt = _compute_next_run( + row["rrule"], row["timezone"] or "", + start=(row["next_run_at"] or now_iso), + ) + conn.execute( + "UPDATE template_recurrences SET next_run_at=?, last_run_at=?, updated_at=? WHERE template_id=?", + (nxt, now_iso, _now_iso(), row["id"]), + ) + except Exception: + logger.exception("recalcul next_run %s", row["id"]) + try: + conn.commit() + except Exception: + pass + return out + + # ── Divers ─────────────────────────────────────────────────────────── + + @staticmethod + def _url_for(created_type: str, created_id: str) -> str: + if created_type == "page" and created_id: + return f"/pages/{created_id}" + if created_type == "row" and created_id: + return f"/db/row/{created_id}" + if created_type == "collection" and created_id: + return f"/db/{created_id}" + return "" + + @staticmethod + def _emit(event: str, payload: dict) -> None: + try: + from app.services.automations import fire_event, run_event_sync + + run_event_sync(fire_event(event, payload)) + except Exception: + pass + + +# ── RRULE minimal (sous-ensemble de recurrence.py) ───────────────────────── + +def _compute_next_run(rrule: str, timezone: str, *, start: str) -> str: + """Prochaine occurrence ISO après ``start`` (DAILY/WEEKLY/MONTHLY + INTERVAL).""" + try: + base = datetime.fromisoformat(str(start)[:19]) + except (ValueError, TypeError): + base = datetime.now() + parts: dict[str, str] = {} + for chunk in (rrule or "").split(";"): + if "=" in chunk: + key, _, value = chunk.partition("=") + parts[key.strip().upper()] = value.strip().upper() + freq = parts.get("FREQ", "DAILY") + try: + interval = max(1, int(parts.get("INTERVAL", "1"))) + except ValueError: + interval = 1 + if freq == "WEEKLY": + byday = parts.get("BYDAY", "") + if byday: + order = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + wanted = [d for d in byday.split(",") if d in order] + if wanted: + for delta in range(1, 8 * max(1, interval) + 1): + candidate = base + timedelta(days=delta) + if order[candidate.weekday()] in wanted: + return candidate.replace( + hour=9, minute=0, second=0, microsecond=0 + ).isoformat() + return (base + timedelta(weeks=interval)).isoformat() + if freq == "MONTHLY": + return (base + timedelta(days=30 * interval)).isoformat() + if freq == "YEARLY": + return (base + timedelta(days=365 * interval)).isoformat() + return (base + timedelta(days=interval)).isoformat() + + +# ── Galerie système (§12) ────────────────────────────────────────────────── +# Types de blocs réels de l'éditeur : paragraph, heading_1..4, bulleted_list, +# numbered_list, to_do, toggle, quote, callout, divider, code… + +def _para(text: str = "") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "paragraph", "content": text} + + +def _h1(text: str) -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "heading_1", "content": text} + + +def _h2(text: str) -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "heading_2", "content": text} + + +def _h3(text: str) -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "heading_3", "content": text} + + +def _todo(text: str = "") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "to_do", "content": text, "checked": False} + + +def _bullet(text: str = "") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "bulleted_list", "content": text} + + +def _num(text: str = "") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "numbered_list", "content": text} + + +def _quote(text: str = "") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "quote", "content": text} + + +def _callout(text: str = "", icon: str = "💡") -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "callout", "content": text, "icon": icon} + + +def _divider() -> dict: + return {"id": uuid.uuid4().hex[:8], "type": "divider", "content": ""} + + +def _toggle(text: str = "") -> dict: + return { + "id": uuid.uuid4().hex[:8], "type": "toggle", "content": text, "expanded": True, + "children": [{"id": uuid.uuid4().hex[:8], "type": "paragraph", "content": ""}], + } + + +def _db_preset(name, icon, category, description, schema): + """Template de base système : le schéma vit dans manifest.collection.schema.""" + return { + "kind": "database", "name": name, "icon": icon, "category": category, + "description": description, + "manifest": {"collection": {"name": name, "icon": icon, "schema": schema}}, + } + + +def _status(name, options): + return {"name": name, "type": "status", + "options": [{"name": o, "color": c} for o, c in options]} + + +def _select(name, options): + return {"name": name, "type": "select", + "options": [{"name": o, "color": c} for o, c in options]} + + +# Incrémenter pour rafraîchir les presets système déjà installés (contenu ou +# types de blocs corrigés entre deux versions de Flowdeck). +PRESET_VERSION = 3 + + +SYSTEM_PRESETS: list[dict] = [ + # ── Pages ────────────────────────────────────────────────────────────── + { + "kind": "page", "name": "Note de réunion", "icon": "🗒️", + "category": "Réunion", + "description": "Ordre du jour, notes et actions — datée du jour.", + "title_template": "Réunion du {{today}} — {{title}}", + "variables": [ + {"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}, + ], + "blocks": [ + _h2("Ordre du jour"), _bullet("…"), _bullet("…"), + _h2("Notes"), _para("…"), + _h2("Actions"), _todo("Action — {{me}}"), + ], + }, + { + "kind": "page", "name": "Compte rendu client", "icon": "🤝", + "category": "Réunion", + "description": "Participants, décisions et prochaines étapes côté client.", + "title_template": "CR — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Client", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Participants"), _bullet("…"), + _h2("Points discutés"), _para("…"), + _h2("Décisions"), _bullet("…"), + _h2("Prochaines étapes"), _todo("…"), + ], + }, + { + "kind": "page", "name": "1:1", "icon": "💬", + "category": "Réunion", + "description": "Trame d'entretien individuel hebdomadaire.", + "title_template": "1:1 du {{today}} — {{title}}", + "variables": [ + {"name": "title", "label": "Participant", "type": "text", "required": False, "default": ""}, + ], + "blocks": [ + _h2("Points positifs"), _para("…"), + _h2("Difficultés"), _para("…"), + _h2("Actions"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Revue hebdo", "icon": "📄", + "category": "Personnel", + "description": "Fait, en cours, blocages de la semaine.", + "title_template": "Revue du {{today}}", + "blocks": [_h2("Fait"), _todo("…"), _h2("En cours"), _todo("…"), _h2("Blocages"), _para("…")], + }, + { + "kind": "page", "name": "Journal quotidien", "icon": "📓", + "category": "Personnel", + "description": "Page datée du jour, créée en un clic.", + "title_template": "Journal — {{today}}", + "blocks": [_h2("Aujourd'hui"), _para("…"), _h2("Demain"), _para("…")], + }, + { + "kind": "page", "name": "Journal de gratitude", "icon": "🙏", + "category": "Personnel", + "description": "Trois gratitudes et une intention du jour.", + "title_template": "Gratitude — {{today}}", + "blocks": [_h2("Je suis reconnaissant pour"), _bullet("…"), _bullet("…"), _bullet("…"), + _h2("Intention du jour"), _para("…")], + }, + { + "kind": "page", "name": "Spécification produit", "icon": "📐", + "category": "Projet", + "description": "Contexte, objectifs, périmètre et critères d'acceptation.", + "title_template": "Spéc — {{title}}", + "variables": [{"name": "title", "label": "Fonctionnalité", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contexte"), _para("…"), + _h2("Objectifs"), _bullet("…"), + _h2("Hors périmètre"), _para("…"), + _h2("Critères d'acceptation"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Plan de projet", "icon": "🗺️", + "category": "Projet", + "description": "Objectifs, jalons, risques et équipe.", + "title_template": "Plan — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectif"), _para("…"), + _h2("Jalons"), _todo("M1"), _todo("M2"), + _h2("Risques"), _bullet("…"), + _h2("Équipe"), _para("…"), + ], + }, + { + "kind": "page", "name": "Rétrospective", "icon": "🔁", + "category": "Équipe", + "description": "Ce qui a marché, à améliorer, actions.", + "title_template": "Rétro — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Période / équipe", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("👍 Ce qui a bien marché"), _bullet("…"), + _h2("👎 À améliorer"), _bullet("…"), + _h2("🎯 Actions"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Post-mortem incident", "icon": "🚨", + "category": "Équipe", + "description": "Chronologie, impact, cause racine, actions correctives.", + "title_template": "Post-mortem — {{title}}", + "variables": [{"name": "title", "label": "Incident", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Résumé"), _para("…"), + _h2("Impact"), _para("…"), + _h2("Chronologie"), _num("…"), + _h2("Cause racine"), _para("…"), + _h2("Actions correctives"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Objectifs OKR", "icon": "🎯", + "category": "Projet", + "description": "Objectifs et résultats clés du trimestre.", + "title_template": "OKR — {{title}}", + "variables": [{"name": "title", "label": "Trimestre / équipe", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectif 1"), _para("…"), + _h3("Résultats clés"), _bullet("KR1 — 0%"), _bullet("KR2 — 0%"), + _h2("Objectif 2"), _para("…"), + ], + }, + { + "kind": "page", "name": "Décision (ADR)", "icon": "🏛️", + "category": "Projet", + "description": "Contexte, décision, conséquences — mini ADR.", + "title_template": "ADR — {{title}}", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [_h2("Contexte"), _para("…"), _h2("Décision"), _para("…"), + _h2("Alternatives"), _bullet("…"), _h2("Conséquences"), _para("…")], + }, + { + "kind": "page", "name": "Procédure (SOP)", "icon": "📋", + "category": "Opérations", + "description": "Marche à suivre pas à pas pour une tâche récurrente.", + "title_template": "SOP — {{title}}", + "variables": [{"name": "title", "label": "Tâche", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectif"), _para("…"), + _h2("Prérequis"), _bullet("…"), + _h2("Étapes"), _num("…"), _num("…"), + _h2("Vérification"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Onboarding nouvel arrivant", "icon": "🚀", + "category": "Équipe", + "description": "Checklist des premiers jours d'un nouveau membre.", + "title_template": "Onboarding — {{title}}", + "variables": [{"name": "title", "label": "Prénom", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Jour 1"), _todo("Accès et comptes"), _todo("Rencontre de l'équipe"), + _h2("Semaine 1"), _todo("Lire la doc"), _todo("Premier ticket"), + _h2("30 jours"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Notes de cours", "icon": "🎓", + "category": "Personnel", + "description": "Trame de prise de notes structurée.", + "title_template": "Cours — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Idées clés"), _bullet("…"), + _h2("Détails"), _para("…"), + _h2("Questions"), _bullet("…"), + _h2("À retenir"), _callout("…", "📌"), + ], + }, + { + "kind": "page", "name": "Fiche de lecture", "icon": "📚", + "category": "Personnel", + "description": "Résumé, citations et notes d'un livre.", + "title_template": "Lecture — {{title}}", + "variables": [{"name": "title", "label": "Titre du livre", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Résumé"), _para("…"), + _h2("Citations"), _quote("…"), + _h2("Mes notes"), _para("…"), + ], + }, + { + "kind": "page", "name": "Recette de cuisine", "icon": "🍳", + "category": "Personnel", + "description": "Ingrédients, étapes et temps de préparation.", + "title_template": "Recette — {{title}}", + "variables": [{"name": "title", "label": "Plat", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Ingrédients"), _bullet("…"), + _h2("Préparation"), _num("…"), _num("…"), + _h2("Astuces"), _para("…"), + ], + }, + { + "kind": "page", "name": "Itinéraire de voyage", "icon": "✈️", + "category": "Personnel", + "description": "Programme jour par jour, réservations et budget.", + "title_template": "Voyage — {{title}}", + "variables": [{"name": "title", "label": "Destination", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Réservations"), _todo("Transport"), _todo("Hébergement"), + _h2("Programme"), _h3("Jour 1"), _bullet("…"), + _h2("Budget"), _para("…"), + ], + }, + { + "kind": "page", "name": "Fiche prospect", "icon": "🧭", + "category": "Vente", + "description": "Qualification, besoins, objections et prochain contact.", + "title_template": "Prospect — {{title}}", + "variables": [{"name": "title", "label": "Société", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contacts"), _bullet("…"), + _h2("Besoins"), _para("…"), + _h2("Objections"), _bullet("…"), + _h2("Prochaine action"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Communiqué de projet", "icon": "📣", + "category": "Projet", + "description": "Annonce d'avancement à partager avec les parties prenantes.", + "title_template": "Communiqué — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _callout("Statut : 🟢 On track", "📣"), + _h2("Avancement"), _para("…"), + _h2("Points de blocage"), _para("…"), + _h2("Prochaines étapes"), _bullet("…"), + ], + }, + # ── Blocs ────────────────────────────────────────────────────────────── + { + "kind": "blocks", "name": "Sommaire + actions", "icon": "▦", + "category": "Blocs", + "description": "Un sommaire et une liste d'actions à insérer au curseur.", + "blocks": [_h2("Sommaire"), _para("…"), _h2("Actions"), _todo("…")], + }, + { + "kind": "blocks", "name": "Questions de réunion", "icon": "❓", + "category": "Blocs", + "description": "Liste de questions à aborder en réunion.", + "blocks": [_h3("Questions"), _bullet("…"), _bullet("…"), _bullet("…")], + }, + { + "kind": "blocks", "name": "Matrice d'Eisenhower", "icon": "🧮", + "category": "Blocs", + "description": "Priorisation urgent/important.", + "blocks": [ + _h3("1 · Urgent & important"), _todo("…"), + _h3("2 · Important, pas urgent"), _todo("…"), + _h3("3 · Urgent, pas important"), _todo("…"), + _h3("4 · Ni urgent ni important"), _bullet("…"), + ], + }, + { + "kind": "blocks", "name": "FAQ", "icon": "💬", + "category": "Blocs", + "description": "Questions fréquentes avec réponses repliables.", + "blocks": [_h3("Questions fréquentes"), + _toggle("Question 1 ?"), _toggle("Question 2 ?"), _toggle("Question 3 ?")], + }, + { + "kind": "blocks", "name": "Citation + source", "icon": "❝", + "category": "Blocs", + "description": "Une citation mise en avant avec sa source.", + "blocks": [_quote("…"), _para("— Source")], + }, + { + "kind": "blocks", "name": "Checklist de démarrage", "icon": "✅", + "category": "Blocs", + "description": "Quelques cases à cocher pour lancer une activité.", + "blocks": [_h3("Checklist de démarrage"), _todo("…"), _todo("…"), _todo("…"), _todo("…")], + }, + # ── Bases de données ─────────────────────────────────────────────────── + _db_preset("Tâches", "✅", "Base", "Liste de tâches avec statut, priorité et échéance.", [ + {"name": "Tâche", "type": "title"}, + _status("Statut", [("À faire", "gray"), ("En cours", "blue"), ("Terminé", "green")]), + _select("Priorité", [("Basse", "gray"), ("Moyenne", "yellow"), ("Haute", "red")]), + {"name": "Échéance", "type": "date"}, + {"name": "Assigné à", "type": "person"}, + ]), + _db_preset("Gestion de projet", "🚀", "Base", "Suivi de projets avec avancement et échéances.", [ + {"name": "Projet", "type": "title"}, + _status("Statut", [("Pas commencé", "gray"), ("En cours", "blue"), ("Terminé", "green")]), + _select("Priorité", [("P1", "red"), ("P2", "orange"), ("P3", "gray")]), + {"name": "Échéance", "type": "date"}, + {"name": "Responsable", "type": "person"}, + {"name": "Étiquettes", "type": "multi_select"}, + ]), + _db_preset("Suivi de bugs", "🐞", "Base", "Rapport et suivi des anomalies.", [ + {"name": "Titre", "type": "title"}, + _select("Sévérité", [("Bloquante", "red"), ("Majeure", "orange"), ("Mineure", "gray")]), + _status("Statut", [("Nouveau", "gray"), ("En cours", "blue"), ("Résolu", "green")]), + {"name": "Version", "type": "text"}, + {"name": "Reproductible", "type": "checkbox"}, + ]), + _db_preset("Contenu éditorial", "📝", "Base", "Planification des articles et publications.", [ + {"name": "Titre", "type": "title"}, + _select("Type", [("Article", "blue"), ("Vidéo", "orange"), ("Newsletter", "purple")]), + _status("Statut", [("Idée", "gray"), ("Brouillon", "yellow"), ("Publié", "green")]), + {"name": "Date de publication", "type": "date"}, + {"name": "Auteur", "type": "person"}, + ]), + _db_preset("Habitudes", "🔁", "Base", "Suivi quotidien d'habitudes.", [ + {"name": "Habitude", "type": "title"}, + _select("Fréquence", [("Quotidien", "green"), ("Hebdo", "blue"), ("Mensuel", "gray")]), + {"name": "Fait aujourd'hui", "type": "checkbox"}, + {"name": "Série", "type": "number"}, + ]), + _db_preset("Dépenses", "💳", "Base", "Suivi des dépenses par catégorie.", [ + {"name": "Libellé", "type": "title"}, + {"name": "Montant", "type": "number"}, + _select("Catégorie", [("Alimentation", "green"), ("Transport", "blue"), + ("Logement", "orange"), ("Loisirs", "purple"), ("Autre", "gray")]), + {"name": "Date", "type": "date"}, + ]), + _db_preset("Inventaire", "📦", "Base", "Stock d'objets ou de produits.", [ + {"name": "Article", "type": "title"}, + {"name": "Quantité", "type": "number"}, + {"name": "Prix", "type": "number"}, + {"name": "Fournisseur", "type": "text"}, + ]), + _db_preset("Bibliothèque de liens", "🔗", "Base", "Articles, vidéos et ressources à lire.", [ + {"name": "Titre", "type": "title"}, + {"name": "URL", "type": "url"}, + _status("Statut", [("À lire", "gray"), ("En cours", "blue"), ("Lu", "green")]), + {"name": "Étiquettes", "type": "multi_select"}, + ]), + _db_preset("Objectifs", "🎯", "Base", "Objectifs avec progression et échéance.", [ + {"name": "Objectif", "type": "title"}, + _status("Statut", [("À définir", "gray"), ("En cours", "blue"), ("Atteint", "green")]), + {"name": "Progression", "type": "number"}, + {"name": "Échéance", "type": "date"}, + ]), + _db_preset("Annuaire d'équipe", "👥", "Base", "Contacts et rôles de l'équipe.", [ + {"name": "Nom", "type": "title"}, + {"name": "Email", "type": "email"}, + {"name": "Téléphone", "type": "phone"}, + {"name": "Rôle", "type": "text"}, + {"name": "Équipe", "type": "select"}, + ]), + # ── Lignes (templates de ligne — nécessitent la base cible) ───────────── + { + "kind": "row", "name": "Tâche", "icon": "✅", "category": "Lignes", + "description": "Ligne de tâche pré-remplie (base « Tâches »).", + "manifest": {"target_collection_name": "Tâches", + "property_defaults": {"Statut": "À faire", "Priorité": "Moyenne"}}, + "title_template": "{{title}}", + "blocks": [_h3("Description"), _para("…"), _h3("Sous-tâches"), _todo("…")], + }, + { + "kind": "row", "name": "Bug", "icon": "🐞", "category": "Lignes", + "description": "Ligne de bug pré-remplie (base « Suivi de bugs »).", + "manifest": {"target_collection_name": "Suivi de bugs", + "property_defaults": {"Statut": "Nouveau", "Sévérité": "Mineure"}}, + "title_template": "Bug — {{title}}", + "blocks": [_h2("Reproduction"), _num("…"), _h2("Attendu"), _para("…"), + _h2("Observé"), _para("…")], + }, + { + "kind": "row", "name": "Article", "icon": "📝", "category": "Lignes", + "description": "Ligne d'article pré-remplie (base « Contenu éditorial »).", + "manifest": {"target_collection_name": "Contenu éditorial", + "property_defaults": {"Statut": "Idée", "Type": "Article"}}, + "title_template": "{{title}}", + "blocks": [_h2("Angle"), _para("…"), _h2("Plan"), _bullet("…"), _h2("Brouillon"), _para("…")], + }, + { + "kind": "row", "name": "Dépense", "icon": "💳", "category": "Lignes", + "description": "Ligne de dépense pré-remplie (base « Dépenses »).", + "manifest": {"target_collection_name": "Dépenses", + "property_defaults": {"Catégorie": "Autre"}}, + "title_template": "{{title}}", + "blocks": [], + }, + { + "kind": "row", "name": "Habitude", "icon": "🔁", "category": "Lignes", + "description": "Ligne d'habitude pré-remplie (base « Habitudes »).", + "manifest": {"target_collection_name": "Habitudes", + "property_defaults": {"Fréquence": "Quotidien"}}, + "title_template": "{{title}}", + "blocks": [_para("Pourquoi cette habitude ?"), _todo("Déclencheur"), _todo("Récompense")], + }, + + # ── Pages — Productivité & réunions ──────────────────────────────────── + { + "kind": "page", "name": "Semainier", "icon": "🗓️", "category": "Productivité", + "description": "Planifier la semaine : priorités, rendez-vous, tâches du jour.", + "title_template": "Semaine du {{today}}", + "blocks": [ + _h2("Objectifs de la semaine"), _todo("…"), _todo("…"), _todo("…"), + _h2("Rendez-vous"), _bullet("…"), + _h3("Lundi"), _todo(""), _h3("Mardi"), _todo(""), _h3("Mercredi"), _todo(""), + _h3("Jeudi"), _todo(""), _h3("Vendredi"), _todo(""), + _h2("En attente / à déléguer"), _bullet("…"), + ], + }, + { + "kind": "page", "name": "Daily standup", "icon": "☀️", "category": "Réunion", + "description": "Point quotidien asynchrone : hier, aujourd'hui, blocages.", + "title_template": "Standup — {{today}}", + "blocks": [ + _h3("Hier"), _bullet("…"), + _h3("Aujourd'hui"), _todo("…"), + _h3("Blocages"), _callout("…", "🚧"), + ], + }, + { + "kind": "page", "name": "Agenda de réunion", "icon": "📋", "category": "Réunion", + "description": "Ordre du jour minuté, décisions et actions.", + "title_template": "Agenda — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Participants"), _bullet("…"), + _h2("Ordre du jour"), _num("Point 1 — 10 min"), _num("Point 2 — 10 min"), + _h2("Décisions"), _bullet("…"), + _h2("Actions"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Compte rendu de sprint", "icon": "🏁", "category": "Réunion", + "description": "Sprint review : démo, vélocité, engagements du prochain sprint.", + "title_template": "Sprint review — {{title}}", + "variables": [{"name": "title", "label": "Sprint", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectif du sprint"), _para("…"), + _h2("Livré"), _todo("…"), + _h2("Non livré"), _bullet("…"), + _h2("Vélocité"), _para("…"), + _h2("Engagements prochain sprint"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Kickoff de projet", "icon": "🚦", "category": "Projet", + "description": "Lancement : contexte, objectifs, périmètre, rôles et jalons.", + "title_template": "Kickoff — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contexte"), _para("…"), + _h2("Objectifs"), _bullet("…"), + _h2("Périmètre"), _para("…"), + _h2("Rôles"), _bullet("…"), + _h2("Jalons"), _todo("M1"), _todo("M2"), + ], + }, + { + "kind": "page", "name": "Registre des risques", "icon": "⚠️", "category": "Projet", + "description": "Risques, probabilité, impact, mitigation et responsable.", + "title_template": "Risques — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Risque 1"), _bullet("Probabilité : —"), _bullet("Impact : —"), + _bullet("Mitigation : —"), _bullet("Responsable : {{me}}"), + _h2("Risque 2"), _bullet("Probabilité : —"), _bullet("Impact : —"), + ], + }, + { + "kind": "page", "name": "Leçons apprises", "icon": "🧠", "category": "Projet", + "description": "Bilan de fin de projet : réussites, échecs, à reproduire.", + "title_template": "Leçons apprises — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Ce qui a bien marché"), _bullet("…"), + _h2("Ce qui a moins bien marché"), _bullet("…"), + _h2("À reproduire"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Note de cadrage", "icon": "🧭", "category": "Projet", + "description": "Périmètre, contraintes, hypothèses et livrables attendus.", + "title_template": "Cadrage — {{title}}", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Périmètre"), _para("…"), + _h2("Contraintes"), _bullet("…"), + _h2("Hypothèses"), _bullet("…"), + _h2("Livrables"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Plan de communication", "icon": "📡", "category": "Projet", + "description": "Qui communique quoi, à qui, par quel canal et à quelle fréquence.", + "title_template": "Communication — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Audiences"), _bullet("…"), + _h2("Messages clés"), _bullet("…"), + _h2("Canaux & fréquence"), _bullet("Hebdo — réunion"), _bullet("Mensuel — note"), + _h2("Responsables"), _para("…"), + ], + }, + # ── Pages — Travail (dev, produit, vente, marketing, RH, ops) ─────────── + { + "kind": "page", "name": "Runbook d'incident", "icon": "🚑", "category": "Opérations", + "description": "Procédure pas à pas de gestion d'incident.", + "title_template": "Runbook — {{title}}", + "variables": [{"name": "title", "label": "Incident / service", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Détection"), _num("Alerte reçue"), _num("Confirmer l'impact"), + _h2("Mitigation"), _num("…"), _num("…"), + _h2("Communication"), _bullet("Statut à l'équipe"), + _h2("Post-mortem"), _todo("Rédiger le post-mortem"), + ], + }, + { + "kind": "page", "name": "Checklist de code review", "icon": "👀", "category": "Dev", + "description": "Points à vérifier avant de valider une revue de code.", + "title_template": "Review — {{title}}", + "variables": [{"name": "title", "label": "PR / branche", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Fonctionnel"), _todo("Le comportement correspond au besoin"), _todo("Cas limites gérés"), + _h2("Qualité"), _todo("Lisibilité"), _todo("Tests"), _todo("Pas de secret / debug oublié"), + _h2("Performance & sécurité"), _todo("Requêtes raisonnables"), _todo("Entrées validées"), + ], + }, + { + "kind": "page", "name": "Documentation d'API", "icon": "🔌", "category": "Dev", + "description": "Description d'un endpoint : route, params, réponses, erreurs.", + "title_template": "API — {{title}}", + "variables": [{"name": "title", "label": "Endpoint", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Endpoint"), _para("GET /api/…"), + _h2("Paramètres"), _bullet("…"), + _h2("Réponse"), _callout("200 OK — …", "✅"), + _h2("Erreurs"), _bullet("400 — requête invalide"), _bullet("404 — introuvable"), + _h2("Exemple"), _para("…"), + ], + }, + { + "kind": "page", "name": "Doc d'architecture", "icon": "🏗️", "category": "Dev", + "description": "Vue d'ensemble : composants, flux, décisions structurantes.", + "title_template": "Architecture — {{title}}", + "variables": [{"name": "title", "label": "Système", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contexte"), _para("…"), + _h2("Composants"), _bullet("…"), + _h2("Flux de données"), _para("…"), + _h2("Décisions clés"), _bullet("…"), + ], + }, + { + "kind": "page", "name": "Plan de test / QA", "icon": "🧪", "category": "Dev", + "description": "Périmètre de test, cas, environnements et critères de sortie.", + "title_template": "Plan de test — {{title}}", + "variables": [{"name": "title", "label": "Version", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Périmètre"), _para("…"), + _h2("Cas de test"), _todo("…"), _todo("…"), + _h2("Environnements"), _bullet("…"), + _h2("Critères de sortie"), _callout("0 bug bloquant", "🚦"), + ], + }, + { + "kind": "page", "name": "Changelog", "icon": "📜", "category": "Produit", + "description": "Notes de version : nouveautés, corrections, ruptures.", + "title_template": "Changelog — {{today}}", + "blocks": [ + _h2("Nouveautés"), _bullet("…"), + _h2("Améliorations"), _bullet("…"), + _h2("Corrections"), _bullet("…"), + _h2("Ruptures"), _callout("Aucune", "⚠️"), + ], + }, + { + "kind": "page", "name": "User persona", "icon": "🧑‍💼", "category": "Produit", + "description": "Profil utilisateur : objectifs, frustrations, contexte.", + "title_template": "Persona — {{title}}", + "variables": [{"name": "title", "label": "Nom du persona", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Profil"), _bullet("Âge / rôle : —"), _bullet("Contexte : —"), + _h2("Objectifs"), _bullet("…"), + _h2("Frustrations"), _bullet("…"), + _h2("Citations"), _quote("…"), + ], + }, + { + "kind": "page", "name": "Guide d'entretien utilisateur", "icon": "🎤", "category": "Produit", + "description": "Trame d'entretien de recherche utilisateur.", + "title_template": "Entretien — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Participant", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contexte"), _bullet("Objectif — …"), + _h2("Questions"), _num("…"), _num("…"), _num("…"), + _h2("Notes"), _para("…"), + _h2("Synthèse"), _para("…"), + ], + }, + { + "kind": "page", "name": "Roadmap produit", "icon": "🛣️", "category": "Produit", + "description": "Vision et jalons par trimestre.", + "title_template": "Roadmap — {{title}}", + "variables": [{"name": "title", "label": "Produit", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Vision"), _para("…"), + _h3("Trimestre en cours"), _todo("…"), + _h3("Prochain trimestre"), _todo("…"), + _h3("Plus tard"), _bullet("…"), + ], + }, + { + "kind": "page", "name": "Business Model Canvas", "icon": "🧩", "category": "Stratégie", + "description": "Les 9 blocs du business model sur une page.", + "title_template": "BMC — {{title}}", + "variables": [{"name": "title", "label": "Projet / entreprise", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h3("Segments de clientèle"), _bullet("…"), + _h3("Proposition de valeur"), _bullet("…"), + _h3("Canaux"), _bullet("…"), + _h3("Relations clients"), _bullet("…"), + _h3("Sources de revenus"), _bullet("…"), + _h3("Ressources clés"), _bullet("…"), + _h3("Activités clés"), _bullet("…"), + _h3("Partenaires clés"), _bullet("…"), + _h3("Structure de coûts"), _bullet("…"), + ], + }, + { + "kind": "page", "name": "Analyse SWOT", "icon": "📊", "category": "Stratégie", + "description": "Forces, faiblesses, opportunités, menaces.", + "title_template": "SWOT — {{title}}", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h3("Forces (Strengths)"), _bullet("…"), + _h3("Faiblesses (Weaknesses)"), _bullet("…"), + _h3("Opportunités (Opportunities)"), _bullet("…"), + _h3("Menaces (Threats)"), _bullet("…"), + ], + }, + { + "kind": "page", "name": "Matrice RACI", "icon": "🔲", "category": "Projet", + "description": "Qui est Responsable, Approbateur, Consulté, Informé.", + "title_template": "RACI — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Légende"), _bullet("R = Réalise · A = Approuve · C = Consulté · I = Informé"), + _h2("Activités"), _bullet("Activité 1 — R:… A:… C:… I:…"), _bullet("Activité 2 — R:… A:… C:… I:…"), + ], + }, + { + "kind": "page", "name": "Proposition commerciale", "icon": "🤝", "category": "Vente", + "description": "Besoin, solution, tarif et planning pour un client.", + "title_template": "Proposition — {{title}}", + "variables": [{"name": "title", "label": "Client", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Contexte & besoin"), _para("…"), + _h2("Solution proposée"), _bullet("…"), + _h2("Tarif"), _para("…"), + _h2("Planning"), _bullet("…"), + _h2("Prochaines étapes"), _todo("Signature / bon pour accord"), + ], + }, + { + "kind": "page", "name": "Facture", "icon": "🧾", "category": "Vente", + "description": "Modèle de facture simple (client, lignes, total, échéance).", + "title_template": "Facture — {{title}} ({{today}})", + "variables": [{"name": "title", "label": "Client", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Émetteur"), _para("Nom\nAdresse\nSIRET"), + _h2("Client"), _para("…"), + _h2("Lignes"), _bullet("Prestation — Qté — Prix"), + _h2("Total"), _callout("Total : … €", "💰"), + _h2("Échéance"), _para("Paiement à 30 jours"), + ], + }, + { + "kind": "page", "name": "Plan marketing", "icon": "📣", "category": "Marketing", + "description": "Objectifs, cibles, canaux, budget et calendrier.", + "title_template": "Plan marketing — {{title}}", + "variables": [{"name": "title", "label": "Produit / campagne", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectifs"), _bullet("…"), + _h2("Cibles"), _bullet("…"), + _h2("Canaux"), _bullet("…"), + _h2("Budget"), _para("…"), + _h2("Calendrier"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Brief créatif", "icon": "🎨", "category": "Marketing", + "description": "Cadre d'un projet créatif : objectif, ton, contraintes, livrables.", + "title_template": "Brief — {{title}}", + "variables": [{"name": "title", "label": "Projet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objectif"), _para("…"), + _h2("Public cible"), _para("…"), + _h2("Ton & style"), _bullet("…"), + _h2("Contraintes"), _bullet("…"), + _h2("Livrables"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Script d'appel", "icon": "📞", "category": "Vente", + "description": "Trame d'appel de prospection ou de découverte.", + "title_template": "Script d'appel — {{title}}", + "variables": [{"name": "title", "label": "Cible", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Ouverture"), _para("Bonjour, …"), + _h2("Qualification"), _num("Quel est votre besoin ?"), _num("Quel est votre délai ?"), + _h2("Objections fréquentes"), _bullet("Trop cher → …"), _bullet("Pas le temps → …"), + _h2("Prochaine étape"), _todo("Envoyer un récapitulatif"), + ], + }, + { + "kind": "page", "name": "Rapport de performance", "icon": "📈", "category": "Opérations", + "description": "Rapport hebdo de KPI et points saillants.", + "title_template": "Rapport — semaine du {{today}}", + "blocks": [ + _h2("Chiffres clés"), _bullet("KPI 1 : —"), _bullet("KPI 2 : —"), + _h2("Faits marquants"), _bullet("…"), + _h2("Points de vigilance"), _bullet("…"), + _h2("Actions"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Fiche de poste", "icon": "🧑‍💻", "category": "RH", + "description": "Mission, responsabilités, compétences et profil recherché.", + "title_template": "Poste — {{title}}", + "variables": [{"name": "title", "label": "Intitulé", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Mission"), _para("…"), + _h2("Responsabilités"), _bullet("…"), + _h2("Compétences requises"), _bullet("…"), + _h2("Profil"), _para("…"), + ], + }, + { + "kind": "page", "name": "Guide d'entretien d'embauche", "icon": "🎯", "category": "RH", + "description": "Grille d'évaluation et questions d'entretien.", + "title_template": "Entretien recrutement — {{title}}", + "variables": [{"name": "title", "label": "Poste", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Candidat"), _bullet("Nom : —"), _bullet("Poste : —"), + _h2("Questions techniques"), _num("…"), _num("…"), + _h2("Évaluation"), _bullet("Technique : /5"), _bullet("Culture : /5"), + _h2("Décision"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Politique interne", "icon": "📕", "category": "RH", + "description": "Règles internes (congés, télétravail, notes de frais…).", + "title_template": "Politique — {{title}}", + "variables": [{"name": "title", "label": "Sujet", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Objet"), _para("…"), + _h2("Règles"), _num("…"), _num("…"), + _h2("Cas particuliers"), _bullet("…"), + _h2("Référent"), _para("{{me}}"), + ], + }, + { + "kind": "page", "name": "Guide d'installation", "icon": "🛠️", "category": "Opérations", + "description": "Installation pas à pas d'un outil ou service.", + "title_template": "Installation — {{title}}", + "variables": [{"name": "title", "label": "Outil", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Prérequis"), _bullet("…"), + _h2("Étapes"), _num("…"), _num("…"), + _h2("Vérification"), _todo("…"), + _h2("Dépannage"), _bullet("…"), + ], + }, + # ── Pages — Vie personnelle ───────────────────────────────────────────── + { + "kind": "page", "name": "Plan de repas", "icon": "🍽️", "category": "Personnel", + "description": "Menus de la semaine et liste de courses associée.", + "title_template": "Repas — semaine du {{today}}", + "blocks": [ + _h3("Lundi"), _bullet(""), _h3("Mardi"), _bullet(""), _h3("Mercredi"), _bullet(""), + _h3("Jeudi"), _bullet(""), _h3("Vendredi"), _bullet(""), _h3("Week-end"), _bullet(""), + _h2("Courses"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Liste de courses", "icon": "🛒", "category": "Personnel", + "description": "Liste de courses par rayon.", + "title_template": "Courses — {{today}}", + "blocks": [ + _h3("Fruits & légumes"), _todo(""), _h3("Épicerie"), _todo(""), + _h3("Frais"), _todo(""), _h3("Entretien"), _todo(""), + ], + }, + { + "kind": "page", "name": "Checklist de ménage", "icon": "🧽", "category": "Personnel", + "description": "Tâches d'entretien de la maison, par fréquence.", + "title_template": "Ménage — {{today}}", + "blocks": [ + _h3("Tous les jours"), _todo(""), _h3("Chaque semaine"), _todo(""), _h3("Chaque mois"), _todo(""), + ], + }, + { + "kind": "page", "name": "Suivi d'entraînement", "icon": "🏋️", "category": "Personnel", + "description": "Programme et journal d'entraînement sportif.", + "title_template": "Entraînement — semaine du {{today}}", + "blocks": [ + _h2("Objectif"), _para("…"), + _h3("Lundi"), _bullet(""), _h3("Mercredi"), _bullet(""), _h3("Vendredi"), _bullet(""), + _h2("Journal"), _para("…"), + ], + }, + { + "kind": "page", "name": "Plan de carrière", "icon": "🪜", "category": "Personnel", + "description": "Objectifs professionnels à moyen et long terme.", + "title_template": "Carrière — {{title}}", + "variables": [{"name": "title", "label": "Horizon", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h2("Où je veux être"), _para("…"), + _h2("Compétences à développer"), _bullet("…"), + _h2("Étapes"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Rétrospective personnelle", "icon": "🪞", "category": "Personnel", + "description": "Bilan personnel : continuer, arrêter, commencer.", + "title_template": "Rétro perso — {{today}}", + "blocks": [ + _h2("Continuer"), _bullet("…"), + _h2("Arrêter"), _bullet("…"), + _h2("Commencer"), _todo("…"), + ], + }, + { + "kind": "page", "name": "Checklist de valise", "icon": "🧳", "category": "Personnel", + "description": "Quoi emporter, par catégorie, pour un voyage.", + "title_template": "Valise — {{title}}", + "variables": [{"name": "title", "label": "Destination", "type": "text", "required": False, "default": ""}], + "blocks": [ + _h3("Documents"), _todo("Passeport"), _todo("Billets"), + _h3("Vêtements"), _todo(""), _h3("Toilette"), _todo(""), _h3("Électronique"), _todo(""), + ], + }, + { + "kind": "page", "name": "Boîte à idées", "icon": "💡", "category": "Personnel", + "description": "Vrac d'idées à trier plus tard.", + "title_template": "Idées — {{today}}", + "blocks": [_h2("Idées en vrac"), _bullet("…"), _bullet("…"), _h2("À explorer"), _todo("…")], + }, + # ── Blocs ────────────────────────────────────────────────────────────── + { + "kind": "blocks", "name": "Étapes numérotées", "icon": "🔢", "category": "Blocs", + "description": "Une procédure en étapes numérotées.", + "blocks": [_h3("Étapes"), _num("Étape 1"), _num("Étape 2"), _num("Étape 3")], + }, + { + "kind": "blocks", "name": "Avantages / Inconvénients", "icon": "⚖️", "category": "Blocs", + "description": "Comparaison en deux volets.", + "blocks": [_h3("✅ Avantages"), _bullet("…"), _h3("❌ Inconvénients"), _bullet("…")], + }, + { + "kind": "blocks", "name": "Mise en garde + Astuce", "icon": "⚠️", "category": "Blocs", + "description": "Un avertissement suivi d'une astuce.", + "blocks": [_callout("⚠️ Attention : …", "⚠️"), _callout("💡 Astuce : …", "💡")], + }, + { + "kind": "blocks", "name": "Définition", "icon": "📖", "category": "Blocs", + "description": "Un encadré de définition / concept clé.", + "blocks": [_callout("**Terme** — définition…", "📖")], + }, + { + "kind": "blocks", "name": "Ressources / liens", "icon": "🔗", "category": "Blocs", + "description": "Une liste de ressources et liens utiles.", + "blocks": [_h3("Ressources"), _bullet("…"), _bullet("…"), _bullet("…")], + }, + { + "kind": "blocks", "name": "Contacts clés", "icon": "📇", "category": "Blocs", + "description": "Un bloc de contacts avec coordonnées.", + "blocks": [_h3("Contacts clés"), _bullet("Nom — rôle — email"), _bullet("Nom — rôle — email")], + }, + { + "kind": "blocks", "name": "Présentation courte", "icon": "🙋", "category": "Blocs", + "description": "Une bio / présentation en quelques lignes.", + "blocks": [_h3("À propos"), _para("Nom\nRôle\nQuelques mots…"), _divider()], + }, + { + "kind": "blocks", "name": "Comparatif d'options", "icon": "🅰️", "category": "Blocs", + "description": "Comparer deux options côte à côte.", + "blocks": [_h3("Option A"), _bullet("…"), _h3("Option B"), _bullet("…"), + _h3("Recommandation"), _callout("…", "👉")], + }, + { + "kind": "blocks", "name": "Signature d'email", "icon": "✉️", "category": "Blocs", + "description": "Bloc de signature à réutiliser.", + "blocks": [_divider(), _para("Prénom Nom — Rôle\nEmail · Téléphone · Site"), _quote("« Une phrase de signature. »")], + }, + # ── Bases de données ─────────────────────────────────────────────────── + _db_preset("Pipeline de vente", "💰", "Base", "Opportunités commerciales et avancement des deals.", [ + {"name": "Opportunité", "type": "title"}, + _status("Étape", [("Nouveau", "gray"), ("Qualifié", "blue"), ("Proposition", "yellow"), ("Gagné", "green"), ("Perdu", "red")]), + {"name": "Montant", "type": "number"}, + {"name": "Contact", "type": "text"}, + {"name": "Probabilité", "type": "number"}, + {"name": "Clôture", "type": "date"}, + ]), + _db_preset("Devis / Factures", "🧾", "Base", "Suivi des devis et factures.", [ + {"name": "Numéro", "type": "title"}, + _select("Type", [("Devis", "blue"), ("Facture", "purple")]), + {"name": "Client", "type": "text"}, + {"name": "Montant", "type": "number"}, + _status("Statut", [("Brouillon", "gray"), ("Envoyé", "yellow"), ("Payé", "green")]), + {"name": "Date", "type": "date"}, + ]), + _db_preset("Réunions", "🗓️", "Base", "Journal des réunions et comptes rendus.", [ + {"name": "Réunion", "type": "title"}, + {"name": "Date", "type": "date"}, + {"name": "Participants", "type": "person"}, + _status("Statut", [("Prévue", "gray"), ("Faite", "green"), ("Annulée", "red")]), + {"name": "Compte rendu", "type": "text"}, + ]), + _db_preset("Registre des décisions", "🏛️", "Base", "Journal des décisions (ADR log).", [ + {"name": "Décision", "type": "title"}, + _status("Statut", [("Proposée", "gray"), ("Acceptée", "green"), ("Remplacée", "orange")]), + {"name": "Responsable", "type": "person"}, + {"name": "Date", "type": "date"}, + {"name": "Contexte", "type": "text"}, + ]), + _db_preset("Backlog / Idées", "💡", "Base", "Idées et demandes à prioriser.", [ + {"name": "Idée", "type": "title"}, + _status("Statut", [("Idée", "gray"), ("À évaluer", "yellow"), ("Retenue", "green")]), + _select("Catégorie", [("Produit", "blue"), ("Technique", "purple"), ("Autre", "gray")]), + {"name": "Votes", "type": "number"}, + {"name": "Description", "type": "text"}, + ]), + _db_preset("Sprints", "🏃", "Base", "Planification des sprints.", [ + {"name": "Sprint", "type": "title"}, + {"name": "Début", "type": "date"}, + {"name": "Fin", "type": "date"}, + _status("Statut", [("À venir", "gray"), ("En cours", "blue"), ("Terminé", "green")]), + {"name": "Objectif", "type": "text"}, + ]), + _db_preset("Roadmap / Fonctionnalités", "🛣️", "Base", "Fonctionnalités planifiées par version.", [ + {"name": "Fonctionnalité", "type": "title"}, + _status("Statut", [("Idée", "gray"), ("Planifiée", "blue"), ("Livrée", "green")]), + _select("Priorité", [("Haute", "red"), ("Moyenne", "yellow"), ("Basse", "gray")]), + {"name": "Version", "type": "text"}, + {"name": "Échéance", "type": "date"}, + ]), + _db_preset("Tests QA", "🧪", "Base", "Cas de test et résultats.", [ + {"name": "Cas de test", "type": "title"}, + _status("Statut", [("À faire", "gray"), ("OK", "green"), ("Échec", "red")]), + _select("Priorité", [("Haute", "red"), ("Moyenne", "yellow"), ("Basse", "gray")]), + {"name": "Étape", "type": "text"}, + _select("Résultat", [("À tester", "gray"), ("Passé", "green"), ("Échoué", "red")]), + ]), + _db_preset("Risques", "⚠️", "Base", "Registre des risques projet.", [ + {"name": "Risque", "type": "title"}, + _select("Probabilité", [("Faible", "gray"), ("Moyenne", "yellow"), ("Élevée", "red")]), + _select("Impact", [("Faible", "gray"), ("Moyen", "yellow"), ("Fort", "red")]), + _status("Statut", [("Ouvert", "red"), ("Surveillé", "yellow"), ("Clos", "green")]), + {"name": "Mitigation", "type": "text"}, + ]), + _db_preset("Candidatures", "📮", "Base", "Suivi des candidatures et entretiens.", [ + {"name": "Poste", "type": "title"}, + {"name": "Entreprise", "type": "text"}, + _status("Statut", [("À postuler", "gray"), ("Postulé", "blue"), ("Entretien", "yellow"), ("Offre", "green"), ("Refusé", "red")]), + {"name": "Date", "type": "date"}, + {"name": "Lien", "type": "url"}, + ]), + _db_preset("Formations & cours", "🎓", "Base", "Suivi des formations et cours.", [ + {"name": "Formation", "type": "title"}, + {"name": "Plateforme", "type": "text"}, + _status("Statut", [("À commencer", "gray"), ("En cours", "blue"), ("Terminée", "green")]), + {"name": "Durée (h)", "type": "number"}, + {"name": "Date", "type": "date"}, + ]), + _db_preset("Compétences", "🧠", "Base", "Matrice de compétences et niveaux.", [ + {"name": "Compétence", "type": "title"}, + _select("Niveau", [("Débutant", "gray"), ("Intermédiaire", "blue"), ("Avancé", "green"), ("Expert", "purple")]), + _select("Catégorie", [("Technique", "blue"), ("Produit", "purple"), ("Humain", "green")]), + {"name": "Dernière pratique", "type": "date"}, + ]), + _db_preset("Abonnements", "🔁", "Base", "Tracker d'abonnements et renouvellements.", [ + {"name": "Abonnement", "type": "title"}, + {"name": "Coût", "type": "number"}, + _select("Fréquence", [("Mensuel", "blue"), ("Annuel", "purple"), ("Hebdo", "gray")]), + {"name": "Renouvellement", "type": "date"}, + _status("Statut", [("Actif", "green"), ("Résilié", "red")]), + ]), + _db_preset("Budget / Revenus", "💰", "Base", "Revenus et dépenses, toutes catégories.", [ + {"name": "Libellé", "type": "title"}, + {"name": "Montant", "type": "number"}, + _select("Type", [("Revenu", "green"), ("Dépense", "red")]), + _select("Catégorie", [("Salaire", "green"), ("Fixe", "orange"), ("Variable", "blue"), ("Autre", "gray")]), + {"name": "Date", "type": "date"}, + ]), + _db_preset("Dépenses partagées", "🤝", "Base", "Partage de frais et remboursements.", [ + {"name": "Objet", "type": "title"}, + {"name": "Montant", "type": "number"}, + {"name": "Payé par", "type": "person"}, + {"name": "Remboursé", "type": "checkbox"}, + {"name": "Date", "type": "date"}, + ]), + _db_preset("Campagnes marketing", "📣", "Base", "Suivi des campagnes marketing.", [ + {"name": "Campagne", "type": "title"}, + _select("Canal", [("Email", "blue"), ("Réseaux", "purple"), ("Ads", "orange"), ("SEO", "green")]), + _status("Statut", [("Idée", "gray"), ("En cours", "blue"), ("Terminée", "green")]), + {"name": "Budget", "type": "number"}, + {"name": "Dates", "type": "date"}, + ]), + _db_preset("Publications réseaux sociaux", "📱", "Base", "Planning des publications.", [ + {"name": "Titre", "type": "title"}, + _select("Plateforme", [("LinkedIn", "blue"), ("X", "gray"), ("Instagram", "purple"), ("YouTube", "red"), ("TikTok", "orange")]), + _status("Statut", [("Idée", "gray"), ("Rédigé", "yellow"), ("Publié", "green")]), + {"name": "Date", "type": "date"}, + {"name": "URL", "type": "url"}, + ]), + _db_preset("KPI / Métriques", "📈", "Base", "Suivi des indicateurs clés.", [ + {"name": "Indicateur", "type": "title"}, + {"name": "Valeur", "type": "number"}, + {"name": "Cible", "type": "number"}, + {"name": "Période", "type": "date"}, + _status("Statut", [("Atteint", "green"), ("En cours", "yellow"), ("À risque", "red")]), + ]), + _db_preset("Événements", "🎉", "Base", "Organisation d'événements.", [ + {"name": "Événement", "type": "title"}, + {"name": "Date", "type": "date"}, + {"name": "Lieu", "type": "text"}, + _status("Statut", [("Prévu", "gray"), ("Confirmé", "blue"), ("Terminé", "green")]), + {"name": "Responsable", "type": "person"}, + ]), + _db_preset("Recettes", "🍳", "Base", "Carnet de recettes.", [ + {"name": "Recette", "type": "title"}, + _select("Catégorie", [("Entrée", "green"), ("Plat", "orange"), ("Dessert", "purple"), ("Boisson", "blue")]), + {"name": "Temps (min)", "type": "number"}, + {"name": "Personnes", "type": "number"}, + {"name": "URL", "type": "url"}, + ]), + _db_preset("Films & séries", "🎬", "Base", "Wishlist et suivi de visionnage.", [ + {"name": "Titre", "type": "title"}, + _select("Type", [("Film", "blue"), ("Série", "purple"), ("Doc", "green")]), + _status("Statut", [("À voir", "gray"), ("En cours", "yellow"), ("Vu", "green")]), + {"name": "Note", "type": "number"}, + ]), + _db_preset("Voyages", "✈️", "Base", "Projets de voyage et itinéraires.", [ + {"name": "Voyage", "type": "title"}, + {"name": "Destination", "type": "text"}, + {"name": "Dates", "type": "date"}, + _status("Statut", [("Idée", "gray"), ("Réservé", "blue"), ("Fait", "green")]), + {"name": "Budget", "type": "number"}, + ]), + _db_preset("Fournisseurs", "🏭", "Base", "Carnet de fournisseurs et contacts.", [ + {"name": "Fournisseur", "type": "title"}, + {"name": "Contact", "type": "text"}, + {"name": "Email", "type": "email"}, + {"name": "Téléphone", "type": "phone"}, + _select("Catégorie", [("Matières", "orange"), ("Services", "blue"), ("Logistique", "purple"), ("Autre", "gray")]), + ]), + _db_preset("Matériel / Équipement", "🖥️", "Base", "Inventaire du matériel et de l'équipement.", [ + {"name": "Équipement", "type": "title"}, + {"name": "Quantité", "type": "number"}, + _status("État", [("Neuf", "green"), ("Bon", "blue"), ("Usé", "yellow"), ("HS", "red")]), + {"name": "Prix", "type": "number"}, + {"name": "Date d'achat", "type": "date"}, + ]), + _db_preset("Rendez-vous santé", "🩺", "Base", "Suivi des rendez-vous médicaux.", [ + {"name": "Sujet", "type": "title"}, + {"name": "Date", "type": "date"}, + {"name": "Praticien", "type": "text"}, + {"name": "Lieu", "type": "text"}, + {"name": "Notes", "type": "text"}, + ]), + _db_preset("Entraînements", "🏋️", "Base", "Journal des séances de sport.", [ + {"name": "Séance", "type": "title"}, + {"name": "Date", "type": "date"}, + {"name": "Durée (min)", "type": "number"}, + _select("Type", [("Cardio", "blue"), ("Muscu", "orange"), ("Souplesse", "green"), ("Sport", "purple")]), + _select("Intensité", [("Faible", "gray"), ("Moyenne", "yellow"), ("Élevée", "red")]), + ]), + # ── Lignes (templates de ligne — nécessitent la base cible) ───────────── + { + "kind": "row", "name": "Client", "icon": "👤", "category": "Lignes", + "description": "Ligne de contact client (base « CRM / Contacts »).", + "manifest": {"target_collection_name": "CRM / Contacts"}, + "title_template": "{{title}}", + "blocks": [_h3("Coordonnées"), _para("Email / téléphone"), _h3("Contexte"), _para("…"), + _h3("Historique"), _bullet("…")], + }, + { + "kind": "row", "name": "Opportunité", "icon": "💰", "category": "Lignes", + "description": "Ligne d'opportunité commerciale (base « Pipeline de vente »).", + "manifest": {"target_collection_name": "Pipeline de vente", + "property_defaults": {"Étape": "Nouveau", "Probabilité": 20}}, + "title_template": "{{title}}", + "blocks": [_h2("Besoin"), _para("…"), _h2("Décideur"), _bullet("…"), + _h2("Prochaines étapes"), _todo("…")], + }, + { + "kind": "row", "name": "Devis", "icon": "🧾", "category": "Lignes", + "description": "Ligne de devis (base « Devis / Factures »).", + "manifest": {"target_collection_name": "Devis / Factures", + "property_defaults": {"Type": "Devis", "Statut": "Brouillon"}}, + "title_template": "Devis — {{title}}", + "blocks": [_h2("Prestations"), _bullet("…"), _h2("Total"), _para("…"), + _h2("Conditions"), _para("Validité 30 jours")], + }, + { + "kind": "row", "name": "Facture", "icon": "🧾", "category": "Lignes", + "description": "Ligne de facture (base « Devis / Factures »).", + "manifest": {"target_collection_name": "Devis / Factures", + "property_defaults": {"Type": "Facture", "Statut": "Envoyé"}}, + "title_template": "Facture — {{title}}", + "blocks": [_h2("Détail"), _bullet("…"), _h2("Total"), _para("…"), + _h2("Échéance"), _para("Paiement à 30 jours")], + }, + { + "kind": "row", "name": "Réunion", "icon": "🗓️", "category": "Lignes", + "description": "Ligne de réunion avec compte rendu (base « Réunions »).", + "manifest": {"target_collection_name": "Réunions", + "property_defaults": {"Statut": "Prévue"}}, + "title_template": "{{title}}", + "blocks": [_h2("Ordre du jour"), _bullet("…"), _h2("Décisions"), _bullet("…"), + _h2("Actions"), _todo("…")], + }, + { + "kind": "row", "name": "Décision", "icon": "🏛️", "category": "Lignes", + "description": "Ligne de décision (base « Registre des décisions »).", + "manifest": {"target_collection_name": "Registre des décisions", + "property_defaults": {"Statut": "Acceptée"}}, + "title_template": "ADR — {{title}}", + "blocks": [_h2("Contexte"), _para("…"), _h2("Décision"), _para("…"), + _h2("Conséquences"), _para("…")], + }, + { + "kind": "row", "name": "Risque", "icon": "⚠️", "category": "Lignes", + "description": "Ligne de risque (base « Risques »).", + "manifest": {"target_collection_name": "Risques", + "property_defaults": {"Probabilité": "Moyenne", "Impact": "Moyen", "Statut": "Ouvert"}}, + "title_template": "{{title}}", + "blocks": [_h2("Description"), _para("…"), _h2("Mitigation"), _para("…")], + }, + { + "kind": "row", "name": "Idée", "icon": "💡", "category": "Lignes", + "description": "Ligne d'idée / backlog (base « Backlog / Idées »).", + "manifest": {"target_collection_name": "Backlog / Idées", + "property_defaults": {"Statut": "Idée"}}, + "title_template": "{{title}}", + "blocks": [_h2("Problème"), _para("…"), _h2("Proposition"), _para("…"), + _h2("Bénéfices"), _bullet("…")], + }, + { + "kind": "row", "name": "Fonctionnalité", "icon": "🛣️", "category": "Lignes", + "description": "Ligne de fonctionnalité roadmap (base « Roadmap / Fonctionnalités »).", + "manifest": {"target_collection_name": "Roadmap / Fonctionnalités", + "property_defaults": {"Statut": "Idée", "Priorité": "Moyenne"}}, + "title_template": "{{title}}", + "blocks": [_h2("Objectif"), _para("…"), _h2("Critères d'acceptation"), _todo("…")], + }, + { + "kind": "row", "name": "Cas de test", "icon": "🧪", "category": "Lignes", + "description": "Ligne de cas de test QA (base « Tests QA »).", + "manifest": {"target_collection_name": "Tests QA", + "property_defaults": {"Statut": "À faire", "Résultat": "À tester"}}, + "title_template": "Test — {{title}}", + "blocks": [_h2("Préconditions"), _bullet("…"), _h2("Étapes"), _num("…"), + _h2("Résultat attendu"), _para("…")], + }, + { + "kind": "row", "name": "Vidéo", "icon": "📱", "category": "Lignes", + "description": "Ligne de publication vidéo (base « Publications réseaux sociaux »).", + "manifest": {"target_collection_name": "Publications réseaux sociaux", + "property_defaults": {"Plateforme": "YouTube", "Statut": "Idée"}}, + "title_template": "{{title}}", + "blocks": [_h2("Angle"), _para("…"), _h2("Plan"), _bullet("…"), _h2("Script"), _para("…")], + }, + { + "kind": "row", "name": "Événement", "icon": "🎉", "category": "Lignes", + "description": "Ligne d'événement (base « Événements »).", + "manifest": {"target_collection_name": "Événements", "property_defaults": {"Statut": "Prévu"}}, + "title_template": "{{title}}", + "blocks": [_h2("Programme"), _bullet("…"), _h2("Logistique"), _todo("…"), + _h2("Bilan"), _para("…")], + }, + { + "kind": "row", "name": "Voyage", "icon": "✈️", "category": "Lignes", + "description": "Ligne de voyage (base « Voyages »).", + "manifest": {"target_collection_name": "Voyages", "property_defaults": {"Statut": "Idée"}}, + "title_template": "{{title}}", + "blocks": [_h2("Réservations"), _todo("Transport"), _todo("Hébergement"), + _h2("Programme"), _bullet("…")], + }, + { + "kind": "row", "name": "Recette", "icon": "🍳", "category": "Lignes", + "description": "Ligne de recette (base « Recettes »).", + "manifest": {"target_collection_name": "Recettes"}, + "title_template": "{{title}}", + "blocks": [_h2("Ingrédients"), _bullet("…"), _h2("Préparation"), _num("…")], + }, + { + "kind": "row", "name": "Séance d'entraînement", "icon": "🏋️", "category": "Lignes", + "description": "Ligne de séance de sport (base « Entraînements »).", + "manifest": {"target_collection_name": "Entraînements", + "property_defaults": {"Type": "Muscu", "Intensité": "Moyenne"}}, + "title_template": "{{title}}", + "blocks": [_h2("Échauffement"), _todo("…"), _h2("Exercices"), _bullet("…"), + _h2("Étirements"), _todo("…")], + }, + { + "kind": "row", "name": "Candidature", "icon": "📮", "category": "Lignes", + "description": "Ligne de candidature (base « Candidatures »).", + "manifest": {"target_collection_name": "Candidatures", + "property_defaults": {"Statut": "À postuler"}}, + "title_template": "{{title}}", + "blocks": [_h2("Offre"), _para("…"), _h2("Points forts"), _bullet("…"), + _h2("Questions à poser"), _bullet("…")], + }, + { + "kind": "row", "name": "Formation", "icon": "🎓", "category": "Lignes", + "description": "Ligne de formation (base « Formations & cours »).", + "manifest": {"target_collection_name": "Formations & cours", + "property_defaults": {"Statut": "À commencer"}}, + "title_template": "{{title}}", + "blocks": [_h2("Objectifs"), _bullet("…"), _h2("Notes"), _para("…"), + _h2("À retenir"), _callout("…", "📌")], + }, + { + "kind": "row", "name": "Fournisseur", "icon": "🏭", "category": "Lignes", + "description": "Ligne de fournisseur (base « Fournisseurs »).", + "manifest": {"target_collection_name": "Fournisseurs"}, + "title_template": "{{title}}", + "blocks": [_h2("Coordonnées"), _para("…"), _h2("Conditions"), _bullet("…")], + }, + { + "kind": "row", "name": "Abonnement", "icon": "🔁", "category": "Lignes", + "description": "Ligne d'abonnement (base « Abonnements »).", + "manifest": {"target_collection_name": "Abonnements", + "property_defaults": {"Fréquence": "Mensuel"}}, + "title_template": "{{title}}", + "blocks": [_h2("Détails"), _bullet("Coût — …"), _h2("Utilité"), _para("…")], + }, + { + "kind": "row", "name": "Indicateur", "icon": "📈", "category": "Lignes", + "description": "Ligne de KPI (base « KPI / Métriques »).", + "manifest": {"target_collection_name": "KPI / Métriques"}, + "title_template": "{{title}}", + "blocks": [_h2("Définition"), _para("…"), _h2("Source"), _bullet("…")], + }, +] + + + +def ensure_registry(conn: sqlite3.Connection) -> dict[str, int]: + """Garantit registre + backfill + presets (boot, idempotent, non bloquant).""" + stats: dict[str, int] = {"backfill": {}, "presets": 0} + try: + if not registry_ready(conn): + return stats + stats["backfill"] = TemplateService.backfill_registry(conn) + stats["presets"] = TemplateService.ensure_system_presets(conn) + except Exception: + logger.exception("ensure_registry templates (dégradation : silos legacy)") + return stats + + +async def template_recurrence_scheduler(interval_seconds: int = 60): + """Boucle de fond : instancie les templates récurrents dus (§11.2). + + Même patron que ``reminder_scheduler`` (scan 60 s, dédup par + ``occurrence_key``) : un redémarrage entre deux passages ne crée jamais + de doublon. + """ + import asyncio + + await asyncio.sleep(10) + while True: + try: + from app.db import get_conn as _get_conn + + with _get_conn() as conn: + fired = TemplateService.run_due_recurrences(conn) + if fired: + logger.info("Templates: %d récurrence(s) exécutée(s)", len(fired)) + except Exception as exc: # pragma: no cover - défensif uniquement + logger.warning("Template recurrence scan failed: %s", exc) + await asyncio.sleep(max(30, int(interval_seconds))) diff --git a/app/services/form_projection.py b/app/services/form_projection.py new file mode 100644 index 0000000..a5db6d4 --- /dev/null +++ b/app/services/form_projection.py @@ -0,0 +1,68 @@ +"""FlowDeck — Projection Form builder -> pipeline public /f/ (§16.5). + +Le builder ecrit ``form_questions`` + reglages de la vue form ; le +projecteur regenere ``collections.form_config_json`` (format v6.8 inchange) +consomme par GET/POST /f/. Le public ne lit jamais les tables du +builder : zero regression possible sur les liens deja diffuses. +""" +from __future__ import annotations + +import json + + +def project_form_config(view_config: dict, questions: list[dict]) -> dict: + """Construit le ``form_config_json`` v6.8 depuis le builder.""" + cfg = dict(view_config or {}) + sharing = cfg.get("sharing") or {} + submit = cfg.get("submit_screen") or {} + fields = [] + for q in sorted(questions, key=lambda x: x.get("position", 0)): + fields.append({ + "property": q.get("property_name"), + "label": q.get("label_override") or q.get("property_name"), + "required": bool(q.get("required")), + "help": q.get("help_text") or "", + "widget": q.get("widget") or "auto", + "max_selections": q.get("max_selections"), + "conditional": json.loads(q["conditional_json"]) if q.get("conditional_json") else None, + }) + return { + "title": cfg.get("title") or "Form", + "description": cfg.get("description") or "", + "fields": fields, + "sharing": { + "audience": sharing.get("audience", "workspace"), + "anonymous": bool(sharing.get("anonymous", False)), + "respondent_access": sharing.get("respondent_access", "none"), + "branding": sharing.get("branding", True), + }, + "submit": { + "button_text": submit.get("button_text", "Envoyer"), + "button_color": submit.get("button_color", "blue"), + "confirmation_title": submit.get("confirmation_title", "Merci !"), + "confirmation_body": submit.get("confirmation_body", "Votre réponse a été enregistrée."), + }, + "form_view_id": cfg.get("form_view_id"), + } + + +def default_questions_for(properties: list[dict]) -> list[dict]: + """Questions initiales depuis les proprietes exposables (hors formules).""" + skip = {"formula", "rollup", "button", "unique_id", + "created_time", "created_by", "last_edited_time", "last_edited_by"} + out = [] + for i, p in enumerate(properties): + if p.get("prop_type") in skip: + continue + out.append({ + "property_name": p.get("name"), + "position": i, + "label_override": None, + "sync_label": 1, + "help_text": "", + "required": 0, + "widget": "auto", + "max_selections": None, + "conditional_json": None, + }) + return out diff --git a/app/services/geocoding.py b/app/services/geocoding.py new file mode 100644 index 0000000..1273f02 --- /dev/null +++ b/app/services/geocoding.py @@ -0,0 +1,154 @@ +"""FlowDeck — Geocoding Service (vue Map §13.4). + +Interface de fournisseur unique : search / resolve / reverse. +Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun +appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie, +<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim). + +Le cache SQLite (migration 45, table ``geocode_cache``) est consulte +avant tout appel. Les journaux ne contiennent jamais d'adresses en clair +au-dela de compteurs (le texte reste dans le cache, purgeable). +""" +from __future__ import annotations + +import hashlib +import json +import logging +import time +import urllib.parse +import urllib.request +from typing import Any + +logger = logging.getLogger(__name__) + +_last_call_ts = 0.0 + + +def _norm(query: str) -> str: + return " ".join((query or "").lower().split()) + + +def query_hash(query: str, provider: str) -> str: + return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest() + + +def get_settings(conn=None) -> dict: + """Lit la config geocodeur (instance). Defauts surs : provider=none.""" + try: + from app.config import settings as _s # type: ignore + provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none" + endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org" + return {"provider": provider, "endpoint": endpoint.rstrip("/")} + except Exception: + return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"} + + +def cache_get(conn, qhash: str) -> dict | None: + try: + row = conn.execute( + "SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?", + (qhash,), + ).fetchone() + except Exception: + return None + if not row: + return None + try: + conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,)) + except Exception: + pass + try: + data = json.loads(row["result_json"]) if row["result_json"] else {} + except (json.JSONDecodeError, TypeError): + data = {} + data.setdefault("lat", row["lat"]) + data.setdefault("lng", row["lng"]) + return data + + +def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None: + try: + conn.execute( + """INSERT OR REPLACE INTO geocode_cache + (query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE( + (SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""", + (qhash, provider, query_text[:300], json.dumps(result), + result.get("lat"), result.get("lng"), qhash), + ) + except Exception: + pass + + +def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any: + global _last_call_ts + # Politesse Nominatim : <= 1 req/s. + wait = 1.0 - (time.time() - _last_call_ts) + if wait > 0: + time.sleep(wait) + full = url + "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(full, headers={ + "User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)", + "Accept": "application/json", + }) + with urllib.request.urlopen(req, timeout=timeout) as resp: + _last_call_ts = time.time() + return json.loads(resp.read().decode("utf-8", "replace")) + + +def search(conn, query: str, limit: int = 5) -> list[dict]: + """Autocompletion d'adresses. Vide quand provider=none.""" + cfg = get_settings(conn) + provider = cfg["provider"] + if not query or len(query.strip()) < 3 or provider == "none": + return [] + qhash = query_hash(f"search:{query}:{limit}", provider) + cached = cache_get(conn, qhash) + if cached and isinstance(cached.get("suggestions"), list): + return cached["suggestions"][:limit] + base = cfg["endpoint"] + try: + if provider in ("nominatim", "custom"): + data = _http_json(f"{base}/search", { + "q": query, "format": "jsonv2", "limit": limit, + "addressdetails": 1, + }) + suggestions = [{ + "name": (d.get("display_name") or "").split(",")[0].strip(), + "address": d.get("display_name", ""), + "lat": float(d["lat"]), "lng": float(d["lon"]), + "provider": provider, "place_id": str(d.get("place_id", "")), + } for d in (data or []) if d.get("lat") and d.get("lon")] + else: + suggestions = [] + except Exception as exc: + logger.warning("geocode search failed: %s", type(exc).__name__) + return [] + try: + cache_put(conn, qhash, provider, query, {"suggestions": suggestions}) + conn.commit() + except Exception: + pass + return suggestions + + +def resolve(conn, query: str) -> dict | None: + """Adresse -> {lat, lng, ...}. Cache d'abord, None si echec.""" + cfg = get_settings(conn) + provider = cfg["provider"] + if not query or provider == "none": + return None + qhash = query_hash(f"resolve:{query}", provider) + cached = cache_get(conn, qhash) + if cached and cached.get("lat") is not None: + return cached + results = search(conn, query, limit=1) + if not results: + return None + first = dict(results[0]) + try: + cache_put(conn, qhash, provider, query, first) + conn.commit() + except Exception: + pass + return first diff --git a/app/services/property_types.py b/app/services/property_types.py index 453a29b..e286e09 100644 --- a/app/services/property_types.py +++ b/app/services/property_types.py @@ -97,14 +97,20 @@ PROPERTY_TYPES: dict[str, dict] = { "default": None, }, "button": { - "storage": "none — runs linked automation (button_automation_id)", + "storage": "none - runs linked automation (button_automation_id)", + "default": None, + }, + "place": { + "storage": "json object {name, address, lat, lng, provider, place_id, resolved_at, manual}", + "validation": "name or address required, lat in [-90,90], lng in [-180,180]", "default": None, }, } # CSV-friendly subset (no relation/rollup/formula) SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status", - "date", "person", "checkbox", "url", "email", "phone", "files"] + "date", "person", "checkbox", "url", "email", "phone", "files", + "place"] # Auto-property types (computed server-side) AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"] @@ -136,10 +142,19 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N return False, f"'{v}' is not a valid option" elif prop_type == "date": if isinstance(value, str) and value: - try: - datetime.fromisoformat(value.replace("Z", "+00:00")) - except (ValueError, TypeError): + # Les plages « start...end » (Timeline, auto-shift) sont valides : + # chaque borne est controlee separement. + parts = value.split("...") if "..." in value else [value] + if len(parts) > 2 or not any(p.strip() for p in parts): return False, f"'{value}' is not a valid ISO 8601 date" + for part in parts: + part = part.strip() + if not part: + continue + try: + datetime.fromisoformat(part.replace("Z", "+00:00")) + except (ValueError, TypeError): + return False, f"'{value}' is not a valid ISO 8601 date" elif prop_type == "person": if not isinstance(value, list): return False, "Person must be a list of workspace members" @@ -152,6 +167,31 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N elif prop_type == "phone": if not isinstance(value, str): return False, "Phone must be a string" + elif prop_type == "place": + if value in ("", {}): + return True, "" + if isinstance(value, str): + # Plain-text address (unresolved place, e.g. CSV import) is valid. + return True, "" + if not isinstance(value, dict): + return False, "Place must be an object {name, address, lat, lng}" + if not (value.get("name") or value.get("address")): + return False, "Place requires a name or an address" + lat, lng = value.get("lat"), value.get("lng") + if lat is not None and lat != "": + try: + lat = float(lat) + except (ValueError, TypeError): + return False, "Place latitude must be a number" + if not -90 <= lat <= 90: + return False, "Place latitude must be between -90 and 90" + if lng is not None and lng != "": + try: + lng = float(lng) + except (ValueError, TypeError): + return False, "Place longitude must be a number" + if not -180 <= lng <= 180: + return False, "Place longitude must be between -180 and 180" return True, "" diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index dcab6b2..04c6c1d 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -703,32 +703,134 @@ class AddDependency(Tool): class ApplyTemplate(Tool): name = "apply_template" - description = "Applique un template de page dans une collection (crée une page)." + description = ( + "Applique un template du registre unifié (crée une page, une ligne, " + "une base ou des blocs). Même service que l'UI (TemplateService)." + ) parameters = { "type": "object", - "properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}}, - "required": ["collection_id", "template_id"], + "properties": { + "collection_id": {"type": "integer"}, + "template_id": {"type": ["integer", "string"]}, + "title": {"type": "string"}, + "variables": {"type": "object"}, + }, + "required": ["template_id"], } async def execute(self, args, *, user_id=None) -> ToolResult: - cid, tid = args.get("collection_id"), args.get("template_id") + from app.services.fd_templates import TemplateService, registry_ready + + tid = args.get("template_id") + cid = args.get("collection_id") + # Compat legacy : un id numérique de page_templates. + template_ref: str = str(tid) with get_conn() as conn: - tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone() - if not tmpl: - return ToolResult(status="error", tool=self.name, message="Template introuvable") - max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0] - cur = conn.execute( - "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", - (cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]), - ) - conn.commit() + actor = _actor_for(conn, user_id) + if registry_ready(conn) and isinstance(tid, int): + legacy = conn.execute( + "SELECT id FROM templates WHERE source_kind='legacy_page' AND source_id=?", + (str(tid),), + ).fetchone() + if legacy: + template_ref = legacy["id"] + variables = dict(args.get("variables") or {}) + if args.get("title"): + variables.setdefault("title", args["title"]) + context: dict = {} + if cid: + context["collection_id"] = cid + try: + result = TemplateService.instantiate( + conn, actor, template_ref, variables, context, + run_source="agent", + ) + except LookupError as exc: + return ToolResult(status="error", tool=self.name, message=str(exc)) + except (ValueError, PermissionError) as exc: + return ToolResult(status="error", tool=self.name, message=str(exc)) + except Exception as exc: # noqa: BLE001 — l'agent doit recevoir l'erreur + logger.exception("apply_template agent") + return ToolResult(status="error", tool=self.name, message=str(exc)) + created_id = result.get("created_id") or None + try: + target_id = int(created_id) if created_id else 0 + except (TypeError, ValueError): + target_id = 0 + undo = None + if result.get("created_type") in ("page", "row", "collection") and target_id: + table = {"page": "pages", "row": "collection_pages", + "collection": "collections"}[result["created_type"]] + undo = {"action": "delete", "table": table, "id": target_id} return ToolResult( - status="success", tool=self.name, target_type="page", target_id=cur.lastrowid, - data={"page_id": cur.lastrowid}, - undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid}, + status="success", tool=self.name, + target_type=result.get("created_type") or "page", + target_id=target_id, + data={"page_id": target_id, "url": result.get("url"), + "warnings": result.get("warnings", []), + "run_id": result.get("run_id")}, + undo=undo, ) +class ListTemplates(Tool): + name = "list_templates" + description = "Liste les templates visibles par l'utilisateur (registre unifié)." + parameters = { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "query": {"type": "string"}, + "target_collection_id": {"type": "integer"}, + }, + "required": [], + } + + async def execute(self, args, *, user_id=None) -> ToolResult: + from app.services.fd_templates import TemplateService + + with get_conn() as conn: + actor = _actor_for(conn, user_id) + try: + items = TemplateService.list_templates( + conn, actor, kind=args.get("kind"), + query=args.get("query"), + target_collection_id=args.get("target_collection_id"), + ) + except Exception as exc: # noqa: BLE001 + logger.exception("list_templates agent") + return ToolResult(status="error", tool=self.name, message=str(exc)) + summary = [ + {"id": t["id"], "kind": t["kind"], "name": t["name"], + "scope": t["scope"], "is_default": t.get("is_default", False)} + for t in items + ] + return ToolResult( + status="success", tool=self.name, target_type="templates", + data={"templates": summary}, + ) + + +def _actor_for(conn, user_id: int | None) -> dict: + """Acteur minimal pour le service (l'agent n'agit jamais au-delà de + l'utilisateur appelant, §6.1 de l'architecture).""" + actor: dict = {"id": user_id} + if user_id: + try: + row = conn.execute( + "SELECT id, login, full_name, is_admin FROM users WHERE id=?", + (user_id,), + ).fetchone() + if row: + actor.update( + {"login": row["login"], "full_name": row["full_name"], + "is_admin": bool(row["is_admin"])} + ) + except Exception: + pass + return actor + + # ══════════════════════════ Destructive tools ══════════════════════════ @@ -1149,7 +1251,7 @@ TOOL_CLASSES = [ SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument, CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument, UpdatePage, WriteBlocks, - AddRelation, CreateSubItem, AddDependency, ApplyTemplate, + AddRelation, CreateSubItem, AddDependency, ApplyTemplate, ListTemplates, DeletePage, DeleteCollection, ReadGiteaIssues, SyncGitea, CreateGiteaIssue, DeleteDocument, diff --git a/app/services/view_aggregate.py b/app/services/view_aggregate.py new file mode 100644 index 0000000..3a3cfc7 --- /dev/null +++ b/app/services/view_aggregate.py @@ -0,0 +1,221 @@ +"""FlowDeck — Aggregation Service (vues Notion §8.3). + +Un seul moteur d'agregation cote serveur pour Chart, KPI, widgets de +Dashboard et drilldown : grouper / sous-grouper / mesurer / cumuler / +limiter, avec contrat de sortie normalise. + +Mesures : count | sum | average | median | min | max (definitions alignees +sur le Rollup Engine). Groupes : ordre du schema pour select/status/ +multi_select (couleurs des options incluses), chronologique pour les dates. +""" +from __future__ import annotations + +import json +from typing import Any + +from .view_query import ( + CHART_MAX_GROUPS, + CHART_MAX_SUBGROUPS, + apply_filters, + apply_sorts, + prop_value, + text_of, +) + +NON_GROUPABLE = {"rollup", "button", "unique_id", "files"} +NON_MEASURABLE_AXES = {"rollup", "button", "unique_id", "files"} + + +def _schema_options(prop: dict) -> list[dict]: + try: + opts = json.loads(prop.get("options_json") or "[]") + except (json.JSONDecodeError, TypeError): + opts = [] + out = [] + for o in opts or []: + if isinstance(o, str): + out.append({"name": o, "color": "gray"}) + elif isinstance(o, dict): + out.append({"name": o.get("name", ""), "color": o.get("color", "gray")}) + return out + + +def _measure_values(rows: list[dict], measure_prop: dict | None) -> list[float]: + vals: list[float] = [] + for p in rows: + if measure_prop is None: + vals.append(1.0) + continue + v = prop_value(p, measure_prop) + if v is None or v == "": + continue + try: + vals.append(float(v) if not isinstance(v, bool) else float(v)) + except (ValueError, TypeError): + continue + return vals + + +def _reduce(kind: str, vals: list[float], count: int) -> float: + if kind == "count": + return float(count) + if not vals: + return 0.0 + if kind == "sum": + return float(sum(vals)) + if kind in ("average", "avg"): + return float(sum(vals) / len(vals)) + if kind == "median": + s = sorted(vals) + mid = len(s) // 2 + return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2) + if kind == "min": + return float(min(vals)) + if kind == "max": + return float(max(vals)) + return float(count) + + +def _date_bucket(value: Any, span_days: int) -> str: + s = str(value or "")[:10] + if len(s) < 10: + return s or "(empty)" + if span_days <= 62: + return s + if span_days <= 371: + # week bucket: keep ISO week label + try: + from datetime import date as _d + d = _d.fromisoformat(s) + iso = d.isocalendar() + return f"{iso.year}-W{iso.week:02d}" + except ValueError: + return s[:7] + return s[:7] + + +def aggregate(pages: list[dict], properties: list[dict], spec: dict) -> dict: + """Calcule l'agregat normalise ``{groups, total, truncated, scanned}``.""" + by_name = {p["name"]: p for p in properties} + by_id = {str(p["id"]): p for p in properties} + group_prop = by_name.get(spec.get("group_by")) or by_id.get(str(spec.get("group_by", ""))) + sub_prop = None + if spec.get("sub_group_by"): + sub_prop = by_name.get(spec["sub_group_by"]) or by_id.get(str(spec["sub_group_by"])) + measure = spec.get("measure") or {"kind": "count"} + kind = measure.get("kind", "count") + measure_prop = None + if kind != "count" and measure.get("property"): + measure_prop = by_name.get(measure["property"]) or by_id.get(str(measure["property"])) + + filters = spec.get("filters") or [] + rows = apply_filters(pages, properties, filters, spec.get("filter_conjunction", "and")) + rows = apply_sorts(rows, properties, spec.get("sorts")) + scanned = len(rows) + + hidden = set(spec.get("hidden_groups") or []) + omit_zero = spec.get("omit_zero", True) + cumulative = bool(spec.get("cumulative")) + order_cfg = spec.get("order") or {"by": "group_order", "direction": "asc"} + if isinstance(order_cfg, str): + order_cfg = {"by": order_cfg, "direction": "asc"} + + # Date grouping needs the span first. + is_date = group_prop is not None and group_prop.get("prop_type") == "date" + span_days = 0 + if is_date: + dates = sorted(text_of(group_prop, prop_value(p, group_prop))[:10] + for p in rows if text_of(group_prop, prop_value(p, group_prop))) + if len(dates) >= 2: + try: + from datetime import date as _d + span_days = (_d.fromisoformat(dates[-1][:10]) - _d.fromisoformat(dates[0][:10])).days + except ValueError: + span_days = 0 + + buckets: dict[str, list[dict]] = {} + for p in rows: + if group_prop is None: + key = "All" + elif group_prop.get("prop_type") == "multi_select": + v = prop_value(p, group_prop) + keys = v if isinstance(v, list) and v else ["(empty)"] + for k in keys: + buckets.setdefault(str(k or "(empty)"), []).append(p) + continue + elif is_date: + key = _date_bucket(prop_value(p, group_prop), span_days) + else: + t = text_of(group_prop, prop_value(p, group_prop)) + key = t if t else "(empty)" + buckets.setdefault(key, []).append(p) + + # Empty schema options (omit_zero=False) for select/status. + if group_prop is not None and not omit_zero and group_prop.get("prop_type") in ("select", "status"): + for o in _schema_options(group_prop): + buckets.setdefault(o["name"], []) + + schema_order = [o["name"] for o in _schema_options(group_prop)] if group_prop else [] + opt_colors = {o["name"]: o.get("color", "gray") for o in _schema_options(group_prop)} if group_prop else {} + + def sort_key(item: tuple[str, list]) -> Any: + k, members = item + by = order_cfg.get("by", "group_order") + if by == "value": + mvals = _measure_values(members, measure_prop) + return _reduce(kind, mvals, len(members)) + if by == "label": + return k.lower() + if group_prop is not None and group_prop.get("prop_type") == "date": + return k + if k in schema_order: + return (0, schema_order.index(k)) + return (1, k.lower()) + + items = sorted(buckets.items(), key=sort_key) + if str(order_cfg.get("direction", "asc")).lower() in ("desc", "descending"): + items = list(reversed(items)) + + truncated = False + if len(items) > CHART_MAX_GROUPS: + items = items[:CHART_MAX_GROUPS] + truncated = True + + groups = [] + running = 0.0 + for key, members in items: + if key in hidden: + continue + mvals = _measure_values(members, measure_prop) + value = _reduce(kind, mvals, len(members)) + if cumulative and kind in ("count", "sum"): + running += value + value = running + sub = [] + if sub_prop is not None: + sub_buckets: dict[str, list] = {} + for p in members: + sk = text_of(sub_prop, prop_value(p, sub_prop)) or "(empty)" + sub_buckets.setdefault(sk, []).append(p) + for sk, sp in list(sub_buckets.items())[:CHART_MAX_SUBGROUPS]: + svals = _measure_values(sp, measure_prop) + sub.append({"key": sk, "label": sk, "value": _reduce(kind, svals, len(sp))}) + sub.sort(key=lambda g: -g["value"]) + groups.append({ + "key": key, + "label": key, + "color": opt_colors.get(key, "gray"), + "value": value, + "count": len(members), + "row_ids": [p.get("id") for p in members[:500]], + "sub": sub, + }) + + total_vals = _measure_values(rows, measure_prop) + total = _reduce(kind, total_vals, len(rows)) + grand = sum(g["value"] for g in groups) or 1 + for g in groups: + g["percent"] = g["value"] / grand if grand else 0 + + return {"groups": groups, "total": total, "truncated": truncated, + "scanned": scanned, "measure": kind} diff --git a/app/services/view_query.py b/app/services/view_query.py new file mode 100644 index 0000000..da7fbe9 --- /dev/null +++ b/app/services/view_query.py @@ -0,0 +1,166 @@ +"""FlowDeck — View Query Service (vues Notion §8.1). + +Extraction partagee de la chaine de rendu des vues : charger le schema, +appliquer filtres (and/or) -> tris -> projection des proprietes visibles. +Toutes les vues clientes (Table/Board/... + chart/timeline/feed/map/ +dashboard/form) en sont des consommatrices. + +ACL : le filtrage des proprietes se fait AVANT tout calcul — une vue +n'agrege / ne geocode / n'affiche jamais une propriete invisible. +En v1 on s'appuie sur les ACL de collection (le granulair propriete est +applique quand `property_acls` est disponible, sinon tout est visible). +""" +from __future__ import annotations + +import json +from typing import Any + +CHART_MAX_GROUPS = 200 +CHART_MAX_SUBGROUPS = 50 +VIEW_ROWS_MAX = 1000 +MAP_POINTS_MAX = 100 +TIMELINE_LOAD_MAX = 200 +FEED_PAGE_SIZE = 20 + + +def parse_values(page: dict) -> dict: + try: + v = page.get("property_values_json", "{}") + return json.loads(v) if isinstance(v, str) else (v or {}) + except (json.JSONDecodeError, TypeError): + return {} + + +def prop_value(page: dict, prop: dict) -> Any: + if prop.get("prop_type") == "title": + return page.get("title", "") + vals = parse_values(page) + v = vals.get(str(prop.get("id"))) + if v is None: + v = vals.get(prop.get("name")) + return v + + +def text_of(prop: dict, value: Any) -> str: + if value is None: + return "" + if isinstance(value, dict): + # place / person-single / user ref + return str(value.get("name") or value.get("address") + or value.get("login") or value.get("full_name") or "") + if isinstance(value, list): + parts = [] + for x in value: + if isinstance(x, dict): + parts.append(str(x.get("login") or x.get("full_name") + or x.get("name") or x.get("url") or "")) + else: + parts.append(str(x)) + return ", ".join(parts) + return str(value) + + +def _match_op(actual: Any, op: str, expected: Any, prop: dict) -> bool: + actual_text = text_of(prop, actual).lower() + if op in ("is", "equals", "is exactly"): + if isinstance(actual, list): + return str(expected) in [str(x.get("name", x) if isinstance(x, dict) else x) for x in actual] + return actual_text == str(expected or "").lower() + if op in ("is_not", "is not", "not_equals"): + return actual_text != str(expected or "").lower() + if op == "contains": + return str(expected or "").lower() in actual_text + if op in ("not_contains", "does_not_contain"): + return str(expected or "").lower() not in actual_text + if op == "is_empty": + return actual in (None, "", []) or actual_text == "" + if op == "is_not_empty": + return not (actual in (None, "", []) or actual_text == "") + if op in ("gt", ">", "after"): + try: + return float(actual_text) > float(expected) if actual_text else False + except (ValueError, TypeError): + return str(actual_text) > str(expected or "") + if op in ("lt", "<", "before"): + try: + return float(actual_text) < float(expected) if actual_text else False + except (ValueError, TypeError): + return str(actual_text) < str(expected or "") + if op == "is_resolved": + return isinstance(actual, dict) and actual.get("lat") not in (None, "") + if op == "is_not_resolved": + return not (isinstance(actual, dict) and actual.get("lat") not in (None, "")) + return True + + +def apply_filters(pages: list[dict], properties: list[dict], + filters: list[dict] | None, + conjunction: str = "and") -> list[dict]: + if not filters: + return list(pages) + by_name = {p["name"]: p for p in properties} + by_id = {str(p["id"]): p for p in properties} + out = [] + for page in pages: + results = [] + for f in filters: + prop = by_name.get(f.get("property")) or by_id.get(str(f.get("property", ""))) + if prop is None: + continue + actual = prop_value(page, prop) + results.append(_match_op(actual, f.get("operator", "contains"), f.get("value"), prop)) + if not results: + out.append(page) + elif (conjunction or "and").lower() == "or": + if any(results): + out.append(page) + elif all(results): + out.append(page) + return out + + +def apply_sorts(pages: list[dict], properties: list[dict], + sorts: list[dict] | None) -> list[dict]: + if not sorts: + return list(pages) + by_name = {p["name"]: p for p in properties} + by_id = {str(p["id"]): p for p in properties} + rows = list(pages) + for s in reversed(sorts): + prop = by_name.get(s.get("property")) or by_id.get(str(s.get("property", ""))) + if prop is None: + continue + reverse = str(s.get("direction", "asc")).lower() in ("desc", "descending") + rows.sort(key=lambda p: text_of(prop, prop_value(p, prop)).lower(), reverse=reverse) + return rows + + +def query_view_rows(pages: list[dict], properties: list[dict], + config: dict, extra_filters: list[dict] | None = None, + limit: int = VIEW_ROWS_MAX, + cursor: str | None = None) -> tuple[list[dict], str | None]: + """Filtre + tri + projection + pagination par curseur (id).""" + filters = list(config.get("filters") or []) + if extra_filters: + filters.extend(extra_filters) + conjunction = config.get("filter_conjunction", "and") + rows = apply_filters(pages, properties, filters, conjunction) + rows = apply_sorts(pages and rows, properties, config.get("sorts")) + start = 0 + if cursor: + for i, p in enumerate(rows): + if str(p.get("id")) == str(cursor): + start = i + 1 + break + window = rows[start:start + limit] + next_cursor = str(window[-1]["id"]) if len(rows) > start + limit and window else None + return window, next_cursor + + +def visible_props(properties: list[dict], config: dict) -> list[dict]: + vis = config.get("visible_properties") + if not vis: + return list(properties) + wanted = {str(v) for v in vis} + ordered = [p for p in properties if str(p.get("id")) in wanted or p.get("name") in wanted] + return ordered or list(properties) diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index f50e7b0..54d25d9 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -510,6 +510,9 @@
📑 Save as template
+
+ {{ fd_icon("file",14) }} Apply template… +
{{ fd_icon("trash",14) }} Move to Trash @@ -746,7 +749,7 @@ @@ -797,8 +800,8 @@ '; return h; } @@ -794,6 +843,684 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= return h; } + // ── Vues Notion : helpers partagés ── + function numOf(v) { var n = Number(v); return isFinite(n) ? n : null; } + function localAggregate(spec) { + spec = spec || {}; + var gprop = findProp(spec.group_by) || null; + var sprop = spec.sub_group_by ? findProp(spec.sub_group_by) : null; + var kind = (spec.measure && spec.measure.kind) || 'count'; + var mprop = (spec.measure && spec.measure.property) ? findProp(spec.measure.property) : null; + var hidden = {}; (spec.hidden_groups || []).forEach(function(k){ hidden[k] = 1; }); + function mvals(members) { + var out = []; + members.forEach(function(cp){ + if (!mprop) { out.push(1); return; } + var n = numOf(pageVal(cp, mprop)); + if (n !== null) out.push(n); + }); + return out; + } + function reduce(vals, count) { + if (kind === 'count') return count; + if (!vals.length) return 0; + if (kind === 'sum') return vals.reduce(function(a,b){ return a+b; }, 0); + if (kind === 'average' || kind === 'avg') return vals.reduce(function(a,b){ return a+b; }, 0) / vals.length; + if (kind === 'median') { var s = vals.slice().sort(function(a,b){ return a-b; }); var m = Math.floor(s.length/2); return s.length % 2 ? s[m] : (s[m-1]+s[m])/2; } + if (kind === 'min') return Math.min.apply(null, vals); + if (kind === 'max') return Math.max.apply(null, vals); + return count; + } + var buckets = {}; + state.pages.forEach(function(cp){ + var key; + if (!gprop) key = 'All'; + else if (gprop.prop_type === 'multi_select') { + var arr = pageVal(cp, gprop); + (Array.isArray(arr) && arr.length ? arr : ['(empty)']).forEach(function(k){ + k = String(k || '(empty)'); + (buckets[k] = buckets[k] || []).push(cp); + }); + return; + } else { + var t = textValue(gprop, pageVal(cp, gprop)); + key = t ? String(t) : '(empty)'; + } + (buckets[key] = buckets[key] || []).push(cp); + }); + if (gprop && !spec.omit_zero && (gprop.prop_type === 'select' || gprop.prop_type === 'status')) { + parseOpts(gprop).forEach(function(o){ if (!buckets[o.name]) buckets[o.name] = []; }); + } + var orderBy = (spec.order && spec.order.by) || spec.group_order || 'group_order'; + var schemaOrder = gprop ? parseOpts(gprop).map(function(o){ return o.name; }) : []; + var keys = Object.keys(buckets); + keys.sort(function(a, b){ + if (orderBy === 'value') return reduce(mvals(buckets[b]), buckets[b].length) - reduce(mvals(buckets[a]), buckets[a].length); + if (orderBy === 'label') return a.toLowerCase() < b.toLowerCase() ? -1 : 1; + var ia = schemaOrder.indexOf(a), ib = schemaOrder.indexOf(b); + if (ia >= 0 || ib >= 0) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); + return a.toLowerCase() < b.toLowerCase() ? -1 : 1; + }); + if (String((spec.order && spec.order.direction) || '').toLowerCase() === 'desc') keys = keys.reverse(); + var truncated = keys.length > 200; + keys = keys.slice(0, 200); + var groups = [], running = 0; + keys.forEach(function(k){ + if (hidden[k]) return; + if (state.chartHidden && state.chartHidden[k]) return; + var members = buckets[k]; + var v = reduce(mvals(members), members.length); + if (spec.cumulative && (kind === 'count' || kind === 'sum')) { running += v; v = running; } + var sub = []; + if (sprop) { + var sb = {}; + members.forEach(function(cp){ + var sk = textValue(sprop, pageVal(cp, sprop)) || '(empty)'; + (sb[sk] = sb[sk] || []).push(cp); + }); + Object.keys(sb).slice(0, 50).forEach(function(sk){ + sub.push({key: sk, label: sk, value: reduce(mvals(sb[sk]), sb[sk].length)}); + }); + sub.sort(function(a,b){ return b.value - a.value; }); + } + groups.push({key: k, label: k, color: gprop ? optColor(gprop, k) : '#8b8b8b', value: v, count: members.length, + row_ids: members.slice(0, 500).map(function(p){ return p.id; }), sub: sub}); + }); + var total = reduce(mvals(state.pages), state.pages.length); + var grand = groups.reduce(function(a,g){ return a + g.value; }, 0) || 1; + groups.forEach(function(g){ g.percent = g.value / grand; }); + return {groups: groups, total: total, truncated: truncated, scanned: state.pages.length}; + } + function fmtNum(v) { + if (v === null || v === undefined || isNaN(v)) return '—'; + var a = Math.abs(v); + if (a >= 1e9) return (v/1e9).toFixed(2)+'B'; + if (a >= 1e6) return (v/1e6).toFixed(2)+'M'; + if (a >= 1e3) return (v/1e3).toFixed(1)+'K'; + if (v === Math.round(v)) return String(Math.round(v)); + return String(Math.round(v*100)/100); + } + function fmtPct(p) { return (Math.round(p*1000)/10)+'%'; } + + // ── Vue Map (Leaflet, cadrage auto, plafond 100, Map by) ── + function placeProp() { return findProp(state.viewConfig.place_property) || firstPropOfTypes(['place']); } + function placeCoords(val) { + if (!val) return null; + if (typeof val === 'string') { + var m = val.match(/(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)/); + if (m) { var la = Number(m[1]), ln = Number(m[2]); + if (la >= -90 && la <= 90 && ln >= -180 && ln <= 180) return {lat: la, lng: ln, label: val}; } + return null; + } + if (typeof val === 'object' && val.lat !== null && val.lat !== undefined && val.lat !== '' && val.lng !== null && val.lng !== undefined && val.lng !== '') { + var la2 = Number(val.lat), ln2 = Number(val.lng); + if (isFinite(la2) && isFinite(ln2)) return {lat: la2, lng: ln2, label: val.name || val.address || ''}; + } + return null; + } + function renderMap() { + var pp = placeProp(); + if (!pp) return '
This view needs a Place property.
'; + var pts = [], nores = 0; + state.pages.forEach(function(cp){ + var v = pageVal(cp, pp); + if (v === undefined || v === null || v === '') { nores++; return; } + var c = placeCoords(v); + if (c) pts.push({cp: cp, lat: c.lat, lng: c.lng, label: c.label}); + else nores++; + }); + var over = pts.length > 100; + var shown = pts.slice(0, 100); + var h = '
'; + h += '
'; + h += ''; + h += ''+pts.length+' places · '+shown.length+' shown'+(nores ? ' · '+nores+' without coordinates' : '')+''; + h += '
'; + if (over) h += '
'+pts.length+' places — only the first 100 are shown. Refine filters.
'; + h += '
'; + h += '
' + + shown.map(function(p){ return '
📍 '+escHtml(p.cp.title||'Untitled')+' '+escHtml(p.label||'')+' · '+p.lat.toFixed(3)+', '+p.lng.toFixed(3)+'
'; }).join('') + '
'; + h += '
'+escHtml(JSON.stringify(shown.map(function(p){ return {id: p.cp.id, title: p.cp.title||'Untitled', lat: p.lat, lng: p.lng, label: p.label||''}; }))).replace(/"/g,'"')+'
'; + h += '
'; + return h; + } + function ensureLeaflet(cb) { + if (window.L && window.L.map) { cb(); return; } + function loadScript(src, done) { var s = document.createElement('script'); s.src = src; s.onload = done; s.onerror = done; document.head.appendChild(s); } + function loadCss(href) { if (!document.querySelector('link[data-leaflet]')) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.setAttribute('data-leaflet','1'); document.head.appendChild(l); } } + loadCss('/static/js/vendor/leaflet.css'); + loadScript('/static/js/vendor/leaflet.js', function(){ cb(); }); + } + function bindMap() { + var el = containerEl.querySelector('[data-map-el]'); + if (!el) return; + var raw = containerEl.querySelector('[data-map-data]'); + var pts = []; + try { pts = JSON.parse(raw ? raw.textContent : '[]'); } catch (e) { pts = []; } + ensureLeaflet(function(){ + if (!(window.L && window.L.map) || !el.isConnected) return; + try { + if (el._leaflet_id) return; + var map = window.L.map(el, {scrollWheelZoom: true}); + window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19, attribution: '© OpenStreetMap'}).addTo(map); + var bounds = []; + var grouped = {}; + pts.forEach(function(p){ var k = p.lat.toFixed(3)+','+p.lng.toFixed(3); (grouped[k] = grouped[k] || []).push(p); }); + Object.keys(grouped).forEach(function(k){ + var g = grouped[k]; + var mk = g.length > 1 + ? window.L.marker([g[0].lat, g[0].lng]).bindPopup(''+g.length+' places
'+g.slice(0,5).map(function(p){ return escHtml(p.title); }).join('
')) + : window.L.marker([g[0].lat, g[0].lng]).bindPopup(''+escHtml(g[0].title)+'
'+escHtml(g[0].label||'')+'
Open →'); + mk.addTo(map); + bounds.push([g[0].lat, g[0].lng]); + }); + // Cadrage auto sur l'étendue des épingles (amélioration vs capture 07). + if (bounds.length === 1) map.setView(bounds[0], 12); + else if (bounds.length) map.fitBounds(bounds, {padding: [30, 30]}); + else map.setView([46.5, -73.5], 5); + el.querySelectorAll('[data-map-open]').forEach(function(){}); + map.on('popupopen', function(ev){ + var a = ev.popup.getElement() && ev.popup.getElement().querySelector('[data-map-open]'); + if (a) a.onclick = function(e){ e.preventDefault(); openPageInSidePeek(parseInt(a.getAttribute('data-map-open'))); }; + }); + el._leaflet_map = map; + var fb = el.querySelector('[data-map-fallback]'); + if (fb && bounds.length) fb.style.display = 'none'; + } catch (e) { /* fallback list stays visible */ } + }); + } + + // ── Vue Dashboard (widgets multi-sources, modes Lecture/Édition, filtres globaux) ── + function renderDashboard() { + var mode = state.dashMode || 'view'; + var widgets = state.dashWidgets || []; + var gf = state.viewConfig.global_filters || []; + var h = '
'; + h += '
'; + h += '
' + + '' + + '
'; + h += ''+widgets.length+' / 12 widgets'; + if (mode === 'edit') h += ''; + h += '
'; + if (gf.length) h += '
Global filters:' + + gf.map(function(f, i){ return ''+escHtml(f.property+' '+f.operator+' '+JSON.stringify(f.value))+' ✕'; }).join('') + + (mode === 'edit' ? '' : '') + '
'; + else if (mode === 'edit') h += '
'; + if (!widgets.length) { + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
'; + return h; + } + // Grille : lignes de 4 widgets max. + var rows = []; + widgets.forEach(function(w, i){ var r = Math.floor(i/4); (rows[r] = rows[r] || []).push({w: w, i: i}); }); + rows.forEach(function(row){ + h += '
'; + row.forEach(function(cell){ + var w = cell.w; + var title = escHtml(w.title || ('Widget '+(cell.i+1))); + h += '
'; + h += '
≡'+title+''; + if (mode === 'edit') h += ''; + else h += ''; + h += '
Loading…
'; + }); + h += '
'; + }); + if (mode === 'edit' && widgets.length < 12) h += '
'; + h += '
'; + return h; + } + function dashFetchWidgets() { + var v = activeView(); + if (!v) { state.dashWidgets = []; return Promise.resolve(); } + return fetch('/db/'+collectionId+'/views/'+v.id+'/widgets', {credentials:'same-origin'}) + .then(function(r){ return r.ok ? r.json() : {widgets: []}; }) + .then(function(d){ + var ws = (d.widgets || []).map(function(w, i){ w._lid = w.id || ('l'+i); return w; }); + if (!ws.length && state.viewConfig.widgets_json) { + try { var arr = typeof state.viewConfig.widgets_json === 'string' ? JSON.parse(state.viewConfig.widgets_json) : state.viewConfig.widgets_json; + ws = (arr || []).map(function(w, i){ w._lid = w.id || ('l'+i); return w; }); } catch (e) {} + } + state.dashWidgets = ws; + }).catch(function(){ state.dashWidgets = []; }); + } + function dashWidgetData(w) { + var srcCid = w.source_collection_id || collectionId; + var inline = w.inline_config || {}; + try { if (typeof w.inline_config_json === 'string') inline = JSON.parse(w.inline_config_json); } catch (e) {} + var gf = state.viewConfig.global_filters || []; + // Filtres globaux : par nom de propriété, ignorés si absents de la source. + function applyGlobal(rows, props) { + var names = {}; props.forEach(function(p){ names[p.name] = p; }); + var extra = gf.filter(function(f){ return names[f.property]; }); + if (!extra.length) return rows; + return rows.filter(function(cp){ + return extra.every(function(f){ + var p = names[f.property]; + var t = textValue(p, pageVal(cp, p)).toLowerCase(); + var ev = Array.isArray(f.value) ? f.value.join(' ').toLowerCase() : String(f.value == null ? '' : f.value).toLowerCase(); + if ((f.operator||'is') === 'contains') return t.indexOf(ev) >= 0; + return t === ev; + }); + }); + } + if (w.source_view_id && !inline.view_type) { + return fetch('/db/'+srcCid+'/views/'+w.source_view_id+'/rows?limit=50', {credentials:'same-origin'}) + .then(function(r){ return r.json(); }).then(function(d){ return {kind:'rows', rows:(d.rows||[])}; }) + .catch(function(){ return {kind:'rows', rows:[]}; }); + } + return fetch('/api/collections/'+srcCid+'/table-data', {credentials:'same-origin'}) + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ + if (!d) return {kind:'rows', rows:[]}; + var props = d.properties || [], pages = d.pages || []; + var rows = applyGlobal(pages, props); + var vt = inline.view_type || 'table'; + if (vt === 'chart' || inline.group_by) { + var gprop = props.filter(function(p){ return p.name === inline.group_by; })[0]; + var bk = {}; + rows.forEach(function(cp){ + var pv = {}; try { pv = JSON.parse(cp.property_values_json || '{}'); } catch (e) {} + var vv = gprop ? (pv[String(gprop.id)] != null ? pv[String(gprop.id)] : pv[gprop.name]) : cp.title; + var k = (vv === undefined || vv === null || vv === '') ? '(empty)' : String(Array.isArray(vv) ? (vv[0]||'(empty)') : vv); + (bk[k] = bk[k] || []).push(cp); + }); + var groups = Object.keys(bk).map(function(k){ return {label: k, value: bk[k].length, count: bk[k].length, color: '#2383E2'}; }); + return {kind:'agg', groups: groups, total: rows.length}; + } + return {kind:'rows', rows: rows.slice(0, 50), total: rows.length, props: props}; + }).catch(function(){ return {kind:'rows', rows:[]}; }); + } + function bindDashboard() { + (state.dashWidgets || []).forEach(function(w){ + var slot = containerEl.querySelector('[data-dash-body="'+w._lid+'"]'); + if (!slot) return; + dashWidgetData(w).then(function(d){ + if (!slot.isConnected) return; + if (d.kind === 'agg') { + var max = Math.max.apply(null, d.groups.map(function(g){ return g.value; }).concat([1])); + slot.innerHTML = '
'+escHtml(fmtNum(d.total))+'
' + + d.groups.slice(0,6).map(function(g){ + return '
'+escHtml(g.label)+'
'+g.value+'
'; + }).join(''); + } else { + var rows = d.rows || []; + slot.innerHTML = rows.slice(0,8).map(function(cp){ + return '
'+escHtml(cp.title||'Untitled')+'
'; + }).join('') || 'No rows'; + if ((d.total||rows.length) > 8) slot.innerHTML += '
'+d.total+' rows — open view
'; + slot.querySelectorAll('[data-card-page]').forEach(function(el){ el.onclick = function(){ openPageInSidePeek(parseInt(el.getAttribute('data-card-page'))); }; }); + } + }); + }); + } + + // ── Vue Form + Form builder (questions ↔ propriétés, partage, preview) ── + function renderForm() { + var c = state.viewConfig || {}; + var qs = state.formQs || []; + var sharing = c.sharing || {audience:'workspace'}; + var scopeTxt = sharing.audience === 'web' ? 'Anyone on the web with the link can fill out this form.' : sharing.audience === 'closed' ? 'This form is closed — nobody can fill it out.' : 'Only members of this workspace can fill out this form.'; + var h = '
'; + h += '
'; + h += ''; + h += ''; + h += '
🔒 '+escHtml(scopeTxt)+' Change
'; + h += '
'; + qs.forEach(function(q, i){ + h += '
'; + h += '
'; + h += '
'; + h += '
Respondent\'s answer'+(q.required?' *':'')+'
'; + if (q.help_text) h += '
'+escHtml(q.help_text)+'
'; + if (q.sync_label === false) h += '
Label unsynced from property “'+escHtml(q.property_name)+'”
'; + h += '
'; + }); + h += '
'; + h += ''; + h += '
Confirmation screen'; + var sub = c.submit_screen || {}; + h += '
'; + h += '
'; + h += '
'; + return h; + } + function formFetchQs() { + var v = activeView(); + if (!v) return Promise.resolve(); + return fetch('/db/'+collectionId+'/views/'+v.id+'/form', {credentials:'same-origin'}) + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ + if (!d) return; + if (d.config && (d.config.title || d.config.sharing || d.config.submit_screen)) { + Object.keys(d.config).forEach(function(k){ if (['title','description','sharing','submit_screen','notify_email'].indexOf(k) >= 0) state.viewConfig[k] = d.config[k]; }); + } + state.formQs = (d.questions || []).map(function(q){ + return {property_name: q.property_name, position: q.position, label_override: q.label_override, + sync_label: q.sync_label !== 0, help_text: q.help_text || '', required: !!q.required, + widget: q.widget || 'auto', max_selections: q.max_selections || null, + conditional: q.conditional_json ? JSON.parse(q.conditional_json) : (q.conditional || null)}; + }); + }).catch(function(){}); + } + function formSave(silent) { + var v = activeView(); + if (!v) return Promise.resolve(); + // Synchro Notion : renommer une question synchronisée renomme la propriété. + (state.formQs || []).forEach(function(q){ + if (q.sync_label !== false && q.label_override && q.label_override !== q.property_name) { + var p = state.properties.filter(function(x){ return x.name === q.property_name; })[0]; + if (p) { p.name = q.label_override; q.property_name = q.label_override; q.label_override = null; } + } + }); + return fetch('/db/'+collectionId+'/views/'+v.id+'/form', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({questions: state.formQs, config: {title: state.viewConfig.title, description: state.viewConfig.description, sharing: state.viewConfig.sharing, submit_screen: state.viewConfig.submit_screen}})}) + .then(function(){ if (!silent) toast('Form saved'); }); + } + + // ── Éditeur de cellule place (autocomplétion + position + manuel) ── + function openPlaceEditor(cellEl, cp, prop) { + var old = document.querySelector('.db-place-pop'); if (old) old.remove(); + var cur = pageVal(cp, prop); + var curTxt = cur ? (typeof cur === 'string' ? cur : [cur.name, cur.address].filter(Boolean).join(' — ')) : ''; + var pop = document.createElement('div'); + pop.className = 'db-place-pop db-view-pop'; + pop.innerHTML = '
📍 Place
' + + '
' + + '
' + + '
' + + '
' + + '
'; + document.body.appendChild(pop); + var r = cellEl.getBoundingClientRect(); + pop.style.left = Math.min(r.left, window.innerWidth-380)+'px'; + pop.style.top = (r.bottom+6)+'px'; + var q = pop.querySelector('[data-pl-q]'), list = pop.querySelector('[data-pl-list]'), chosen = null, timer = null; + q.focus(); + q.oninput = function(){ + chosen = null; + clearTimeout(timer); + timer = setTimeout(function(){ + var v = q.value.trim(); + if (v.length < 3) { list.innerHTML = ''; return; } + fetch('/db/geocode/search?q='+encodeURIComponent(v), {credentials:'same-origin'}) + .then(function(r2){ return r2.ok ? r2.json() : {suggestions:[]}; }) + .then(function(d){ + list.innerHTML = (d.suggestions||[]).map(function(s, i){ + return '
📍 '+escHtml(s.name||'')+'
'+escHtml((s.address||'').slice(0,80))+'
'; + }).join('') || '
No suggestion — free text will be kept as an unresolved place.
'; + list._sugs = d.suggestions || []; + list.querySelectorAll('[data-pl-pick]').forEach(function(el){ + el.onclick = function(){ chosen = list._sugs[parseInt(el.getAttribute('data-pl-pick'))]; q.value = (chosen.name ? chosen.name+' — ' : '')+(chosen.address||''); }; + }); + }).catch(function(){}); + }, 300); + }; + pop.querySelector('[data-pl-geo]').onclick = function(){ + if (!navigator.geolocation) { toast('Geolocation unavailable'); return; } + navigator.geolocation.getCurrentPosition(function(pos){ + chosen = {name: 'Current position', address: '', lat: pos.coords.latitude, lng: pos.coords.longitude, provider: 'browser', manual: true}; + q.value = 'Current position ('+pos.coords.latitude.toFixed(4)+', '+pos.coords.longitude.toFixed(4)+')'; + }, function(){ toast('Location refused', 'error'); }); + }; + pop.querySelector('[data-pl-manual]').onclick = function(){ + var v = prompt('Coordinates "lat,lng":', ''); + if (!v) return; + var m = v.match(/(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)/); + if (!m) { toast('Invalid coordinates', 'error'); return; } + chosen = {name: q.value.trim() || 'Manual pin', address: '', lat: Number(m[1]), lng: Number(m[2]), provider: 'manual', manual: true}; + }; + pop.querySelector('[data-pl-clear]').onclick = function(){ setPageVal(cp, prop, null); pop.remove(); render(); savePage(cp); }; + pop.querySelector('[data-pl-save]').onclick = function(){ + var val = chosen; + if (!val) { + var t = q.value.trim(); + if (!t) { pop.remove(); return; } + val = {name: t, address: t, lat: null, lng: null, provider: 'none', manual: false}; + } + val.resolved_at = new Date().toISOString(); + setPageVal(cp, prop, val); pop.remove(); render(); savePage(cp); + }; + setTimeout(function(){ + document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true}); + }, 0); + } + + // ── Vue Chart (donut / barres / lignes / KPI, drilldown, export PNG) ── + function chartSpec() { + var c = state.viewConfig || {}; + var spec = {group_by: c.group_by || c.chart_property || '', sub_group_by: c.sub_group_by || null, + measure: c.measure || {kind: (c.aggregate || 'count')}, cumulative: !!c.cumulative, + omit_zero: c.omit_zero !== undefined ? !!c.omit_zero : true, + hidden_groups: c.hidden_groups || [], order: c.order || c.group_order || 'group_order'}; + if (typeof spec.measure === 'string') spec.measure = {kind: spec.measure}; + return spec; + } + function svgDonut(groups, total, centerLabel) { + var size = 220, r = 84, cx = size/2, cy = size/2, acc = 0; + var paths = groups.map(function(g, i){ + var frac = g.percent || 0; + var a0 = acc * Math.PI * 2 - Math.PI/2, a1 = (acc + frac) * Math.PI * 2 - Math.PI/2; + acc += frac; + var large = frac > 0.5 ? 1 : 0; + var x0 = cx + r*Math.cos(a0), y0 = cy + r*Math.sin(a0); + var x1 = cx + r*Math.cos(a1), y1 = cy + r*Math.sin(a1); + var xm = cx + (r+22)*Math.cos((a0+a1)/2), ym = cy + (r+22)*Math.sin((a0+a1)/2); + var anchor = xm > cx ? 'start' : 'end'; + var label = groups.length <= 8 ? ''+escHtml(g.count+' ('+fmtPct(g.percent||0)+')')+'' : ''; + if (frac <= 0) return label; + if (frac >= 0.9999) return '' + label; + return ''+escHtml(g.label+': '+g.count)+'' + label; + }).join(''); + return '' + paths + + ''+escHtml(fmtNum(total))+'' + + ''+escHtml(centerLabel || 'Total')+''; + } + function svgBars(groups, horizontal) { + var max = Math.max.apply(null, groups.map(function(g){ return g.value; }).concat([1])); + if (!horizontal) { + var bars = groups.map(function(g){ + var hgt = Math.max(3, (g.value/max)*120); + return ''+escHtml(g.label+': '+fmtNum(g.value))+'' + + '' + + ''+escHtml(fmtNum(g.value))+'' + + ''+escHtml(String(g.label).slice(0,10))+''; + }).join(''); + return '' + + groups.map(function(_, i){ return ''+bars.split('').join('').split('' + + ''+groups.map(function(g){ return ''; }).join('')+'
Data
'+escHtml(g.label)+''+escHtml(String(g.value))+'
'; + } + var rows = groups.map(function(g){ + var w = Math.max(2, (g.value/max)*100); + return '
' + + ''+escHtml(g.label)+'' + + '
' + + ''+escHtml(fmtNum(g.value))+'
'; + }).join(''); + return '
'+rows+'
'; + } + function svgLine(groups) { + var max = Math.max.apply(null, groups.map(function(g){ return g.value; }).concat([1])); + var W = 560, H = 200, P = 28; + var pts = groups.map(function(g, i){ + var x = groups.length > 1 ? P + i*(W-2*P)/(groups.length-1) : W/2; + var y = H - P - (g.value/max)*(H-2*P); + return {x: x, y: y, g: g}; + }); + var line = pts.map(function(p, i){ return (i ? 'L' : 'M') + p.x.toFixed(1)+' '+p.y.toFixed(1); }).join(' '); + var dots = pts.map(function(p){ return ''+escHtml(p.g.label+': '+fmtNum(p.g.value))+''; }).join(''); + return '' + dots + ''; + } + function renderChart() { + var c = state.viewConfig || {}; + var type = c.chart_type || 'doughnut'; + var gprop = findProp(c.group_by || c.chart_property); + if (!gprop && type !== 'number') { + return '
Select a Group by property in ⚙ View settings to build the chart.
'; + } + if (type === 'number') { + var agg = localAggregate(chartSpec()); + var lbl = c.title || ((c.measure && c.measure.property) || state.collection.name || 'Total'); + return '
' + + '
'+escHtml(lbl)+'
' + + '
'+escHtml(fmtNum(agg.total))+'
' + + '
'+escHtml((c.measure && c.measure.kind) || 'count')+' · '+agg.scanned+' row(s)
'; + } + var agg2 = localAggregate(chartSpec()); + if (!agg2.groups.length) return '
No data — adjust filters. ('+agg2.scanned+' rows scanned)
'; + var centerLbl = (c.style && c.style.center_value === 'sum') ? 'Sum' : 'Total'; + var viz = type === 'bar' ? svgBars(agg2.groups, false) : type === 'bar_horizontal' ? svgBars(agg2.groups, true) : type === 'line' ? svgLine(agg2.groups) : svgDonut(agg2.groups, agg2.total, centerLbl); + var showLegend = !(c.style && c.style.legend === false); + var legend = showLegend ? '
' + + agg2.groups.map(function(g){ return ''; }).join('') + '
' : ''; + var trunc = agg2.truncated ? '
200 groups shown — refine filters.
' : ''; + var drill = state.drilldown ? '
'+escHtml(state.drilldown.group)+''+state.drilldown.rows.length+' rows
' + + state.drilldown.rows.slice(0, 30).map(function(cp){ return '
'+escHtml(cp.icon&&cp.icon!=='file'?cp.icon+' ':'')+escHtml(cp.title||'Untitled')+'
'; }).join('') + '
' : ''; + return '
' + + '
'+viz+'
' + legend + trunc + drill + '
'; + } + + // ── Vue Timeline (échelles, today, seau sans date, drag des dates) ── + var TL_SCALES = ['hour','day','week','month','quarter','year']; + function tlDateProp() { return findProp(state.viewConfig.date_property || state.viewConfig.date_range_property) || firstPropOfTypes(['date']); } + function tlParse(v) { + if (!v) return null; + var s = String(v); + var m = s.match(/(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2}))?/); + if (m) { var end = null; if (s.indexOf('...') >= 0) { var p2 = s.split('...')[1]; var m2 = p2.match(/(\d{4}-\d{2}-\d{2})/); if (m2) end = m2[1]; } + return {start: m[1], end: end || m[1]}; } + return null; + } + function renderTimeline() { + var dp = tlDateProp(); + if (!dp) return '
Add a Date property to use the timeline view.
'; + var scale = TL_SCALES.indexOf(state.viewConfig.scale) >= 0 ? state.viewConfig.scale : 'month'; + var dated = [], undated = []; + state.pages.forEach(function(cp){ + var r = tlParse(pageVal(cp, dp)); + (r ? dated : undated).push({cp: cp, range: r}); + }); + var lim = Number(state.viewConfig.load_limit) || 200; + var shown = dated.slice(0, lim); + var h = '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + if (state.tlShowUndated && undated.length) { + h += '
' + undated.slice(0,30).map(function(x){ + return '
'+escHtml(x.cp.title||'Untitled')+'
'; + }).join('') + '
'; + } + if (!dated.length) return h + '
No dated rows. Set a date on a row to draw its bar.
'; + var all = dated.map(function(x){ return x.range.start; }).concat(dated.map(function(x){ return x.range.end; })).sort(); + var min = all[0], max = all[all.length-1]; + var t0 = new Date(min).getTime(), t1 = new Date(max).getTime(); + if (t1 <= t0) t1 = t0 + 864e5; + var todayIso = new Date().toISOString().slice(0,10); + var todayLeft = Math.min(100, Math.max(0, (new Date(todayIso).getTime()-t0)/(t1-t0)*100)); + // Tri : ordre persistant row_order puis titre. + var order = state.viewConfig.row_order || []; + shown.sort(function(a, b){ + var ia = order.indexOf(a.cp.id), ib = order.indexOf(b.cp.id); + if (ia >= 0 || ib >= 0) return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib); + return String(a.cp.title||'').localeCompare(String(b.cp.title||'')); + }); + h += '
'; + h += '
'+escHtml(min)+''+escHtml(max)+''+shown.length+' / '+dated.length+'
'; + h += '
'; + h += '
'; + shown.forEach(function(x){ + var s = new Date(x.range.start).getTime(), e = new Date(x.range.end).getTime(); + if (e < s) e = s; + var left = (s-t0)/(t1-t0)*100, width = Math.max((e-s)/(t1-t0)*100, 1.2); + var ro = READONLY_TYPES.indexOf(dp.prop_type) >= 0; + h += '
'; + h += ''+escHtml(x.cp.title||'Untitled')+''; + h += '
'; + h += '
' + + '' + + '
'; + h += '
'; + }); + h += '
'; + if (state.viewConfig.show_table) { + var tprops = (state.viewConfig.table_properties || []).map(findProp).filter(Boolean); + if (!tprops.length) tprops = state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3); + h += '
' + + tprops.map(function(p){ return ''; }).join('') + '' + + shown.map(function(x){ return '' + + tprops.map(function(p){ return ''; }).join('') + ''; }).join('') + + '
Page'+escHtml(p.name)+'
'+escHtml(x.cp.title||'Untitled')+''+escHtml(textValue(p, pageVal(x.cp,p)).slice(0,60))+'
'; + } + h += ''; + return h; + } + + // ── Vue Feed (cartes sociales : auteur, ancienneté, props, réactions) ── + function relTime(iso) { + if (!iso) return ''; + var t = new Date(String(iso).length <= 10 ? String(iso)+'T12:00:00' : iso).getTime(); + if (isNaN(t)) return ''; + var d = Date.now() - t; + var m = Math.floor(d/6e4), hh = Math.floor(d/36e5), dd = Math.floor(d/864e5); + if (m < 1) return 'just now'; + if (m < 60) return m+'m'; + if (hh < 24) return hh+'h'; + if (dd < 30) return dd+'d'; + return String(iso).slice(0,10); + } + function feedAuthor(cp) { + var pv = pvOf(cp); + var cand = cp.last_edited_by || cp.created_by; + var keys = Object.keys(pv || {}); + for (var i=0;i?'; + h += '
'; + h += '
'+av+''+who+' · '+escHtml(relTime(au.when))+ (cp.updated_at && cp.updated_at !== cp.created_at ? ' (edited)' : '') +''; + if (state.viewConfig.show_view_count !== false) h += '👁 '+(cp.view_count||0)+''; + h += '
'; + h += '

'+escHtml(cp.title||'Untitled')+'

'; + if (vprops.length) h += '
'+vprops.map(function(p){ + var t = textValue(p, pageVal(cp,p)); + return t ? ''+escHtml(p.name+': '+String(t).slice(0,40))+'' : ''; + }).join('')+'
'; + var excerpt = cp.excerpt || cp.content_snippet || ''; + if (excerpt) h += '

'+escHtml(String(excerpt).slice(0,280))+'

'; + h += '
'; + h += ''; + h += ''; + h += '
'; + }); + if (!rows.length) h += '
No posts yet — press + New to publish the first one.
'; + if (rows.length > lim) h += ''; + h += ''; + return h; + } + // ── Event binding ── function bindEvents() { bindViewBar(); @@ -887,6 +1614,419 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= if (state.viewType === 'calendar' && containerEl.querySelector('#db-cal-body')) refreshCalendar(); var galAdd = containerEl.querySelector('[data-gallery-add]'); if (galAdd) galAdd.onclick = function(){ addNewPage(); }; + bindNotionViews(); + } + + // ── Vues Notion : câblage des interactions ── + function bindNotionViews() { + // Chart : légende, drilldown, export PNG. + containerEl.querySelectorAll('[data-legend]').forEach(function(b){ + b.onclick = function(){ + var k = b.getAttribute('data-legend'); + state.chartHidden = state.chartHidden || {}; + if (state.chartHidden[k]) delete state.chartHidden[k]; else state.chartHidden[k] = 1; + render(); + }; + }); + containerEl.querySelectorAll('[data-chart-viz] [data-drill]').forEach(function(el){ + el.onclick = function(e){ + e.stopPropagation(); + var key = el.getAttribute('data-drill'); + var spec = chartSpec(); + var gprop = findProp(spec.group_by); + var rows = state.pages.filter(function(cp){ + if (!gprop) return true; + var t = textValue(gprop, pageVal(cp, gprop)) || '(empty)'; + return String(t) === String(key); + }); + state.drilldown = {group: key, rows: rows}; + render(); + }; + }); + containerEl.querySelectorAll('[data-drill-close]').forEach(function(b){ b.onclick = function(){ state.drilldown = null; render(); }; }); + containerEl.querySelectorAll('[data-drill-save]').forEach(function(b){ + b.onclick = function(){ + if (!state.drilldown) return; + var spec = chartSpec(); + fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({name: 'Drilldown ' + state.drilldown.group, view_type: 'table', config: {filters: [{property: spec.group_by, operator: 'is', value: state.drilldown.group}]}})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { state.views.push(d); applyView(d); state.drilldown = null; render(); toast('Saved as view'); } }); + }; + }); + var exp = containerEl.querySelector('[data-chart-export]'); + if (exp) exp.onclick = function(){ + var svg = containerEl.querySelector('[data-chart-viz] svg'); + if (!svg) { toast('Nothing to export'); return; } + try { + var xml = new XMLSerializer().serializeToString(svg); + var img = new Image(); + img.onload = function(){ + var cv = document.createElement('canvas'); + cv.width = 1200; cv.height = 800; + var ctx = cv.getContext('2d'); + ctx.fillStyle = '#191919'; ctx.fillRect(0, 0, cv.width, cv.height); + ctx.drawImage(img, 0, 0, cv.width, cv.height); + var a = document.createElement('a'); + a.download = 'chart.png'; a.href = cv.toDataURL('image/png'); a.click(); + }; + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml); + } catch (e) { toast('Export failed', 'error'); } + }; + // Timeline. + containerEl.querySelectorAll('[data-tl-scale]').forEach(function(s){ s.onchange = function(){ persistConfig({scale: s.value}); }; }); + var tlt = containerEl.querySelector('[data-tl-today]'); + if (tlt) tlt.onclick = function(){ render(); toast('Centered on today'); }; + var tln = containerEl.querySelector('[data-tl-nodate]'); + if (tln) tln.onclick = function(){ state.tlShowUndated = !state.tlShowUndated; render(); }; + var tlt2 = containerEl.querySelector('[data-tl-table]'); + if (tlt2) tlt2.onchange = function(){ persistConfig({show_table: tlt2.checked}); }; + var tla = containerEl.querySelector('[data-tl-add-date]'); + if (tla) tla.onclick = function(){ + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Due', prop_type:'date'})}) + .then(function(r){ return r.json(); }).then(function(d){ if (d.id) state.properties.push(d); render(); }); + }; + containerEl.querySelectorAll('[data-tl-setdate]').forEach(function(b){ + b.onclick = function(){ + var cp = pageById(parseInt(b.getAttribute('data-tl-setdate'))); + var dp = tlDateProp(); + if (!cp || !dp) return; + var v = prompt('Date (YYYY-MM-DD):', new Date().toISOString().slice(0,10)); + if (!v) return; + setPageVal(cp, dp, v); render(); savePage(cp); + }; + }); + // Drag des barres / poignées (écriture via savePage — principe 4). + containerEl.querySelectorAll('[data-tl-bar]').forEach(function(bar){ + var pid = parseInt(bar.getAttribute('data-tl-bar')); + var cp = pageById(pid), dp = tlDateProp(); + if (!cp || !dp || READONLY_TYPES.indexOf(dp.prop_type) >= 0) { bar.style.cursor = 'not-allowed'; return; } + bar.onpointerdown = function(e){ + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + var track = bar.parentElement; + var trect = track.getBoundingClientRect(); + var rng = tlParse(pageVal(cp, dp)) || {start: new Date().toISOString().slice(0,10), end: new Date().toISOString().slice(0,10)}; + var t0 = new Date(rng.start).getTime(), span = Math.max(new Date(rng.end).getTime() - t0, 864e5); + var grip = e.target.getAttribute('data-tl-grip'); + var startX = e.clientX, origStart = t0, origEnd = new Date(rng.end).getTime(); + var dayMs = 864e5; + function iso(ms){ return new Date(ms).toISOString().slice(0,10); } + function onMove(ev){ + var dx = (ev.clientX - startX) / Math.max(trect.width, 1); + // Échelle temporelle de la vue complète pour convertir le geste. + var all = state.pages.map(function(p){ var r2 = tlParse(pageVal(p, dp)); return r2 ? [r2.start, r2.end] : null; }).filter(Boolean); + var flat = []; all.forEach(function(a){ flat.push(a[0], a[1]); }); flat.sort(); + var vt0 = flat.length ? new Date(flat[0]).getTime() : t0; + var vt1 = flat.length ? new Date(flat[flat.length-1]).getTime() : t0 + span; + if (vt1 <= vt0) vt1 = vt0 + 864e5; + var delta = dx * (vt1 - vt0); + delta = Math.round(delta / dayMs) * dayMs; + var ns = origStart, ne = origEnd; + if (grip === 'l') ns = Math.min(origStart + delta, ne); + else if (grip === 'r') ne = Math.max(origEnd + delta, ns); + else { ns = origStart + delta; ne = origEnd + delta; } + bar.title = iso(ns) + ' → ' + iso(ne); + } + function onUp(ev){ + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + var dx = (ev.clientX - startX) / Math.max(trect.width, 1); + var all2 = state.pages.map(function(p){ var r2 = tlParse(pageVal(p, dp)); return r2 ? [r2.start, r2.end] : null; }).filter(Boolean); + var flat2 = []; all2.forEach(function(a){ flat2.push(a[0], a[1]); }); flat2.sort(); + var vt0 = flat2.length ? new Date(flat2[0]).getTime() : origStart; + var vt1 = flat2.length ? new Date(flat2[flat2.length-1]).getTime() : origEnd; + if (vt1 <= vt0) vt1 = vt0 + 864e5; + var delta = Math.round(dx * (vt1 - vt0) / dayMs) * dayMs; + if (!delta) { render(); return; } + var ns = origStart, ne = origEnd; + if (grip === 'l') ns = Math.min(origStart + delta, ne); + else if (grip === 'r') ne = Math.max(origEnd + delta, ns); + else { ns = origStart + delta; ne = origEnd + delta; } + function iso2(ms){ return new Date(ms).toISOString().slice(0,10); } + setPageVal(cp, dp, ns === ne ? iso2(ns) : iso2(ns) + '...' + iso2(ne)); + render(); + savePage(cp); + } + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }; + }); + // Feed. + containerEl.querySelectorAll('[data-feed-react]').forEach(function(b){ + b.onclick = function(e){ + e.stopPropagation(); + var id = b.getAttribute('data-feed-react'); + state.feedReacts = state.feedReacts || {}; + var arr = state.feedReacts[id] || []; + var mine = arr.indexOf('you'); + if (mine >= 0) arr.splice(mine, 1); else arr.push('you'); + state.feedReacts[id] = arr; + render(); + }; + }); + containerEl.querySelectorAll('[data-feed-comment]').forEach(function(b){ + b.onclick = function(e){ e.stopPropagation(); openPageInSidePeek(parseInt(b.getAttribute('data-feed-comment'))); }; + }); + var fm = containerEl.querySelector('[data-feed-more]'); + if (fm) fm.onclick = function(){ state.feedLimit = (state.feedLimit || 20) + 20; render(); }; + // Map. + var mpBy = containerEl.querySelector('[data-map-by]'); + if (mpBy) mpBy.onchange = function(){ var p = findProp(mpBy.value); persistConfig({place_property: p ? p.name : ''}); }; + var mpAdd = containerEl.querySelector('[data-map-add-place]'); + if (mpAdd) mpAdd.onclick = function(){ + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Place', prop_type:'place'})}) + .then(function(r){ return r.json(); }).then(function(d){ if (d.id) state.properties.push(d); render(); }); + }; + var mpRe = containerEl.querySelector('[data-map-recenter]'); + if (mpRe) mpRe.onclick = function(){ bindMap(); }; + if (state.viewType === 'map') bindMap(); + // Dashboard. + containerEl.querySelectorAll('[data-dash-mode]').forEach(function(b){ + b.onclick = function(){ state.dashMode = b.getAttribute('data-dash-mode'); render(); }; + }); + var dAdd = containerEl.querySelector('[data-dash-add]'); + if (dAdd) dAdd.onclick = function(){ openDashAddWidget(dAdd); }; + containerEl.querySelectorAll('[data-dash-del]').forEach(function(b){ + b.onclick = function(){ + var lid = b.getAttribute('data-dash-del'); + state.dashWidgets = (state.dashWidgets || []).filter(function(w){ return String(w._lid) !== String(lid); }); + render(); + }; + }); + containerEl.querySelectorAll('[data-dash-dup]').forEach(function(b){ + b.onclick = function(){ + var lid = b.getAttribute('data-dash-dup'); + var src = (state.dashWidgets || []).filter(function(w){ return String(w._lid) === String(lid); })[0]; + if (src && (state.dashWidgets || []).length < 12) { + var copy = JSON.parse(JSON.stringify(src)); + copy.id = null; copy._lid = 'l' + Date.now(); + copy.title = (copy.title || 'Widget') + ' copy'; + state.dashWidgets.push(copy); render(); + } + }; + }); + containerEl.querySelectorAll('[data-dash-open]').forEach(function(b){ + b.onclick = function(e){ + e.preventDefault(); + var lid = b.getAttribute('data-dash-open'); + var w = (state.dashWidgets || []).filter(function(x){ return String(x._lid) === String(lid); })[0]; + if (w && w.source_view_id) { + var target = state.views.filter(function(v){ return String(v.id) === String(w.source_view_id); })[0]; + if (target) { applyView(target); render(); return; } + } + toast('Source view is in another base'); + }; + }); + var dSave = containerEl.querySelector('[data-dash-save]'); + if (dSave) dSave.onclick = function(){ + var v = activeView(); + if (!v) return; + var payload = (state.dashWidgets || []).map(function(w, i){ + return {id: (typeof w.id === 'number' ? w.id : null), row_index: Math.floor(i/4), position: i, + col_span: w.col_span || 1, height_units: w.height_units || 2, + source_collection_id: w.source_collection_id || collectionId, + source_view_id: w.source_view_id || null, + inline_config: w.inline_config || {}}; + }); + fetch('/db/'+collectionId+'/views/'+v.id+'/widgets', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({widgets: payload, global_filters: state.viewConfig.global_filters || []})}) + .then(function(){ toast('Dashboard saved'); dashFetchWidgets().then(render); }); + }; + var dGfAdd = containerEl.querySelector('[data-dash-gf-add]'); + if (dGfAdd) dGfAdd.onclick = function(){ + var name = prompt('Property name for the global filter:'); + if (!name) return; + var val = prompt('Value (contains):', ''); + var gf = (state.viewConfig.global_filters || []).slice(); + gf.push({property: name, operator: 'contains', value: val || '', excluded_widget_ids: []}); + persistConfig({global_filters: gf}); + }; + containerEl.querySelectorAll('[data-dash-gf-del]').forEach(function(a){ + a.onclick = function(e){ + e.preventDefault(); + var gf = (state.viewConfig.global_filters || []).slice(); + gf.splice(parseInt(a.getAttribute('data-dash-gf-del')), 1); + persistConfig({global_filters: gf}); + }; + }); + containerEl.querySelectorAll('[data-dash-tpl]').forEach(function(b){ + b.onclick = function(){ + var kind = b.getAttribute('data-dash-tpl'); + if (kind === 'agent') { toast('✨ Ask the agent: “build me a status dashboard for this base”'); return; } + var g = firstPropOfTypes(['status','select']); + state.dashWidgets = [ + {_lid: 'l1', title: 'By status', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'chart', group_by: g ? g.name : ''}}, + {_lid: 'l2', title: 'All rows', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'table'}}, + {_lid: 'l3', title: 'Milestones', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'table'}} + ]; + render(); + }; + }); + if (state.viewType === 'dashboard') { + if (!state.dashWidgets) { dashFetchWidgets().then(function(){ if (state.viewType === 'dashboard') render(); }); } + else bindDashboard(); + } + // Form header + builder. + containerEl.querySelectorAll('[data-form-preview]').forEach(function(b){ + b.onclick = function(e){ e.preventDefault(); e.stopPropagation(); openFormPreview(); }; + }); + containerEl.querySelectorAll('[data-form-share]').forEach(function(b){ + b.onclick = function(e){ e.preventDefault(); e.stopPropagation(); openFormShare(b); }; + }); + var ft = containerEl.querySelector('[data-form-title]'); + if (ft) ft.onchange = function(){ persistConfig({title: ft.value}, {silent: true}); formSave(true); }; + var fd = containerEl.querySelector('[data-form-desc]'); + if (fd) fd.onchange = function(){ persistConfig({description: fd.value}, {silent: true}); formSave(true); }; + containerEl.querySelectorAll('[data-fq-label]').forEach(function(inp){ + inp.onchange = function(){ + var card = inp.closest('[data-fq]'); + var q = state.formQs[parseInt(card.getAttribute('data-fq'))]; + if (!q) return; + var nv = inp.value.trim(); + if (q.sync_label !== false && nv && nv !== q.property_name) { + if (confirm('Rename property “'+q.property_name+'” to “'+nv+'” everywhere?')) { q.label_override = nv; } + else { q.sync_label = false; q.label_override = nv; } + } else q.label_override = nv === q.property_name ? null : nv; + formSave(true).then(render); + }; + }); + containerEl.querySelectorAll('[data-fq-up]').forEach(function(b){ + b.onclick = function(){ + var i = parseInt(b.getAttribute('data-fq-up')); + if (i <= 0) return; + var t = state.formQs[i-1]; state.formQs[i-1] = state.formQs[i]; state.formQs[i] = t; + formSave(true).then(render); + }; + }); + containerEl.querySelectorAll('[data-fq-down]').forEach(function(b){ + b.onclick = function(){ + var i = parseInt(b.getAttribute('data-fq-down')); + if (i >= state.formQs.length - 1) return; + var t = state.formQs[i+1]; state.formQs[i+1] = state.formQs[i]; state.formQs[i] = t; + formSave(true).then(render); + }; + }); + containerEl.querySelectorAll('[data-fq-menu]').forEach(function(b){ b.onclick = function(){ openQuestionMenu(b); }; }); + var fa = containerEl.querySelector('[data-form-addq]'); + if (fa) fa.onclick = function(){ + var name = prompt('New question / property name:'); + if (!name) return; + var type = prompt('Type (text, number, select, date, checkbox, url, email, phone, place):', 'text') || 'text'; + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name: name, prop_type: type})}) + .then(function(r){ return r.json(); }) + .then(function(d){ + if (d.id) state.properties.push(d); + state.formQs.push({property_name: name, position: state.formQs.length, label_override: null, sync_label: true, help_text: '', required: false, widget: 'auto', max_selections: null, conditional: null}); + formSave(true).then(render); + }); + }; + containerEl.querySelectorAll('[data-form-btn-text],[data-form-confirm-title],[data-form-confirm-body]').forEach(function(inp){ + inp.onchange = function(){ + var sub = Object.assign({}, state.viewConfig.submit_screen); + var bt = containerEl.querySelector('[data-form-btn-text]'); + var ct = containerEl.querySelector('[data-form-confirm-title]'); + var cb = containerEl.querySelector('[data-form-confirm-body]'); + if (bt) sub.button_text = bt.value; + if (ct) sub.confirmation_title = ct.value; + if (cb) sub.confirmation_body = cb.value; + persistConfig({submit_screen: sub}, {silent: true}); formSave(true); + }; + }); + if (state.viewType === 'form' && !state.formQs) { formFetchQs().then(function(){ if (state.viewType === 'form') render(); }); } + } + function openDashAddWidget(anchor) { + var h = '
Add a widget
'; + h += '
Views of this base first, then another source, then a new inline view.
'; + state.views.filter(function(v){ return v.view_type !== 'dashboard'; }).forEach(function(v){ + h += '
'+viewSvg(v.view_type)+' '+escHtml(v.name)+'
'; + }); + h += '
+ New chart for this dashboard
'; + h += '
+ New table for this dashboard
'; + var pop = openPop(anchor, h); + function add(w){ + state.dashWidgets = state.dashWidgets || []; + if (state.dashWidgets.length >= 12) { toast('12 widgets maximum'); return; } + w._lid = 'l' + Date.now(); + state.dashWidgets.push(w); pop.remove(); render(); + } + pop.querySelectorAll('[data-dw]').forEach(function(el){ + el.onclick = function(){ + var v = state.views.filter(function(x){ return String(x.id) === el.getAttribute('data-dw'); })[0]; + if (v) add({title: v.name, source_collection_id: collectionId, source_view_id: v.id, inline_config: {}}); + }; + }); + pop.querySelectorAll('[data-dw-new]').forEach(function(el){ + el.onclick = function(){ + var vt = el.getAttribute('data-dw-new'); + var g = firstPropOfTypes(['status','select']); + add({title: 'New ' + vt, source_collection_id: collectionId, source_view_id: null, + inline_config: vt === 'chart' ? {view_type: 'chart', group_by: g ? g.name : ''} : {view_type: 'table'}}); + }; + }); + } + function openQuestionMenu(anchor) { + var card = anchor.closest('[data-fq]'); + var q = state.formQs[parseInt(card.getAttribute('data-fq'))]; + if (!q) return; + var h = '
'+escHtml(q.property_name)+'
' + + '
' + + '
' + + '
' + + '
' + + '
⧉ Duplicate (new property)
' + + '
Remove from form
'; + var pop = openPop(anchor, h); + pop.querySelector('[data-qm="required"]').onchange = function(){ q.required = this.value === '1'; formSave(true).then(render); }; + pop.querySelector('[data-qm="sync"]').onchange = function(){ q.sync_label = this.value === '1'; if (q.sync_label) q.label_override = null; formSave(true).then(render); }; + pop.querySelector('[data-qm-text]').onchange = function(){ q.help_text = this.value; formSave(true).then(render); }; + pop.querySelector('[data-qm-cond]').onchange = function(){ + var m = this.value.match(/^\s*(.+?)\s*=\s*(.+?)\s*$/); + q.conditional = m ? {if_property: m[1], equals: m[2], then: 'show'} : null; + formSave(true).then(render); + }; + pop.querySelector('[data-qm-dup]').onclick = function(){ + var base = q.property_name + ' 2'; + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name: base, prop_type: 'text'})}) + .then(function(){ state.formQs.push(Object.assign({}, q, {property_name: base, label_override: null})); pop.remove(); formSave(true).then(render); }); + }; + pop.querySelector('[data-qm-del]').onclick = function(){ + if (!confirm('Remove “'+q.property_name+'” from the form? (property kept)')) return; + state.formQs = state.formQs.filter(function(x){ return x !== q; }); + pop.remove(); formSave(true).then(render); + }; + } + function openFormPreview() { + var c = state.viewConfig || {}; + var h = '
'+escHtml(c.title || 'Form')+' (preview — not submittable)
'; + (state.formQs || []).forEach(function(q){ + if (q.conditional) h += '
Shown if '+escHtml(q.conditional.if_property+' = '+q.conditional.equals)+'
'; + h += ''; + }); + var sub = c.submit_screen || {}; + h += '
'; + openPop(containerEl.querySelector('[data-form-preview]') || containerEl, h); + } + function openFormShare(anchor) { + var c = state.viewConfig || {}; + var sh = c.sharing || {}; + var h = '
Share form
' + + '
' + + '
' + + '
' + + '
Public link: /f/<token> (pipeline v6.8 unchanged). “Closed” answers 410.
'; + var pop = openPop(anchor, h); + pop.querySelectorAll('[data-sh]').forEach(function(el){ + el.onchange = function(){ + var nsh = Object.assign({}, state.viewConfig.sharing); + if (el.getAttribute('data-sh') === 'audience') nsh.audience = el.value; + if (el.getAttribute('data-sh') === 'anonymous') nsh.anonymous = el.value === '1'; + if (el.getAttribute('data-sh') === 'branding') nsh.branding = el.value === '1'; + if (nsh.audience === 'web') nsh.anonymous = true; + persistConfig({sharing: nsh}, {silent: true}); formSave(true); + }; + }); } function bindResize(handle) { @@ -919,6 +2059,7 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= var val = pageVal(cp, prop); var t = prop.prop_type; if (t === 'person') { openPersonPicker(cellEl, cp, prop); return; } + if (t === 'place') { openPlaceEditor(cellEl, cp, prop); return; } var input; cellEl.innerHTML = ''; cellEl.classList.add('db-cell-editing'); if (t === 'checkbox') { @@ -1046,7 +2187,7 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= '

Add a property

'+ '
'+ '
'+ - '
'+ + '
'+ '
'+ '
'+ '
'+ @@ -1176,6 +2317,39 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= state.properties.forEach(function(p){ h += '
'; }); + } else if (t === 'chart') { + h += '
'; + h += '
'+propSelect('group_by', state.viewConfig.group_by || state.viewConfig.chart_property, function(){ return true; })+'
'; + h += '
'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(){ return true; })+'
'; + h += '
'; + h += '
'+propSelect('measure_prop', (state.viewConfig.measure&&state.viewConfig.measure.property)||'', function(p){ return p.prop_type==='number'; })+'
'; + h += '
'; + h += '
'; + h += '
'; + h += '
Click a segment for the drilldown table. Chart is read-only (Notion parity).
'; + } else if (t === 'timeline') { + h += '
'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'
'; + h += '
'; + h += '
'; + h += '
'; + h += '
Drag bars / edges to rewrite dates. Vertical order is display-only.
'; + } else if (t === 'feed') { + h += '
'; + h += '
Visible properties
'; + state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){ + var vis = state.viewConfig.visible_properties; + var on = !vis || !vis.length ? true : vis.some(function(x){ return String(x)===String(p.id) || x===p.name; }); + h += ''+escHtml(p.name)+''; + }); + h += '
'; + } else if (t === 'map') { + h += '
'+propSelect('place_property', state.viewConfig.place_property, function(p){ return p.prop_type==='place'; })+'
'; + h += '
100 pins max. Click a pin to open the row. Unresolved places are counted, not plotted.
'; + } else if (t === 'dashboard') { + h += '
Widgets reference views of this or another base. 4 per row, 12 max. Global filters apply by property name to widgets that have it.
'; + } else if (t === 'form') { + h += '
'; + h += '
Build questions in the view (••• per question). Sync on = renaming edits the property.
'; } else { h += '
List uses the first properties. Configure card properties in a Board or Gallery view.
'; } @@ -1189,7 +2363,22 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= // Wire selects / inputs pop.querySelectorAll('[data-pop-field]').forEach(function(el){ el.onchange = function(){ - var patch = {}; patch[this.dataset.popField] = this.value; persistConfig(patch); + var f = this.dataset.popField, val = this.value, patch = {}; + if (f === 'measure_kind') { persistConfig({measure: Object.assign({}, state.viewConfig.measure, {kind: val})}); return; } + if (f === 'measure_prop') { var p = findProp(val); persistConfig({measure: Object.assign({}, state.viewConfig.measure, {kind: (state.viewConfig.measure&&state.viewConfig.measure.kind)||'count', property: p ? p.name : null})}); return; } + if (f === 'omit_zero' || f === 'cumulative' || f === 'show_table' || f === 'show_view_count') { patch[f] = (val === '1'); persistConfig(patch); return; } + if (f === 'group_order') { persistConfig({group_order: val, order: {by: val, direction: 'asc'}}); return; } + if (f === 'load_limit') { patch[f] = Number(val) || 200; persistConfig(patch); return; } + patch[f] = val; persistConfig(patch); + }; + }); + pop.querySelectorAll('[data-feedprop]').forEach(function(el){ + el.onclick = function(){ + var id = this.dataset.feedprop; + var vis = (state.viewConfig.visible_properties || []).slice(); + var idx = vis.findIndex(function(x){ return String(x) === String(id); }); + if (idx >= 0) vis.splice(idx, 1); else vis.push(Number(id)); + persistConfig({visible_properties: vis}); }; }); pop.querySelectorAll('[data-wip]').forEach(function(el){ @@ -1233,18 +2422,59 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v= }); } + function defaultConfigFor(t) { + if (t === 'chart') { + var g = firstPropOfTypes(['status','select']); + return {version:2, chart_type:'doughnut', group_by:(g && g.name) || '', measure:{kind:'count'}, omit_zero:false, style:{palette:'property', height:'medium', data_labels:'percent', legend:true, center_value:'total'}}; + } + if (t === 'timeline') { + var dp = firstPropOfTypes(['date']); + return {version:2, date_property:(dp && dp.name) || '', scale:'month', show_table:false, table_properties:[], load_limit:200, row_order:[]}; + } + if (t === 'feed') return {version:2, order:'last_edited_desc', show_view_count:true, excerpt:{enabled:true, max_chars:280}, visible_properties:[]}; + if (t === 'map') { + var pp = firstPropOfTypes(['place']); + return {version:2, place_property:(pp && pp.name) || '', cluster:true, visible_properties:[]}; + } + if (t === 'dashboard') return {version:2, global_filters:[], row_heights:{}, mode:'view'}; + if (t === 'form') return {version:2, title:(state.collection.name||'Form'), description:'', sharing:{audience:'workspace', anonymous:false, respondent_access:'none', branding:true}, submit_screen:{button_text:'Envoyer', button_color:'blue', confirmation_title:'Merci !', confirmation_body:'Votre réponse a été enregistrée.'}}; + return {}; + } function openAddViewMenu(anchor) { - var h = '
Add a view
'; - ['table','board','calendar','gallery','list'].forEach(function(t){ - h += '
'+viewSvg(t)+' '+t.charAt(0).toUpperCase()+t.slice(1)+'
'; + var h = '
Add a view
'; + FD_VIEW_ORDER.forEach(function(t){ + var missing = ''; + if (t === 'timeline' && !firstPropOfTypes(['date'])) missing = ' · date needed'; + if (t === 'map' && !firstPropOfTypes(['place'])) missing = ' · +place'; + h += '
'+viewSvg(t)+' '+fdViewLabel(t)+missing+'
'; }); + h += '
'; var pop = openPop(anchor, h); pop.querySelectorAll('[data-addview]').forEach(function(el){ el.onclick = function(){ var t = this.dataset.addview; pop.remove(); - fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:t.charAt(0).toUpperCase()+t.slice(1), view_type:t, config:{}})}) - .then(function(r){ return r.json(); }) - .then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } }); + // Prérequis de schéma (§7.3) : map sans place -> création assistée. + function createView(cfg) { + fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:fdViewLabel(t), view_type:t, config:cfg})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } }); + } + if (t === 'timeline' && !firstPropOfTypes(['date'])) { + if (confirm('Timeline needs a Date property. Create one now?')) { + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Due', prop_type:'date'})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) state.properties.push(d); createView(defaultConfigFor(t)); }); + } + return; + } + if (t === 'map' && !firstPropOfTypes(['place'])) { + if (!confirm('This view needs a Place property — create it?')) return; + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Place', prop_type:'place'})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) state.properties.push(d); createView(defaultConfigFor(t)); }); + return; + } + createView(defaultConfigFor(t)); }; }); } diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index 2d9e547..8b61da4 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -21,6 +21,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. {id:'audio',name:'Audio',icon:'🎵'}, ]}, {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}, + {name:'TEMPLATES',items:[{id:'template',name:'Templates (base, ligne, blocs)',icon:'🧩'}]}, {name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]}, {name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}]; @@ -1492,6 +1493,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. tplSaving:false, // ── DB Templates ── dbTplOpen:false,dbTplLoading:false,dbTemplates:[], + // ── Unified template picker (registre unifié §7.1 : la pilule + // « Templates » ouvre TOUJOURS ce panneau — jamais un no-op, P5) ── + tplOpen:false,tplLoading:false,tplItems:[],tplQuery:'',tplKind:'all', + tplError:'',tplSelected:null,tplPreviewBlocks:[],tplPreviewTitle:'', + tplPreviewWarnings:[],tplVarDefs:[],tplVars:{},tplApplying:false, + tplRestrict:null,tplKindOptionsList:['page','database','row','blocks','all'], // ── Version history ── versionsOpen:false,versionsLoading:false,versionsList:[], // ── Backlinks ── @@ -2560,7 +2567,6 @@ applyAIBlocks(text){ self.dbTemplates=d.templates||[]; }catch(e){ self.dbTemplates=[]; } self.dbTplLoading=false; - self.$nextTick(()=>{ const f=document.getElementById('fd-dbtpl'); if(f) setTimeout(()=>{f.style.opacity='1';},10); }); }, closeDbTemplatePicker(){ this.dbTplOpen=false; }, async createDbFromTemplate(tplName){ @@ -2588,6 +2594,139 @@ applyAIBlocks(text){ } }catch(e){ this.showToast('Error: '+e.message); } }, + // ── Unified template picker (§7.1) ── + openTplPicker(opts){ + opts=opts||{}; + this.tplRestrict=(opts.kinds&&opts.kinds.length)?opts.kinds:null; + this.tplKindOptionsList=this.tplRestrict?['all'].concat(this.tplRestrict):['page','database','row','blocks','all']; + this.tplKind='all'; + this.gsMoreOpen=false; this.moreOpen=false; + this.tplOpen=true; this.tplSelected=null; this.tplPreviewBlocks=[]; + this.tplPreviewTitle=''; this.tplPreviewWarnings=[]; this.tplVarDefs=[]; + this.tplVars={}; this.tplError=''; + this.loadTplList(); + }, + closeTplPicker(){ this.tplOpen=false; }, + // Build CSP : une seule expression par handler (pas de `;`). + setTplKind(k){ this.tplKind=k; this.loadTplList(); }, + setTplVar(name,value){ this.tplVars[name]=value; this.previewTpl(); }, + tplKindLabel(k){ return {all:'Tous',page:'Pages',blocks:'Blocs',row:'Lignes',database:'Bases'}[k]||k; }, + async loadTplList(){ + this.tplLoading=true; this.tplError=''; + var self=this; + try{ + var p=new URLSearchParams(); + if(this.tplKind!=='all') p.set('kind',this.tplKind); + if(this.tplQuery) p.set('q',this.tplQuery); + const r=await fetch('/api/templates?'+p.toString(),{credentials:'same-origin'}); + if(!r.ok) throw new Error('HTTP '+r.status); + const d=await r.json(); + var items=d.templates||[]; + // Vue restreinte (slash /template : base, ligne, blocs — pas de page). + if(this.tplRestrict) items=items.filter(function(t){return self.tplRestrict.indexOf(t.kind)>=0;}); + self.tplItems=items; + }catch(e){ self.tplItems=[]; self.tplError='Chargement impossible : '+e.message; } + self.tplLoading=false; + }, + async selectTpl(t){ + this.tplSelected=t; this.tplVars={}; this.tplPreviewWarnings=[]; + this.tplPreviewBlocks=[]; this.tplPreviewTitle=t.name; + try{ + const r=await fetch('/api/templates/'+encodeURIComponent(t.id),{credentials:'same-origin'}); + if(!r.ok) throw new Error('HTTP '+r.status); + const d=await r.json(); + this.tplVarDefs=d.variables||[]; + this.tplPreviewBlocks=(d.blocks_preview||[]); + this.tplPreviewTitle=d.name; + this.previewTpl(); + }catch(e){ this.tplPreviewWarnings=['Aperçu indisponible : '+e.message]; } + }, + async previewTpl(){ + if(!this.tplSelected) return; + try{ + const csrf = getCsrf(); + const r=await fetch('/api/templates/'+encodeURIComponent(this.tplSelected.id)+'/preview',{ + method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf}, + credentials:'same-origin',body:JSON.stringify({variables:this.tplVars}) + }); + if(!r.ok) return; + const d=await r.json(); + this.tplPreviewTitle=d.title||this.tplSelected.name; + this.tplPreviewBlocks=d.blocks_preview||this.tplPreviewBlocks; + this.tplPreviewWarnings=d.warnings||[]; + }catch(e){} + }, + async applyTpl(){ + if(!this.tplSelected||this.tplApplying) return; + this.tplApplying=true; + const self=this; const t=this.tplSelected; + const csrf = getCsrf(); + try{ + var body={variables:this.tplVars,context:{}}; + if(t.kind==='blocks'||t.kind==='page'){ + body.page_id=this.pid; body.mode='fill'; + } + if(t.kind==='page'){ + // Le titre suit le template ; {{title}} = sujet saisi, sinon le + // titre actuel de la page s'il est significatif, sinon le nom du + // template (côté serveur). + var pt=(this.pageTitle||'').trim(); + if(!body.variables.title && pt && pt!=='Untitled' && pt!=='Sans titre'){ body.variables.title=pt; } + } + if(t.kind==='database'){ + // Base INLINE dans le document courant (pas de navigation) : + // même patron que la pilule « Database » (createDbFromTemplate). + body.parent_page_id=this.pid; + } + // kind=row : le serveur utilise la base propriétaire du template ; + // kind=database hors page : une base neuve est créée (navigation vers elle). + const r=await fetch('/api/templates/'+encodeURIComponent(t.id)+'/instantiate',{ + method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf}, + credentials:'same-origin',body:JSON.stringify(body) + }); + if(!r.ok){ const txt=await r.text(); throw new Error('HTTP '+r.status+' '+txt.slice(0,200)); } + const d=await r.json(); + (d.warnings||[]).forEach(function(w){ self.showToast(w,'error'); }); + if(t.kind==='blocks'&&d.blocks){ + const idx=self._tplInsertIndex(); + const fresh=d.blocks.map(function(b){ b.id=genId(); return b; }); + self.blocks.splice.apply(self.blocks,[idx,0].concat(fresh)); + self.dirty=true; self.autoSave(); self.render(); + self.showToast('Blocs insérés depuis « '+t.name+' »'); + }else if(t.kind==='database'&&d.created_id){ + const idx=self._tplInsertIndex(); + const nb=self.mkB('embed','',{embed_type:'collection',collection_id:parseInt(d.created_id,10)}); + self.blocks.splice(idx,0,nb); + self.dirty=true; self.autoSave(); self.render(); + self.showToast('Base « '+t.name+' » insérée dans le document'); + }else if(body.page_id){ + // Page courante remplie en place (pas de navigation, §7.1). + const fresh=(d.blocks||[]).map(function(b){ if(!b.id) b.id=genId(); return b; }); + if(fresh.length) self.blocks=fresh; + if(d.title){ self.pageTitle=d.title; var te=document.getElementById('_titleEl'); if(te){ te.textContent=d.title; te.classList.toggle('empty',!(d.title||'').trim()); } } + self.dirty=true; self.autoSave(); self.render(); + self.showToast('Page remplie depuis « '+t.name+' »'); + }else if(t.kind==='row'&&d.url){ + // Appliqué depuis un document : on ne quitte pas la page, on + // signale la ligne créée dans sa base. + self.showToast('Ligne « '+t.name+' » créée dans sa base'); + }else if(d.url){ + if(window.fdNavigate) window.fdNavigate(d.url); else window.location=d.url; + }else{ + self.showToast('Créé depuis « '+t.name+' »'); + } + // Undo via le toast : le run est journalisé côté serveur. + self.tplOpen=false; + }catch(e){ self.showToast('Échec : '+e.message,'error'); } + self.tplApplying=false; + }, + _tplInsertIndex(){ + try{ + const ab=this.getActiveBlock(); + if(ab&&typeof ab.idx==='number') return ab.idx+1; + }catch(e){} + return this.blocks.length; + }, async createForm(){ const name=prompt('Form name:','New Form'); if(!name||!name.trim())return; @@ -2721,6 +2860,7 @@ applyAIBlocks(text){ }, applySlash(id){ const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; + if(id==='template'){ this.openTplPicker({kinds:['database','row','blocks']}); return; } if(id==='database'){ this.openDbTemplatePicker(idx); return; } if(id==='synced'){ const block=this.blocks[idx];this.sync();this.pushHistory(); diff --git a/tests/test_fd_templates.py b/tests/test_fd_templates.py new file mode 100644 index 0000000..c468567 --- /dev/null +++ b/tests/test_fd_templates.py @@ -0,0 +1,386 @@ +"""Templates unifiés façon Notion — tests de la refonte (§17.2 du cahier des charges). + +Couvre : migrations 48/49, backfill idempotent, presets système, +résolution des variables (aucun {{token}} survivant), dégradation sur +propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée, +API interne et annulation (undo). +""" +import json + +import pytest + +from app.db import get_conn +from app.services.fd_templates import TemplateService, registry_ready + + +@pytest.fixture +def actor(): + return {"id": 1, "login": "tester", "full_name": "Tester", "is_admin": 1} + + +def _ensure_ws(conn, actor): + """Workspace valide (FK) pour les inserts directs des tests.""" + from app.services.fd_templates import _ensure_workspace + return _ensure_workspace(conn, actor) + + +def _db_version(): + with get_conn() as conn: + return conn.execute("SELECT COALESCE(MAX(version),0) FROM schema_version").fetchone()[0] + + +def test_migrations_48_49_applied(client): + assert registry_ready(get_conn().__enter__()) or True + with get_conn() as conn: + assert registry_ready(conn) + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + assert {"templates", "template_runs", "template_recurrences"} <= tables + assert _db_version() >= 49 + + +def test_backfill_idempotent_and_presets(client, actor): + with get_conn() as conn: + first = TemplateService.backfill_registry(conn) + n1 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0] + second = TemplateService.backfill_registry(conn) + n2 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0] + assert n1 == n2 + assert sum(second.values()) == 0 + # Silos legacy catalogués, jamais détruits. + legacy_db = conn.execute("SELECT COUNT(*) FROM database_templates").fetchone()[0] + reg_db = conn.execute( + "SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0] + assert reg_db == legacy_db and legacy_db > 0 + # Galerie système : au moins 5 presets utilisables. + n_presets = TemplateService.ensure_system_presets(conn) + sys_rows = conn.execute( + "SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0] + assert sys_rows >= 5 + assert TemplateService.ensure_system_presets(conn) == 0 + assert first["legacy_database"] >= 0 + + +def test_instantiate_page_resolves_variables(client, actor): + with get_conn() as conn: + dto = TemplateService.create_template(conn, actor, { + "kind": "page", "name": "Revue", "scope": "personal", + "title_template": "Revue du {{today}} — {{me}}", + "blocks": [{"id": "b1", "type": "paragraph", "content": "vu le {{today}}"}], + }) + conn.commit() + result = TemplateService.instantiate(conn, actor, dto["id"], {}, {}) + assert result["created_type"] == "page" + assert "{{" not in result["url"] and result["url"].startswith("/pages/") + page = conn.execute("SELECT * FROM pages WHERE id=?", + (int(result["created_id"]),)).fetchone() + assert "{{" not in (page["title"] or "") + assert "{{" not in (page["content"] or "") + assert "Revue du 20" in page["title"] # date du jour résolue + runs = conn.execute("SELECT * FROM template_runs WHERE template_id=?", + (dto["id"],)).fetchall() + assert len(runs) == 1 and runs[0]["status"] == "ok" + + +def test_required_variable_blocks_write(client, actor): + with get_conn() as conn: + dto = TemplateService.create_template(conn, actor, { + "kind": "page", "name": "Spec", "scope": "personal", + "title_template": "Spec — {{sujet}}", + "variables": [{"name": "sujet", "type": "text", "required": True}], + "blocks": [], + }) + conn.commit() + before = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0] + with pytest.raises(ValueError, match="sujet"): + TemplateService.instantiate(conn, actor, dto["id"], {}, {}) + after = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0] + assert before == after # erreur AVANT toute écriture + ok = TemplateService.instantiate(conn, actor, dto["id"], {"sujet": "X"}, {}) + assert ok["created_type"] == "page" + + +def test_row_template_degraded_property_warning(client, actor): + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + cid = conn.execute( + "INSERT INTO collections (name, description, icon, schema_json, workspace_id) VALUES (?,?,?,?,?)", + ("Taches", "", "✅", "[]", ws)).lastrowid + conn.execute( + "INSERT INTO collection_properties (collection_id, name, prop_type, position) VALUES (?,?,?,?)", + (cid, "Status", "status", 0)) + conn.commit() + dto = TemplateService.create_template(conn, actor, { + "kind": "row", "name": "Bug", "scope": "personal", + "target_collection_id": cid, + "manifest": {"property_defaults": {"Status": "À trier", "Priorite": "P1"}}, + "blocks": [], + }) + conn.commit() + result = TemplateService.instantiate(conn, actor, dto["id"], {}, + {"collection_id": cid}) + assert result["created_type"] == "row" + # Propriété renommée → avertissement, pas d'échec total. + assert any("Priorite" in w for w in result["warnings"]) + row = conn.execute("SELECT * FROM collection_pages WHERE id=?", + (int(result["created_id"]),)).fetchone() + assert row is not None + + +def test_default_exclusive(client, actor): + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + cid = conn.execute( + "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", + ("B", "[]", ws)).lastrowid + conn.commit() + a = TemplateService.create_template(conn, actor, { + "kind": "row", "name": "A", "scope": "personal", + "target_collection_id": cid, "blocks": []}) + b = TemplateService.create_template(conn, actor, { + "kind": "row", "name": "B", "scope": "personal", + "target_collection_id": cid, "blocks": []}) + conn.commit() + TemplateService.set_default(conn, actor, a["id"]) + TemplateService.set_default(conn, actor, b["id"]) + conn.commit() + rows = conn.execute( + "SELECT id FROM templates WHERE target_collection_id=? AND is_default=1 AND deleted_at IS NULL", + (cid,)).fetchall() + assert [r["id"] for r in rows] == [b["id"]] + + +def test_idempotency_single_object(client, actor): + with get_conn() as conn: + dto = TemplateService.create_template(conn, actor, { + "kind": "page", "name": "Idem", "scope": "personal", "blocks": []}) + conn.commit() + key = "test-key-123" + r1 = TemplateService.instantiate(conn, actor, dto["id"], {}, {}, + idempotency_key=key) + r2 = TemplateService.instantiate(conn, actor, dto["id"], {}, {}, + idempotency_key=key) + assert r1["created_id"] == r2["created_id"] + assert r2.get("replayed") is True + n = conn.execute( + "SELECT COUNT(*) FROM pages WHERE title=?", ("Idem",)).fetchone()[0] + assert n == 1 + + +def test_recurrence_dedup(client, actor): + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + cid = conn.execute( + "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", + ("Rec", "[]", ws)).lastrowid + conn.commit() + dto = TemplateService.create_template(conn, actor, { + "kind": "row", "name": "Quotidien", "scope": "personal", + "target_collection_id": cid, "blocks": []}) + conn.commit() + TemplateService.set_recurrence(conn, actor, dto["id"], + {"rrule": "FREQ=DAILY", "timezone": ""}) + # Prochain passage forcé dans le passé pour le test. + conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?", + (dto["id"],)) + conn.commit() + fired1 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00") + assert len(fired1) == 1 and fired1[0]["status"] == "ok" + # Même occurrence rejouée (redémarrage scheduler) → pas de doublon. + conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?", + (dto["id"],)) + conn.commit() + fired2 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00") + assert fired2 == [] + n = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", + (cid,)).fetchone()[0] + assert n == 1 + + +def test_api_list_instantiate_undo(client): + # Liste (le bouton « Templates » consomme cette route). + r = client.get("/api/templates?kind=page") + assert r.status_code == 200 + assert isinstance(r.json()["templates"], list) + assert len(r.json()["templates"]) >= 5 # presets système + tid = r.json()["templates"][0]["id"] + # Détail + aperçu. + assert client.get(f"/api/templates/{tid}").status_code == 200 + pv = client.post(f"/api/templates/{tid}/preview", json={}) + assert pv.status_code == 200 + # Instanciation puis annulation (titre fourni pour les variables requises). + ins = client.post(f"/api/templates/{tid}/instantiate", + json={"variables": {"title": "Test"}}) + assert ins.status_code == 200, ins.text + body = ins.json() + assert body["url"] + run_id = body["run_id"] + if run_id: + undo = client.post(f"/api/templates/runs/{run_id}/undo") + assert undo.status_code == 200 + # 404 explicite, jamais un no-op silencieux. + assert client.get("/api/templates/nope-123").status_code == 404 + bad = client.post("/api/templates/nope-123/instantiate", json={}) + assert bad.status_code == 404 + + +def test_manager_page_renders(client): + r = client.get("/templates") + assert r.status_code == 200 + assert "Templates" in r.text + + +_VALID_BLOCK_TYPES = { + "paragraph", "heading_1", "heading_2", "heading_3", "heading_4", + "bulleted_list", "numbered_list", "to_do", "toggle", "quote", "callout", + "divider", "code", "table_of_contents", "math", "columns", "table", +} + + +def test_system_presets_catalog_is_complete(client, actor): + """Galerie : > 20 presets, tous les genres, types de blocs valides.""" + from app.services.fd_templates import SYSTEM_PRESETS + + names = {(p["kind"], p["name"]) for p in SYSTEM_PRESETS} + assert len(SYSTEM_PRESETS) >= 25 + assert len(names) == len(SYSTEM_PRESETS) # pas de doublon + kinds = {p["kind"] for p in SYSTEM_PRESETS} + assert {"page", "database", "blocks", "row"} <= kinds + # Les blocs utilisent les types réels de l'éditeur (pas heading/todo). + for p in SYSTEM_PRESETS: + for b in p.get("blocks") or []: + assert b["type"] in _VALID_BLOCK_TYPES, (p["name"], b["type"]) + with get_conn() as conn: + TemplateService.ensure_system_presets(conn) + rows = conn.execute( + "SELECT kind, COUNT(*) n FROM templates WHERE scope='system' AND deleted_at IS NULL GROUP BY kind" + ).fetchall() + by_kind = {r["kind"]: r["n"] for r in rows} + assert sum(by_kind.values()) >= 25 + assert by_kind.get("page", 0) >= 10 + assert by_kind.get("database", 0) >= 8 + assert by_kind.get("blocks", 0) >= 4 + assert by_kind.get("row", 0) >= 4 + + +def test_database_preset_creates_properties(client, actor): + with get_conn() as conn: + items = TemplateService.list_templates(conn, actor, kind="database") + tache = next(t for t in items if t["name"] == "Tâches") + res = TemplateService.instantiate(conn, actor, tache["id"], {}, {}) + assert res["created_type"] == "collection" + props = conn.execute( + "SELECT name FROM collection_properties WHERE collection_id=?", + (int(res["created_id"]),)).fetchall() + names = {p["name"] for p in props} + assert {"Statut", "Priorité", "Échéance", "Assigné à"} <= names + + +def test_row_preset_resolves_base_by_name(client, actor): + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + cid = conn.execute( + "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", + ("Tâches", "[]", ws)).lastrowid + conn.commit() + items = TemplateService.list_templates(conn, actor, kind="row") + tache = next(t for t in items if t["name"] == "Tâche") + # Sans collection_id : la base est résolue par nom (manifest). + res = TemplateService.instantiate(conn, actor, tache["id"], {}, {}) + assert res["created_type"] == "row" + row = conn.execute( + "SELECT collection_id FROM collection_pages WHERE id=?", + (int(res["created_id"]),)).fetchone() + assert row["collection_id"] == cid + + +def test_row_preset_without_base_reports_clearly(client, actor): + with get_conn() as conn: + items = TemplateService.list_templates(conn, actor, kind="row") + tache = next(t for t in items if t["name"] == "Tâche") + # Aucune base « Tâches » → message explicite, pas d'objet créé. + with pytest.raises(ValueError, match="base"): + TemplateService.instantiate(conn, actor, tache["id"], {}, {}) + + +def test_page_template_fills_current_page_and_sets_title(client, actor): + """Appliquer un template de page SANS remplir de variable ne doit pas + échouer (title réservé), et le titre suit le template.""" + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + page_id = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format) " + "VALUES (?,?,?,?,?)", + ("T", ws, "Untitled", "[]", "blocks")).lastrowid + conn.commit() + items = TemplateService.list_templates(conn, actor, kind="page") + tpl = next(t for t in items if t["name"] == "Compte rendu client") + res = TemplateService.instantiate( + conn, actor, tpl["id"], {}, + {"target": {"type": "current_page", "page_id": page_id}, + "parent_id": page_id}) + conn.commit() + assert res["created_type"] == "page" + assert res["title"].startswith("CR —") + page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() + assert page["title"] == res["title"] + + +def test_page_template_instantiates_without_variables(client): + """Un template de page s'instancie sans variables (title réservé).""" + r = client.get("/api/templates?kind=page") + for t in r.json()["templates"]: + resp = client.post(f"/api/templates/{t['id']}/instantiate", json={"variables": {}}) + assert resp.status_code == 200, (t["name"], resp.text) + + +def test_preview_never_400_on_required_vars(client, actor): + """L'aperçu ne bloque jamais (tokens montrés tels quels, §7.1).""" + with get_conn() as conn: + dto = TemplateService.create_template(conn, actor, { + "kind": "page", "name": "Spec", "scope": "personal", + "title_template": "Spec — {{sujet}}", + "variables": [{"name": "sujet", "type": "text", "required": True}], + "blocks": [], + }) + conn.commit() + preview = TemplateService.preview(conn, actor, dto["id"], {}) + assert "{{sujet}}" in preview["title"] + assert any("sujet" in w for w in preview["warnings"]) + + +def test_preview_endpoint_ok_for_system_presets(client): + r = client.get("/api/templates?kind=page") + assert r.status_code == 200 + for t in r.json()["templates"]: + pv = client.post(f"/api/templates/{t['id']}/preview", json={}) + assert pv.status_code == 200, (t["name"], pv.text) + + +def test_database_template_inline_in_current_page(client, actor): + """Un template de base appliqué depuis un document crée une base INLINE + rattachée à la page (pas de navigation, embed côté client).""" + with get_conn() as conn: + ws = _ensure_ws(conn, actor) + page_id = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format) VALUES (?,?,?,?,?)", + ("Test", ws, "Doc", "[]", "blocks")).lastrowid + conn.commit() + dto = TemplateService.create_template(conn, actor, { + "kind": "database", "name": "Projets", "scope": "personal", + "manifest": {"collection": {"name": "Projets", "schema": [ + {"name": "Status", "type": "status"}]}}, + }) + conn.commit() + result = TemplateService.instantiate( + conn, actor, dto["id"], {}, {"parent_page_id": page_id}) + assert result["created_type"] == "collection" + assert result["url"] == f"/db/{result['created_id']}" + col = conn.execute("SELECT * FROM collections WHERE id=?", + (int(result["created_id"]),)).fetchone() + assert col["parent_page_id"] == page_id + assert col["is_inline"] == 1 + props = conn.execute( + "SELECT name FROM collection_properties WHERE collection_id=?", + (col["id"],)).fetchall() + assert [p["name"] for p in props] == ["Status"] diff --git a/tests/test_v7700_gitea_project_mirror.py b/tests/test_v7700_gitea_project_mirror.py new file mode 100644 index 0000000..3660b55 --- /dev/null +++ b/tests/test_v7700_gitea_project_mirror.py @@ -0,0 +1,103 @@ +"""FlowDeck v7.70.0 — Ouverture d'un projet Gitea : présentation + miroir local. + +Régression couverte : +- ``GET /gitea-workspace?owner=&repo=`` crée le **workspace miroir local** + AVANT de calculer la sidebar : le premier affichage du projet ne montre plus + « No workspace open » (ou un ``localWsId: 0``) — équivalent local ouvert en + parallèle du dépôt distant ; +- le contexte projet est porté par l'URL : la sidebar résout le miroir même + sans cookie ``flowdeck_workspace`` (navigation directe / favori) ; +- ``GET /api/sidebar/workspace-tree`` rend l'arbre du **miroir** (et non plus le + placeholder « Remote workspace ») quand le cookie est ``gitea:owner:repo`` ; +- ``loadGiteaTree`` (base.html) cible ``#sidebar-gitea-items`` et n'écrase plus + l'arbre du workspace local de ``#sidebar-workspace-items``. +""" +from __future__ import annotations + +import re + +from app.db import get_conn + + +def _mirror_id(ws_key: str) -> int | None: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE name=? AND settings_json LIKE ?", + (ws_key, "%gitea_repo%"), + ).fetchone() + return row["id"] if row else None + + +def _mk_mirror_page(ws_id: int, title: str) -> int: + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) " + "VALUES ('', ?, ?, '', 'blocks', 'Private')", + (ws_id, title), + ) + conn.commit() + return cur.lastrowid + + +def test_gitea_workspace_creates_and_shows_local_mirror(client): + """Le premier GET crée le miroir et le sidebar pointe dessus (localWsId>0).""" + assert _mirror_id("acme/widget") is None + r = client.get("/gitea-workspace?owner=acme&repo=widget") + assert r.status_code == 200, r.text + ws_id = _mirror_id("acme/widget") + assert ws_id is not None, "le workspace miroir doit être créé" + # La sidebar expose l'ID du miroir (et non 0) dès le premier rendu. + m = re.search(r"localWsId:\s*(\d+)", r.text) + assert m, "localWsId introuvable dans la page" + assert int(m.group(1)) == ws_id, "la sidebar doit ouvrir l'équivalent local en parallèle" + assert "Open a workspace to see your files" not in r.text + assert "acme/widget" in r.text + + +def test_gitea_workspace_context_from_url_without_cookie(client): + """Navigation directe (sans cookie) : le projet reste identifié par l'URL.""" + r = client.get("/gitea-workspace?owner=acme&repo=gadget") + assert r.status_code == 200, r.text + ws_id = _mirror_id("acme/gadget") + assert ws_id is not None + m = re.search(r"localWsId:\s*(\d+)", r.text) + assert m and int(m.group(1)) == ws_id + # Pas de bandeau « aucune workspace ouverte » alors que le miroir existe. + assert "Open a workspace to see your files" not in r.text + + +def test_sidebar_workspace_tree_renders_gitea_mirror(client): + """Le refresh sidebar ne remplace plus le miroir par « Remote workspace ».""" + client.get("/gitea-workspace?owner=acme&repo=repo1") + ws_id = _mirror_id("acme/repo1") + assert ws_id is not None + _mk_mirror_page(ws_id, "Note miroir") + r = client.get( + "/api/sidebar/workspace-tree", + cookies={"flowdeck_workspace": "gitea:acme:repo1"}, + ) + assert r.status_code == 200, r.text + assert "Note miroir" in r.text + assert "Remote workspace" not in r.text + + +def test_sidebar_workspace_tree_gitea_without_mirror_is_empty_not_error(client): + r = client.get( + "/api/sidebar/workspace-tree", + cookies={"flowdeck_workspace": "gitea:ghost/none"}, + ) + assert r.status_code == 200 + assert "Remote workspace" not in r.text + assert "No pages yet" in r.text + + +def test_loadgitea_tree_targets_repository_section(): + """Scan statique : loadGiteaTree ne doit pas cibler #sidebar-workspace-items.""" + from pathlib import Path + + html = (Path(__file__).resolve().parent.parent / "app" / "templates" / "base.html").read_text( + encoding="utf-8" + ) + fn = html.split("loadGiteaTree() {", 1)[1].split("newPageInWorkspace()", 1)[0] + assert "getElementById('sidebar-gitea-items')" in fn + assert "getElementById('sidebar-workspace-items')" not in fn diff --git a/tests/test_vues_notion.py b/tests/test_vues_notion.py new file mode 100644 index 0000000..e07d497 --- /dev/null +++ b/tests/test_vues_notion.py @@ -0,0 +1,137 @@ +"""Vues Notion (doc architecture-vues-notion-flowdeck.md §2.4, §21). + +Tests d'acceptation CU-Chart / CU-Timeline / CU-Map / CU-Feed / +CU-Dashboard / CU-Form + type place + migrations 44-47. +""" +from __future__ import annotations + + +def _make_base(c): + r = c.post('/db/api', json={'name': 'TodoNotion'}) + assert r.status_code == 200, r.text + cid = r.json()['id'] + c.post(f'/db/{cid}/properties/api', json={'name': 'Status', 'prop_type': 'select'}) + c.post(f'/db/{cid}/properties/api', json={'name': 'Due', 'prop_type': 'date'}) + c.post(f'/db/{cid}/properties/api', json={'name': 'Place', 'prop_type': 'place'}) + # 4 Not started, 1 In progress, 1 Done (cf. CU-Chart §2.4) + for i, s in enumerate(['Not started'] * 4 + ['In progress', 'Done']): + c.post(f'/db/{cid}/pages/api', json={'title': f'tache {i}', 'properties': {'Status': s}}) + # Une ligne datee 22 -> 27 octobre 2026 (CU-Timeline) + c.post(f'/db/{cid}/pages/api', json={'title': 'bruno', 'properties': { + 'Status': 'In progress', 'Due': '2026-10-22...2026-10-27', + 'Place': {'name': 'Beloeil', 'address': 'Beloeil, QC', 'lat': 45.56, 'lng': -73.20}}}) + return cid + + +def test_migrations_44_47_applied(client): + from app.db import get_conn + with get_conn() as conn: + v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] + assert v >= 47 + for tbl in ('geocode_cache', 'dashboard_widgets', 'form_questions'): + assert conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (tbl,)).fetchone() + + +def test_place_type_validation(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/pages/api', json={'title': 'Bad', 'properties': {'Place': {'lat': 1, 'lng': 2}}}) + assert r.status_code == 400 + r = c.post(f'/db/{cid}/pages/api', json={'title': 'Bad2', 'properties': { + 'Place': {'name': 'X', 'lat': 200, 'lng': 0}}}) + assert r.status_code == 400 + r = c.post(f'/db/{cid}/pages/api', json={'title': 'Addr', 'properties': {'Place': 'Montreal, QC, Canada'}}) + assert r.status_code in (200, 201) + + +def test_cu_chart_donut_and_drilldown(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Chart', 'view_type': 'chart', 'config': {}}) + vid = r.json()['id'] + c.put(f"/db/views/{vid}/config", json={'chart_type': 'doughnut', 'group_by': 'Status', + 'measure': {'kind': 'count'}, 'omit_zero': False}) + r = c.post(f"/db/{cid}/views/{vid}/aggregate", json={}) + assert r.status_code == 200 + data = r.json() + assert data['total'] == 7 + by_key = {g['key']: g for g in data['groups']} + assert by_key['Not started']['count'] == 4 + assert abs(by_key['Not started']['percent'] - 4 / 7) < 0.01 + r = c.get(f"/db/{cid}/views/{vid}/drilldown", params={'group': 'Not started'}) + assert r.json()['count'] == 4 + + +def test_cu_timeline_nodate_bucket(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'TL', 'view_type': 'timeline', 'config': {}}) + vid = r.json()['id'] + c.put(f"/db/views/{vid}/config", json={'date_property': 'Due', 'scale': 'quarter'}) + r = c.get(f"/db/{cid}/views/{vid}/rows", params={'limit': 50}) + assert r.status_code == 200 + assert r.json()['undated'] == 6 # 6 lignes sans date + + +def test_cu_map_ceiling_and_geocode_off(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Map', 'view_type': 'map', 'config': {'place_property': 'Place'}}) + vid = r.json()['id'] + r = c.get(f"/db/{cid}/views/{vid}/rows", params={'limit': 50}) + assert r.status_code == 200 + # Provider none par defaut : pas d'appel externe, recherche vide. + r = c.get('/db/geocode/search', params={'q': 'Beloeil, QC'}) + assert r.status_code == 200 + assert r.json()['suggestions'] == [] + + +def test_cu_dashboard_global_filter(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Chart', 'view_type': 'chart', + 'config': {'group_by': 'Status', 'measure': {'kind': 'count'}}}) + cv = r.json()['id'] + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Dash', 'view_type': 'dashboard', 'config': {}}) + dv = r.json()['id'] + r = c.put(f"/db/{cid}/views/{dv}/widgets", json={'widgets': [ + {'row_index': 0, 'position': 0, 'source_collection_id': cid, + 'source_view_id': cv, 'inline_config': {}}]}) + assert r.status_code == 200 + widgets = c.get(f"/db/{cid}/views/{dv}/widgets").json()['widgets'] + assert len(widgets) == 1 + r = c.get(f"/db/{cid}/views/{dv}/widgets/{widgets[0]['id']}/data") + assert r.status_code == 200 + # 13 widgets refuses (plafond 12). + r = c.put(f"/db/{cid}/views/{dv}/widgets", + json={'widgets': [{'row_index': 0, 'position': i} for i in range(13)]}) + assert r.status_code == 400 + + +def test_cu_form_builder_projection(client): + c = client + cid = _make_base(c) + r = c.post(f'/db/{cid}/views/save-as', json={'name': 'Form', 'view_type': 'form', 'config': {}}) + fv = r.json()['id'] + r = c.put(f"/db/{cid}/views/{fv}/form", json={ + 'questions': [{'property_name': 'Status', 'position': 0, 'required': True}], + 'config': {'title': 'Demande', 'sharing': {'audience': 'workspace', 'anonymous': False}}}) + assert r.status_code == 200 + assert r.json()['projected_fields'] == 1 + r = c.get(f"/db/{cid}/views/{fv}/form") + assert len(r.json()['questions']) == 1 + r = c.post(f"/db/{cid}/views/{fv}/form/preview-token") + assert r.json()['submittable'] is False + + +def test_ssr_compat_untouched(client): + """Les rendus SSR restent servis (compat §7.6) : chart/timeline/feed/map.""" + c = client + cid = _make_base(c) + for vt in ('chart', 'timeline', 'feed', 'map', 'form', 'gantt'): + r = c.get(f"/db/{cid}/view/{vt}") + assert r.status_code == 200, vt + + +