Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
3449 lines
156 KiB
Python
3449 lines
156 KiB
Python
"""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)))
|