feat(templates): refonte complète des templates façon Notion + vues/agents-skills
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped

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/.
This commit is contained in:
2026-10-10 18:52:19 -04:00
parent d0452e10dd
commit fc8548194a
36 changed files with 12290 additions and 137 deletions
+8
View File
@@ -147,6 +147,14 @@ class Settings(BaseSettings):
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
# ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ──
GEOCODER_PROVIDER: str = "none" # none | nominatim | custom
GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org"
VIEW_ROWS_MAX: int = 1000
MAP_POINTS_MAX: int = 100
TIMELINE_LOAD_MAX: int = 200
CHART_MAX_SUBGROUPS: int = 50
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+11
View File
@@ -833,6 +833,17 @@ def init_db():
from app.migrations import apply_migrations
apply_migrations(conn)
# ── Templates unifiés (§9.5, §12) : backfill idempotent des silos
# legacy + presets système. Non bloquant : un échec laisse les silos
# legacy servir leurs anciennes surfaces (dégradation, pas panne).
try:
from app.services.fd_templates import ensure_registry, reset_registry_cache
reset_registry_cache()
ensure_registry(conn)
except Exception:
import logging as _logging
_logging.getLogger(__name__).exception("ensure_registry templates")
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
# schema exists without depending on the FastAPI lifespan startup.
from app.services.webhook_outbound import init_webhook_tables
+9 -2
View File
@@ -44,6 +44,7 @@ from app.routers.audit import router as audit_router
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.fd_templates import router as fd_templates_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
@@ -154,6 +155,10 @@ async def lifespan(_app: FastAPI):
from app.services.reminders import reminder_scheduler
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Templates récurrents (§11.2) : scan 60 s dédupliqué ──
from app.services.fd_templates import template_recurrence_scheduler
template_rec_task = _spawn("template_recurrence_scheduler", template_recurrence_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
@@ -172,7 +177,7 @@ async def lifespan(_app: FastAPI):
try:
yield
finally:
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, template_rec_task, semantic_task, calendar_task)
if webhook_task is not None:
_tasks = _tasks + (webhook_task,)
for task in _tasks:
@@ -186,7 +191,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.70.1",
version="7.71.5",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -268,6 +273,8 @@ app.include_router(audit_router)
app.include_router(governance_router)
# v7.3.0 — teamspaces + verified wiki
app.include_router(wiki_router)
# Templates unifiés façon Notion (registre + gestionnaire + API interne)
app.include_router(fd_templates_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
+223
View File
@@ -1802,3 +1802,226 @@ def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
"ON meeting_transcripts(event_occurrence_id) "
"WHERE event_occurrence_id <> ''"
)
# ── Vues Notion §10 : migrations 44 à 47 (après 39-43 réservées Agents&Skills) ──
@register(44, "vues-notion: view_config_v2 (config_version + index view_type)")
def _migration_view_config_v2(conn: sqlite3.Connection) -> None:
"""Migration 44 — versionnage des config_json de vues (phase 1, Chart)."""
cols = columns(conn, "collection_views")
if "config_version" not in cols:
conn.execute(
"ALTER TABLE collection_views ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_collection_views_type "
"ON collection_views(collection_id, view_type)"
)
@register(45, "vues-notion: place_geocode_cache")
def _migration_place_geocode_cache(conn: sqlite3.Connection) -> None:
"""Migration 45 — cache partage du Geocoding Service (phase 3, Map)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS geocode_cache (
query_hash TEXT PRIMARY KEY,
provider TEXT NOT NULL DEFAULT 'nominatim',
query_text TEXT NOT NULL DEFAULT '',
result_json TEXT NOT NULL DEFAULT '{}',
lat REAL,
lng REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
hit_count INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at)"
)
@register(46, "vues-notion: dashboard_widgets")
def _migration_dashboard_widgets(conn: sqlite3.Connection) -> None:
"""Migration 46 — widgets de la vue Dashboard (phase 4)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
row_index INTEGER NOT NULL DEFAULT 0,
col_span INTEGER NOT NULL DEFAULT 1,
height_units INTEGER NOT NULL DEFAULT 2,
source_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
source_view_id INTEGER REFERENCES collection_views(id) ON DELETE SET NULL,
inline_config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_view "
"ON dashboard_widgets(view_id, row_index, position)"
)
@register(47, "vues-notion: form_questions_v2")
def _migration_form_questions_v2(conn: sqlite3.Connection) -> None:
"""Migration 47 — questions du Form builder + repondant (phase 5)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS form_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
property_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
label_override TEXT,
sync_label INTEGER NOT NULL DEFAULT 1,
help_text TEXT NOT NULL DEFAULT '',
required INTEGER NOT NULL DEFAULT 0,
widget TEXT NOT NULL DEFAULT 'auto',
max_selections INTEGER,
conditional_json TEXT,
UNIQUE(view_id, property_name)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_questions_view "
"ON form_questions(view_id, position)"
)
for table, ddl, col in (
("form_responses", "ALTER TABLE form_responses ADD COLUMN view_id INTEGER", "view_id"),
("form_responses", "ALTER TABLE form_responses ADD COLUMN respondent_user_id INTEGER REFERENCES users(id)", "respondent_user_id"),
("form_responses", "ALTER TABLE form_responses ADD COLUMN anonymous INTEGER NOT NULL DEFAULT 0", "anonymous"),
):
try:
existing = columns(conn, table)
except Exception:
continue
if col not in existing:
try:
conn.execute(ddl)
except Exception:
pass
# ── Templates Notion §9 : migrations 48 à 49 (registre unifié + journal) ──
@register(48, "templates-notion: registre unifié `templates`")
def _migration_templates_registry(conn: sqlite3.Connection) -> None:
"""Migration 48 — registre unifié des templates (phase 1).
Catalogue les trois silos legacy (``database_templates``,
``page_templates``, ``page_global_templates``) SANS les détruire :
colonnes ``source_kind``/``source_id`` + backfill idempotent côté
service (``TemplateService.backfill_registry``), rejouable au boot.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
workspace_id INTEGER NOT NULL DEFAULT 1,
teamspace_id INTEGER,
kind TEXT NOT NULL DEFAULT 'page'
CHECK (kind IN ('page','row','database','blocks')),
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '📄',
cover_url TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal','workspace','teamspace','system')),
owner_id INTEGER,
target_collection_id INTEGER,
content_page_id INTEGER,
source_kind TEXT NOT NULL DEFAULT 'native'
CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')),
source_id TEXT NOT NULL DEFAULT '',
manifest_json TEXT NOT NULL DEFAULT '{}',
variables_json TEXT NOT NULL DEFAULT '[]',
is_default INTEGER NOT NULL DEFAULT 0,
is_system INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
archived_at TEXT,
deleted_at TEXT,
sync_version INTEGER NOT NULL DEFAULT 1
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_ws_kind "
"ON templates(workspace_id, kind)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_target "
"ON templates(target_collection_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_source "
"ON templates(source_kind, source_id)"
)
# Un seul défaut actif par base (les lignes soft-deleted/archivées ne comptent pas).
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_templates_default "
"ON templates(target_collection_id) "
"WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL"
)
@register(49, "templates-notion: récurrences + journal `template_runs`")
def _migration_template_runs(conn: sqlite3.Connection) -> None:
"""Migration 49 — récurrences des templates de ligne + journal des
instanciations (compteurs du gestionnaire, dédup scheduler, audit)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS template_recurrences (
template_id TEXT PRIMARY KEY REFERENCES templates(id) ON DELETE CASCADE,
rrule TEXT NOT NULL DEFAULT '',
timezone TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT,
last_run_at TEXT,
end_count INTEGER,
updated_by INTEGER,
updated_at TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS template_runs (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
run_source TEXT NOT NULL DEFAULT 'ui'
CHECK (run_source IN ('ui','api','agent','automation','recurrence')),
actor_id INTEGER,
occurrence_key TEXT,
created_type TEXT NOT NULL DEFAULT '',
created_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok','error','undone')),
warnings_json TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
idempotency_key TEXT,
created_at TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_occurrence "
"ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_idem "
"ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
"ON template_runs(template_id, created_at)"
)
+141
View File
@@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str
created += 1
conn.commit()
return {"imported": created, "status": "ok"}
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
def _v2_actor(user) -> dict:
return {"id": user.get("id"), "login": user.get("login", ""),
"full_name": user.get("full_name", ""),
"is_admin": bool(user.get("is_admin"))}
@router.get("/fd-templates")
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
kind: str | None = None, q: str | None = None):
from app.services.fd_templates import TemplateService
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
return {"templates": items}
@router.get("/fd-templates/{template_id}")
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
from app.services.fd_templates import TemplateService, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
@router.post("/fd-templates/{template_id}/instantiate")
def instantiate_fd_template_v2(template_id: str, request: Request,
authorization: str | None = Header(default=None),
body: dict = Body(default={})):
from app.services.fd_templates import TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
if idem:
hit = check_idempotency(request, user["id"])
if hit:
return hit["data"]
with get_conn() as conn:
try:
result = TemplateService.instantiate(
conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
except ValueError as exc:
raise HTTPException(400, str(exc))
audit_log(user, "template.instantiate", "template", template_id, "")
if idem:
store_idempotency(idem, user["id"], result, 200)
return result
@router.get("/fd-templates/{template_id}/export")
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
"category": dto["category"], "variables": dto["variables"],
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
}
if dto.get("recurrence"):
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
"timezone": dto["recurrence"].get("timezone", "")}
return manifest
@router.post("/fd-templates/import")
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
body: dict = Body(default={})):
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
from app.services.fd_templates import KINDS, TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if body.get("format") != "flowdeck-template":
raise HTTPException(400, "format must be flowdeck-template")
try:
version = int(body.get("version") or 0)
except (TypeError, ValueError):
raise HTTPException(400, "version invalide")
if version > 1:
raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page"
if kind not in KINDS:
raise HTTPException(400, "kind inconnu")
blocks = ((body.get("content") or {}).get("blocks") or [])
if not isinstance(blocks, list) or len(blocks) > 2000:
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
target = body.get("target") or {}
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
"description": (body.get("description") or "")[:2000],
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
"scope": "personal", "blocks": blocks,
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
"title_template": (body.get("manifest") or {}).get("title_template", "")}
if kind == "row":
# La base cible est résolue par nom dans le workspace, jamais par id
# externe ; si absente, le template est créé « orphelin » et signalé.
cname = target.get("collection_name", "")
with get_conn() as conn:
actor = _v2_actor(user)
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
if row:
spec["target_collection_id"] = row["id"]
dto = TemplateService.create_template(conn, actor, spec)
conn.commit()
return {"id": dto["id"], "status": "imported"}
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
# le contrat exige un signalement — on refuse proprement.
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
with get_conn() as conn:
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
conn.commit()
audit_log(user, "template.import", "template", dto["id"], "")
return {"id": dto["id"], "status": "imported"}
+329 -1
View File
@@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
"property_groups", "view_type",
# Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4
"version", "chart_type", "chart_property", "measure", "omit_zero",
"cumulative", "hidden_groups", "group_order", "style", "order",
"aggregate", "title", "center_value", "palette", "height",
"data_labels", "legend", "grid", "smooth", "gradient",
"color_by_value", "separate_end_property", "scale", "show_table",
"table_properties", "load_limit", "show_dependencies", "sub_items",
"row_order", "show_view_count", "excerpt", "place_property",
"cluster", "default_style", "global_filters", "row_heights",
"excluded_widget_ids", "sharing", "submit_screen", "notify_email",
"description", "form_view_id"):
if key in body:
config[key] = body[key]
@@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default
"status": "duplicated"}
# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ──
def _load_view_collection(conn, collection_id: int, view_id: int):
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
config = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
config = {}
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
return dict(view), config, props, pages
@router.get("/{collection_id}/views/{view_id}/rows")
def view_rows_api(request: Request, collection_id: int, view_id: int,
cursor: str | None = None, limit: int = 50,
window: str | None = None):
"""Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
if window:
# Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client.
config = dict(config)
limit = max(1, min(int(limit or 50), 1000))
rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor)
undated = 0
date_prop = (config.get("date_property") or config.get("date_range_property"))
if date_prop and (view.get("view_type") == "timeline"):
from app.services.view_query import prop_value, text_of
by_name = {p["name"]: p for p in props}
by_id = {str(p["id"]): p for p in props}
dp = by_name.get(date_prop) or by_id.get(str(date_prop))
if dp:
for p in pages:
if not text_of(dp, prop_value(p, dp)):
undated += 1
return {"rows": rows, "next_cursor": next_cursor, "total": len(pages),
"undated": undated, "view_type": view.get("view_type")}
@router.post("/{collection_id}/views/{view_id}/aggregate")
def view_aggregate_api(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
"""Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3."""
from app.services.view_aggregate import aggregate
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
spec = {
"group_by": body.get("group_by", config.get("group_by")),
"sub_group_by": body.get("sub_group_by", config.get("sub_group_by")),
"measure": body.get("measure", config.get("measure", {"kind": "count"})),
"cumulative": body.get("cumulative", config.get("cumulative", False)),
"omit_zero": body.get("omit_zero", config.get("omit_zero", True)),
"hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])),
"order": body.get("order", config.get("order", config.get("group_order", "group_order"))),
"filters": (config.get("filters") or []) + (body.get("filters") or []),
"filter_conjunction": config.get("filter_conjunction", "and"),
"sorts": config.get("sorts"),
}
# Compat : ancienne config SSR {chart_property, aggregate}
if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")):
spec["group_by"] = config.get("chart_property") or body.get("chart_property")
if isinstance(spec["measure"], str):
spec["measure"] = {"kind": spec["measure"]}
if isinstance(spec["order"], str):
spec["order"] = {"by": spec["order"], "direction": "asc"}
return aggregate(pages, props, spec)
@router.get("/{collection_id}/views/{view_id}/drilldown")
def view_drilldown_api(request: Request, collection_id: int, view_id: int,
group: str = "", sub: str | None = None, limit: int = 100):
"""Tableau restreint des lignes d'un segment de Chart (§8.4)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
group_by = config.get("group_by") or config.get("chart_property")
extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else []
if sub and config.get("sub_group_by"):
extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub})
rows, _ = query_view_rows(pages, props, config, extra_filters=extra,
limit=max(1, min(int(limit or 100), 500)))
return {"rows": rows, "group": group, "sub": sub, "count": len(rows)}
@router.get("/geocode/search")
def geocode_search_api(request: Request, q: str = "", limit: int = 5):
"""Autocompletion d'adresses (editeur place). Provider none -> []."""
from app.services import geocoding as _geo
with get_conn() as conn:
return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))}
@router.post("/geocode/resolve")
def geocode_resolve_api(request: Request, body: dict = Body(default={})):
"""Adresse -> coordonnees (cache migration 45)."""
from app.services import geocoding as _geo
with get_conn() as conn:
result = _geo.resolve(conn, body.get("q") or body.get("address") or "")
if not result:
raise HTTPException(status_code=404, detail="Place not resolved")
return result
@router.get("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_get(request: Request, collection_id: int, view_id: int):
with get_conn() as conn:
view, config, _, _ = _load_view_collection(conn, collection_id, view_id)
try:
rows = conn.execute(
"SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position",
(view_id,)).fetchall()
widgets = [dict(r) for r in rows]
except Exception:
widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or [])
return {"widgets": widgets, "config": config}
@router.put("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
_require_edit(collection_id, _su(request))
widgets = body.get("widgets") or []
if len(widgets) > 12:
raise HTTPException(status_code=400, detail="12 widgets maximum")
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,))
for i, w in enumerate(widgets):
conn.execute(
"""INSERT INTO dashboard_widgets
(view_id, position, row_index, col_span, height_units,
source_collection_id, source_view_id, inline_config_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1),
int(w.get("height_units", 2) or 2),
w.get("source_collection_id"), w.get("source_view_id"),
json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})),
)
conn.commit()
except HTTPException:
raise
except Exception:
# Repli : stockage JSON dans le config (pre-migration 46).
config = json.loads(view["config_json"] or "{}")
config["widgets_json"] = widgets
config["global_filters"] = body.get("global_filters", config.get("global_filters", []))
if body.get("global_filters") is not None:
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
return {"widgets": widgets, "fallback": "config_json"}
if body.get("global_filters") is not None:
config = json.loads(view["config_json"] or "{}")
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
try:
run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id}))
except Exception:
pass
return {"widgets": widgets, "status": "saved"}
@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data")
def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int):
from app.services.view_aggregate import aggregate
from app.services.view_query import query_view_rows
with get_conn() as conn:
try:
w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?",
(widget_id, view_id)).fetchone()
widget = dict(w) if w else None
except Exception:
widget = None
if widget is None:
raise HTTPException(status_code=404, detail="Widget not found")
src_cid = widget.get("source_collection_id") or collection_id
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
src_view = None
if widget.get("source_view_id"):
src_view = conn.execute("SELECT * FROM collection_views WHERE id=?",
(widget["source_view_id"],)).fetchone()
try:
inline = json.loads(widget.get("inline_config_json") or "{}")
except (json.JSONDecodeError, TypeError):
inline = {}
cfg = dict(inline)
if src_view:
try:
base = json.loads(src_view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
cfg = {**base, **inline}
vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table"))
if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None):
return aggregate(pages, props, {
"group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"),
"measure": cfg.get("measure", {"kind": "count"}),
"omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []),
"order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [],
})
rows, _ = query_view_rows(pages, props, cfg, limit=50)
return {"rows": rows, "view_type": vtype or "table"}
@router.get("/{collection_id}/views/{view_id}/form")
def form_builder_get(request: Request, collection_id: int, view_id: int):
from app.services.form_projection import default_questions_for
with get_conn() as conn:
view, config, props, _ = _load_view_collection(conn, collection_id, view_id)
try:
qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
qs = []
if not qs:
qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props])
return {"config": config, "questions": qs}
@router.put("/{collection_id}/views/{view_id}/form")
def form_builder_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
from app.services.form_projection import project_form_config
_require_edit(collection_id, _su(request))
questions = body.get("questions") or []
form_cfg = body.get("config") or {}
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
base = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
base.update({k: v for k, v in form_cfg.items()
if k in ("title", "description", "sharing", "submit_screen", "notify_email")})
base["version"] = 2
try:
conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,))
for i, q in enumerate(questions):
conn.execute(
"""INSERT INTO form_questions
(view_id, property_name, position, label_override, sync_label,
help_text, required, widget, max_selections, conditional_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, q.get("property_name"), i,
q.get("label_override"), 1 if q.get("sync_label", True) else 0,
q.get("help_text", ""), 1 if q.get("required") else 0,
q.get("widget", "auto"), q.get("max_selections"),
json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")),
)
stored_qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
base["form_questions_json"] = questions
stored_qs = questions
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
conn.commit()
return {"questions": stored_qs, "status": "saved", "fallback": "config_json"}
projected = project_form_config(base, [{
"property_name": r["property_name"], "position": r["position"],
"label_override": r["label_override"], "sync_label": r["sync_label"],
"help_text": r["help_text"], "required": r["required"],
"widget": r["widget"], "max_selections": r["max_selections"],
"conditional_json": r["conditional_json"],
} for r in stored_qs])
base["projected_form_config"] = projected
# Synchro libelle -> nom de propriete (defaut Notion).
for r in stored_qs:
if r["sync_label"] and r["label_override"]:
conn.execute(
"UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?",
(r["label_override"], collection_id, r["property_name"]))
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
# La projection alimente aussi le pipeline public /f/ existant (meme collection).
try:
conn.execute("UPDATE collections SET form_config_json=? WHERE id=?",
(json.dumps(projected), collection_id))
except Exception:
pass
conn.commit()
return {"questions": stored_qs, "status": "saved",
"projected_fields": len(projected.get("fields", []))}
@router.post("/{collection_id}/views/{view_id}/form/preview-token")
def form_preview_token(request: Request, collection_id: int, view_id: int):
"""Jeton d'apercu non-soumissible (soumissions refusees cote serveur)."""
import secrets
return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False}
# ── v1.8.0 Sub-items & Dependencies ──
+480
View File
@@ -0,0 +1,480 @@
"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie).
Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1.
Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même
``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.fd_templates import TemplateService, ensure_registry
logger = logging.getLogger(__name__)
router = APIRouter(tags=["fd-templates"])
def _actor(request: Request) -> dict | None:
try:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
except Exception:
user = None
return user
def _require_actor(request: Request) -> dict:
user = _actor(request)
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
def _as_http(exc: Exception) -> HTTPException:
if isinstance(exc, LookupError):
return HTTPException(404, str(exc) or "Template introuvable")
if isinstance(exc, PermissionError):
return HTTPException(403, str(exc) or "Accès refusé")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc) or "Requête invalide")
logger.exception("templates API")
return HTTPException(500, "Erreur interne")
# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════
@router.get("/templates", response_class=HTMLResponse)
def templates_manager_page(request: Request):
"""Gestionnaire de templates : liste filtrable + side peek d'aperçu."""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
sidebar = board_sidebar(request, "", "")
try:
with get_conn() as conn:
ensure_registry(conn)
except Exception:
logger.exception("ensure_registry /templates")
template = ENV.get_template("fd_templates_manager.html")
return template.render(**sidebar)
# ═══════════ API interne — lecture ═══════════
@router.get("/api/templates")
def list_templates(
request: Request,
kind: str | None = Query(default=None),
scope: str | None = Query(default=None),
target_collection_id: int | None = Query(default=None),
q: str | None = Query(default=None),
include_archived: bool = Query(default=False),
):
actor = _actor(request)
with get_conn() as conn:
try:
items = TemplateService.list_templates(
conn, actor, kind=kind, scope=scope,
target_collection_id=target_collection_id, query=q,
include_archived=include_archived,
)
except Exception as exc:
raise _as_http(exc) from None
return {"templates": items}
@router.get("/api/templates/{template_id}")
def get_template(request: Request, template_id: str):
actor = _actor(request)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, actor, template_id)
from app.services.fd_templates import _row_to_dto
dto = _row_to_dto(conn, row)
preview = TemplateService.preview(conn, actor, template_id)
dto["blocks_preview"] = preview.get("blocks_preview", [])
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.get("/api/collections/{collection_id}/templates")
def list_collection_templates(request: Request, collection_id: int):
"""Menu *New ▾* d'une base : templates de lignes + défaut (§7.3)."""
actor = _actor(request)
with get_conn() as conn:
try:
items = TemplateService.list_templates(
conn, actor, kind="row", target_collection_id=collection_id
)
default_id = next(
(t["id"] for t in items if t.get("is_default")), None
)
except Exception as exc:
raise _as_http(exc) from None
return {"templates": items, "default_id": default_id}
@router.get("/api/templates/{template_id}/runs")
def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)):
actor = _require_actor(request)
with get_conn() as conn:
try:
TemplateService.get_template(conn, actor, template_id)
rows = conn.execute(
"SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?",
(template_id, max(1, min(int(limit), 100))),
).fetchall()
except Exception as exc:
raise _as_http(exc) from None
return {"runs": [dict(r) for r in rows]}
# ═══════════ API interne — écriture ═══════════
@router.post("/api/templates")
def create_template(request: Request, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
# Création depuis une source existante (create_from_*, §2.1).
if body.get("from_page_id"):
dto = _create_from_page(conn, actor, int(body["from_page_id"]), body)
elif body.get("from_collection_id"):
dto = _create_from_collection(
conn, actor, int(body["from_collection_id"]), body
)
elif body.get("from_row"):
dto = _create_from_row(conn, actor, body["from_row"], body)
else:
dto = TemplateService.create_template(conn, actor, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.patch("/api/templates/{template_id}")
def update_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.update_template(conn, actor, template_id, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/duplicate")
def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.duplicate_template(conn, actor, template_id, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.delete("/api/templates/{template_id}")
def delete_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
result = TemplateService.delete_template(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/{template_id}/archive")
def archive_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.archive_template(conn, actor, template_id, archived=True)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/restore")
def restore_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.archive_template(conn, actor, template_id, archived=False)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/property-defaults")
def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.update_template(
conn, actor, template_id,
{"property_defaults": body.get("property_defaults") or body},
)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/variables")
def set_variables(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json")
if variables is None and isinstance(body, list):
variables = body
with get_conn() as conn:
try:
dto = TemplateService.update_template(
conn, actor, template_id, {"variables": variables or []}
)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/set-default")
def set_default(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.set_default(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/unset-default")
def unset_default(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.unset_default(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/recurrence")
def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
spec = None
if body and body.get("rrule"):
spec = {
"rrule": body.get("rrule"),
"timezone": body.get("timezone") or "",
"enabled": body.get("enabled", True),
}
with get_conn() as conn:
try:
recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return {"template_id": template_id, "recurrence": recurrence}
@router.post("/api/templates/{template_id}/preview")
def preview_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _actor(request)
with get_conn() as conn:
try:
result = TemplateService.preview(
conn, actor, template_id, body.get("variables") or {},
title=(body.get("title") or ""),
)
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/{template_id}/instantiate")
def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _actor(request)
idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
context = body.get("context") or {}
# La cible « page courante » porte l'id de page pour ajout/remplacement.
if body.get("page_id") and "target" not in context:
context["target"] = {"type": "current_page", "page_id": body.get("page_id"),
"mode": body.get("mode") or "fill"}
if body.get("collection_id") and "collection_id" not in context:
context["collection_id"] = body.get("collection_id")
if body.get("parent_page_id") and "parent_page_id" not in context:
context["parent_page_id"] = body.get("parent_page_id")
with get_conn() as conn:
try:
result = TemplateService.instantiate(
conn, actor, template_id, body.get("variables") or {}, context,
idempotency_key=idempotency_key,
run_source="api" if request.headers.get("authorization") else "ui",
)
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/runs/{run_id}/undo")
def undo_run(request: Request, run_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
result = TemplateService.mark_undone(conn, actor, run_id)
except Exception as exc:
raise _as_http(exc) from None
return result
# ═══════════ Création depuis l'existant (create_from_*) ═══════════
def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise LookupError("Page source introuvable")
blocks: list = []
try:
if (row["content_format"] or "") == "blocks" and row["content"]:
blocks = json.loads(row["content"]) or []
except (json.JSONDecodeError, TypeError):
blocks = []
return TemplateService.create_template(
conn, actor,
{
"kind": spec.get("kind") or "page",
"name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)",
"description": spec.get("description") or "",
"icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄",
"category": spec.get("category") or "",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"target_collection_id": spec.get("target_collection_id"),
"blocks": blocks if isinstance(blocks, list) else [],
"variables": spec.get("variables") or [],
"title_template": spec.get("title_template") or f"{row['title'] or ''}",
},
)
def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not collection:
raise LookupError("Base source introuvable")
props = [
dict(p)
for p in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
schema = [
{
"name": p["name"],
"type": p["prop_type"] if "prop_type" in p else p.get("type", "text"),
"options": json.loads(p.get("options_json") or "[]"),
}
for p in props
]
manifest = {
"format": "flowdeck-template",
"version": 1,
"collection": {
"name": collection["name"],
"icon": collection["icon"] if "icon" in collection.keys() else "📋",
"schema": schema,
},
}
if spec.get("with_seed_rows"):
manifest["collection"]["seed_rows"] = []
return TemplateService.create_template(
conn, actor,
{
"kind": "database",
"name": spec.get("name") or f"{collection['name']} (template)",
"description": spec.get("description") or (collection["description"] or ""),
"icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂",
"category": spec.get("category") or "Base",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"manifest": manifest,
"variables": spec.get("variables") or [],
},
)
def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict:
if isinstance(from_row, dict):
collection_id = from_row.get("collection_id")
row_id = from_row.get("row_id")
else:
collection_id, row_id = spec.get("target_collection_id"), from_row
if not collection_id or not row_id:
raise ValueError("from_row exige {collection_id, row_id}")
source = conn.execute(
"SELECT * FROM collection_pages WHERE id=? AND collection_id=?",
(row_id, collection_id),
).fetchone()
if not source:
raise LookupError("Ligne source introuvable")
try:
pv = json.loads(source["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pv = {}
# Les valeurs sont re-cléées par nom de propriété pour survivre à l'export.
props = {
str(p["id"]): p["name"]
for p in conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()}
shadow_blocks: list = []
try:
shadow = conn.execute(
"SELECT content, content_format FROM pages WHERE collection_row_id=?",
(row_id,),
).fetchone()
if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]:
shadow_blocks = json.loads(shadow["content"]) or []
except Exception:
shadow_blocks = []
return TemplateService.create_template(
conn, actor,
{
"kind": "row",
"name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)",
"description": spec.get("description") or "",
"icon": spec.get("icon") or "📄",
"category": spec.get("category") or "",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"target_collection_id": collection_id,
"blocks": shadow_blocks if isinstance(shadow_blocks, list) else [],
"manifest": {"property_defaults": defaults},
"title_template": spec.get("title_template") or (source["title"] or ""),
"variables": spec.get("variables") or [],
},
)
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
"""FlowDeck — Projection Form builder -> pipeline public /f/ (§16.5).
Le builder ecrit ``form_questions`` + reglages de la vue form ; le
projecteur regenere ``collections.form_config_json`` (format v6.8 inchange)
consomme par GET/POST /f/<token>. Le public ne lit jamais les tables du
builder : zero regression possible sur les liens deja diffuses.
"""
from __future__ import annotations
import json
def project_form_config(view_config: dict, questions: list[dict]) -> dict:
"""Construit le ``form_config_json`` v6.8 depuis le builder."""
cfg = dict(view_config or {})
sharing = cfg.get("sharing") or {}
submit = cfg.get("submit_screen") or {}
fields = []
for q in sorted(questions, key=lambda x: x.get("position", 0)):
fields.append({
"property": q.get("property_name"),
"label": q.get("label_override") or q.get("property_name"),
"required": bool(q.get("required")),
"help": q.get("help_text") or "",
"widget": q.get("widget") or "auto",
"max_selections": q.get("max_selections"),
"conditional": json.loads(q["conditional_json"]) if q.get("conditional_json") else None,
})
return {
"title": cfg.get("title") or "Form",
"description": cfg.get("description") or "",
"fields": fields,
"sharing": {
"audience": sharing.get("audience", "workspace"),
"anonymous": bool(sharing.get("anonymous", False)),
"respondent_access": sharing.get("respondent_access", "none"),
"branding": sharing.get("branding", True),
},
"submit": {
"button_text": submit.get("button_text", "Envoyer"),
"button_color": submit.get("button_color", "blue"),
"confirmation_title": submit.get("confirmation_title", "Merci !"),
"confirmation_body": submit.get("confirmation_body", "Votre réponse a été enregistrée."),
},
"form_view_id": cfg.get("form_view_id"),
}
def default_questions_for(properties: list[dict]) -> list[dict]:
"""Questions initiales depuis les proprietes exposables (hors formules)."""
skip = {"formula", "rollup", "button", "unique_id",
"created_time", "created_by", "last_edited_time", "last_edited_by"}
out = []
for i, p in enumerate(properties):
if p.get("prop_type") in skip:
continue
out.append({
"property_name": p.get("name"),
"position": i,
"label_override": None,
"sync_label": 1,
"help_text": "",
"required": 0,
"widget": "auto",
"max_selections": None,
"conditional_json": None,
})
return out
+154
View File
@@ -0,0 +1,154 @@
"""FlowDeck — Geocoding Service (vue Map §13.4).
Interface de fournisseur unique : search / resolve / reverse.
Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun
appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie,
<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim).
Le cache SQLite (migration 45, table ``geocode_cache``) est consulte
avant tout appel. Les journaux ne contiennent jamais d'adresses en clair
au-dela de compteurs (le texte reste dans le cache, purgeable).
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
import urllib.parse
import urllib.request
from typing import Any
logger = logging.getLogger(__name__)
_last_call_ts = 0.0
def _norm(query: str) -> str:
return " ".join((query or "").lower().split())
def query_hash(query: str, provider: str) -> str:
return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest()
def get_settings(conn=None) -> dict:
"""Lit la config geocodeur (instance). Defauts surs : provider=none."""
try:
from app.config import settings as _s # type: ignore
provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none"
endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org"
return {"provider": provider, "endpoint": endpoint.rstrip("/")}
except Exception:
return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"}
def cache_get(conn, qhash: str) -> dict | None:
try:
row = conn.execute(
"SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?",
(qhash,),
).fetchone()
except Exception:
return None
if not row:
return None
try:
conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,))
except Exception:
pass
try:
data = json.loads(row["result_json"]) if row["result_json"] else {}
except (json.JSONDecodeError, TypeError):
data = {}
data.setdefault("lat", row["lat"])
data.setdefault("lng", row["lng"])
return data
def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None:
try:
conn.execute(
"""INSERT OR REPLACE INTO geocode_cache
(query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE(
(SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""",
(qhash, provider, query_text[:300], json.dumps(result),
result.get("lat"), result.get("lng"), qhash),
)
except Exception:
pass
def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any:
global _last_call_ts
# Politesse Nominatim : <= 1 req/s.
wait = 1.0 - (time.time() - _last_call_ts)
if wait > 0:
time.sleep(wait)
full = url + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(full, headers={
"User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)",
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=timeout) as resp:
_last_call_ts = time.time()
return json.loads(resp.read().decode("utf-8", "replace"))
def search(conn, query: str, limit: int = 5) -> list[dict]:
"""Autocompletion d'adresses. Vide quand provider=none."""
cfg = get_settings(conn)
provider = cfg["provider"]
if not query or len(query.strip()) < 3 or provider == "none":
return []
qhash = query_hash(f"search:{query}:{limit}", provider)
cached = cache_get(conn, qhash)
if cached and isinstance(cached.get("suggestions"), list):
return cached["suggestions"][:limit]
base = cfg["endpoint"]
try:
if provider in ("nominatim", "custom"):
data = _http_json(f"{base}/search", {
"q": query, "format": "jsonv2", "limit": limit,
"addressdetails": 1,
})
suggestions = [{
"name": (d.get("display_name") or "").split(",")[0].strip(),
"address": d.get("display_name", ""),
"lat": float(d["lat"]), "lng": float(d["lon"]),
"provider": provider, "place_id": str(d.get("place_id", "")),
} for d in (data or []) if d.get("lat") and d.get("lon")]
else:
suggestions = []
except Exception as exc:
logger.warning("geocode search failed: %s", type(exc).__name__)
return []
try:
cache_put(conn, qhash, provider, query, {"suggestions": suggestions})
conn.commit()
except Exception:
pass
return suggestions
def resolve(conn, query: str) -> dict | None:
"""Adresse -> {lat, lng, ...}. Cache d'abord, None si echec."""
cfg = get_settings(conn)
provider = cfg["provider"]
if not query or provider == "none":
return None
qhash = query_hash(f"resolve:{query}", provider)
cached = cache_get(conn, qhash)
if cached and cached.get("lat") is not None:
return cached
results = search(conn, query, limit=1)
if not results:
return None
first = dict(results[0])
try:
cache_put(conn, qhash, provider, query, first)
conn.commit()
except Exception:
pass
return first
+45 -5
View File
@@ -97,14 +97,20 @@ PROPERTY_TYPES: dict[str, dict] = {
"default": None,
},
"button": {
"storage": "none — runs linked automation (button_automation_id)",
"storage": "none - runs linked automation (button_automation_id)",
"default": None,
},
"place": {
"storage": "json object {name, address, lat, lng, provider, place_id, resolved_at, manual}",
"validation": "name or address required, lat in [-90,90], lng in [-180,180]",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
"date", "person", "checkbox", "url", "email", "phone", "files"]
"date", "person", "checkbox", "url", "email", "phone", "files",
"place"]
# Auto-property types (computed server-side)
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
@@ -136,10 +142,19 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
return False, f"'{v}' is not a valid option"
elif prop_type == "date":
if isinstance(value, str) and value:
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
# Les plages « start...end » (Timeline, auto-shift) sont valides :
# chaque borne est controlee separement.
parts = value.split("...") if "..." in value else [value]
if len(parts) > 2 or not any(p.strip() for p in parts):
return False, f"'{value}' is not a valid ISO 8601 date"
for part in parts:
part = part.strip()
if not part:
continue
try:
datetime.fromisoformat(part.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
@@ -152,6 +167,31 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
elif prop_type == "phone":
if not isinstance(value, str):
return False, "Phone must be a string"
elif prop_type == "place":
if value in ("", {}):
return True, ""
if isinstance(value, str):
# Plain-text address (unresolved place, e.g. CSV import) is valid.
return True, ""
if not isinstance(value, dict):
return False, "Place must be an object {name, address, lat, lng}"
if not (value.get("name") or value.get("address")):
return False, "Place requires a name or an address"
lat, lng = value.get("lat"), value.get("lng")
if lat is not None and lat != "":
try:
lat = float(lat)
except (ValueError, TypeError):
return False, "Place latitude must be a number"
if not -90 <= lat <= 90:
return False, "Place latitude must be between -90 and 90"
if lng is not None and lng != "":
try:
lng = float(lng)
except (ValueError, TypeError):
return False, "Place longitude must be a number"
if not -180 <= lng <= 180:
return False, "Place longitude must be between -180 and 180"
return True, ""
+119 -17
View File
@@ -703,32 +703,134 @@ class AddDependency(Tool):
class ApplyTemplate(Tool):
name = "apply_template"
description = "Applique un template de page dans une collection (crée une page)."
description = (
"Applique un template du registre unifié (crée une page, une ligne, "
"une base ou des blocs). Même service que l'UI (TemplateService)."
)
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "template_id"],
"properties": {
"collection_id": {"type": "integer"},
"template_id": {"type": ["integer", "string"]},
"title": {"type": "string"},
"variables": {"type": "object"},
},
"required": ["template_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, tid = args.get("collection_id"), args.get("template_id")
from app.services.fd_templates import TemplateService, registry_ready
tid = args.get("template_id")
cid = args.get("collection_id")
# Compat legacy : un id numérique de page_templates.
template_ref: str = str(tid)
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
if not tmpl:
return ToolResult(status="error", tool=self.name, message="Template introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
actor = _actor_for(conn, user_id)
if registry_ready(conn) and isinstance(tid, int):
legacy = conn.execute(
"SELECT id FROM templates WHERE source_kind='legacy_page' AND source_id=?",
(str(tid),),
).fetchone()
if legacy:
template_ref = legacy["id"]
variables = dict(args.get("variables") or {})
if args.get("title"):
variables.setdefault("title", args["title"])
context: dict = {}
if cid:
context["collection_id"] = cid
try:
result = TemplateService.instantiate(
conn, actor, template_ref, variables, context,
run_source="agent",
)
except LookupError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except (ValueError, PermissionError) as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — l'agent doit recevoir l'erreur
logger.exception("apply_template agent")
return ToolResult(status="error", tool=self.name, message=str(exc))
created_id = result.get("created_id") or None
try:
target_id = int(created_id) if created_id else 0
except (TypeError, ValueError):
target_id = 0
undo = None
if result.get("created_type") in ("page", "row", "collection") and target_id:
table = {"page": "pages", "row": "collection_pages",
"collection": "collections"}[result["created_type"]]
undo = {"action": "delete", "table": table, "id": target_id}
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
status="success", tool=self.name,
target_type=result.get("created_type") or "page",
target_id=target_id,
data={"page_id": target_id, "url": result.get("url"),
"warnings": result.get("warnings", []),
"run_id": result.get("run_id")},
undo=undo,
)
class ListTemplates(Tool):
name = "list_templates"
description = "Liste les templates visibles par l'utilisateur (registre unifié)."
parameters = {
"type": "object",
"properties": {
"kind": {"type": "string"},
"query": {"type": "string"},
"target_collection_id": {"type": "integer"},
},
"required": [],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.fd_templates import TemplateService
with get_conn() as conn:
actor = _actor_for(conn, user_id)
try:
items = TemplateService.list_templates(
conn, actor, kind=args.get("kind"),
query=args.get("query"),
target_collection_id=args.get("target_collection_id"),
)
except Exception as exc: # noqa: BLE001
logger.exception("list_templates agent")
return ToolResult(status="error", tool=self.name, message=str(exc))
summary = [
{"id": t["id"], "kind": t["kind"], "name": t["name"],
"scope": t["scope"], "is_default": t.get("is_default", False)}
for t in items
]
return ToolResult(
status="success", tool=self.name, target_type="templates",
data={"templates": summary},
)
def _actor_for(conn, user_id: int | None) -> dict:
"""Acteur minimal pour le service (l'agent n'agit jamais au-delà de
l'utilisateur appelant, §6.1 de l'architecture)."""
actor: dict = {"id": user_id}
if user_id:
try:
row = conn.execute(
"SELECT id, login, full_name, is_admin FROM users WHERE id=?",
(user_id,),
).fetchone()
if row:
actor.update(
{"login": row["login"], "full_name": row["full_name"],
"is_admin": bool(row["is_admin"])}
)
except Exception:
pass
return actor
# ══════════════════════════ Destructive tools ══════════════════════════
@@ -1149,7 +1251,7 @@ TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
UpdatePage, WriteBlocks,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate, ListTemplates,
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
+221
View File
@@ -0,0 +1,221 @@
"""FlowDeck — Aggregation Service (vues Notion §8.3).
Un seul moteur d'agregation cote serveur pour Chart, KPI, widgets de
Dashboard et drilldown : grouper / sous-grouper / mesurer / cumuler /
limiter, avec contrat de sortie normalise.
Mesures : count | sum | average | median | min | max (definitions alignees
sur le Rollup Engine). Groupes : ordre du schema pour select/status/
multi_select (couleurs des options incluses), chronologique pour les dates.
"""
from __future__ import annotations
import json
from typing import Any
from .view_query import (
CHART_MAX_GROUPS,
CHART_MAX_SUBGROUPS,
apply_filters,
apply_sorts,
prop_value,
text_of,
)
NON_GROUPABLE = {"rollup", "button", "unique_id", "files"}
NON_MEASURABLE_AXES = {"rollup", "button", "unique_id", "files"}
def _schema_options(prop: dict) -> list[dict]:
try:
opts = json.loads(prop.get("options_json") or "[]")
except (json.JSONDecodeError, TypeError):
opts = []
out = []
for o in opts or []:
if isinstance(o, str):
out.append({"name": o, "color": "gray"})
elif isinstance(o, dict):
out.append({"name": o.get("name", ""), "color": o.get("color", "gray")})
return out
def _measure_values(rows: list[dict], measure_prop: dict | None) -> list[float]:
vals: list[float] = []
for p in rows:
if measure_prop is None:
vals.append(1.0)
continue
v = prop_value(p, measure_prop)
if v is None or v == "":
continue
try:
vals.append(float(v) if not isinstance(v, bool) else float(v))
except (ValueError, TypeError):
continue
return vals
def _reduce(kind: str, vals: list[float], count: int) -> float:
if kind == "count":
return float(count)
if not vals:
return 0.0
if kind == "sum":
return float(sum(vals))
if kind in ("average", "avg"):
return float(sum(vals) / len(vals))
if kind == "median":
s = sorted(vals)
mid = len(s) // 2
return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2)
if kind == "min":
return float(min(vals))
if kind == "max":
return float(max(vals))
return float(count)
def _date_bucket(value: Any, span_days: int) -> str:
s = str(value or "")[:10]
if len(s) < 10:
return s or "(empty)"
if span_days <= 62:
return s
if span_days <= 371:
# week bucket: keep ISO week label
try:
from datetime import date as _d
d = _d.fromisoformat(s)
iso = d.isocalendar()
return f"{iso.year}-W{iso.week:02d}"
except ValueError:
return s[:7]
return s[:7]
def aggregate(pages: list[dict], properties: list[dict], spec: dict) -> dict:
"""Calcule l'agregat normalise ``{groups, total, truncated, scanned}``."""
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
group_prop = by_name.get(spec.get("group_by")) or by_id.get(str(spec.get("group_by", "")))
sub_prop = None
if spec.get("sub_group_by"):
sub_prop = by_name.get(spec["sub_group_by"]) or by_id.get(str(spec["sub_group_by"]))
measure = spec.get("measure") or {"kind": "count"}
kind = measure.get("kind", "count")
measure_prop = None
if kind != "count" and measure.get("property"):
measure_prop = by_name.get(measure["property"]) or by_id.get(str(measure["property"]))
filters = spec.get("filters") or []
rows = apply_filters(pages, properties, filters, spec.get("filter_conjunction", "and"))
rows = apply_sorts(rows, properties, spec.get("sorts"))
scanned = len(rows)
hidden = set(spec.get("hidden_groups") or [])
omit_zero = spec.get("omit_zero", True)
cumulative = bool(spec.get("cumulative"))
order_cfg = spec.get("order") or {"by": "group_order", "direction": "asc"}
if isinstance(order_cfg, str):
order_cfg = {"by": order_cfg, "direction": "asc"}
# Date grouping needs the span first.
is_date = group_prop is not None and group_prop.get("prop_type") == "date"
span_days = 0
if is_date:
dates = sorted(text_of(group_prop, prop_value(p, group_prop))[:10]
for p in rows if text_of(group_prop, prop_value(p, group_prop)))
if len(dates) >= 2:
try:
from datetime import date as _d
span_days = (_d.fromisoformat(dates[-1][:10]) - _d.fromisoformat(dates[0][:10])).days
except ValueError:
span_days = 0
buckets: dict[str, list[dict]] = {}
for p in rows:
if group_prop is None:
key = "All"
elif group_prop.get("prop_type") == "multi_select":
v = prop_value(p, group_prop)
keys = v if isinstance(v, list) and v else ["(empty)"]
for k in keys:
buckets.setdefault(str(k or "(empty)"), []).append(p)
continue
elif is_date:
key = _date_bucket(prop_value(p, group_prop), span_days)
else:
t = text_of(group_prop, prop_value(p, group_prop))
key = t if t else "(empty)"
buckets.setdefault(key, []).append(p)
# Empty schema options (omit_zero=False) for select/status.
if group_prop is not None and not omit_zero and group_prop.get("prop_type") in ("select", "status"):
for o in _schema_options(group_prop):
buckets.setdefault(o["name"], [])
schema_order = [o["name"] for o in _schema_options(group_prop)] if group_prop else []
opt_colors = {o["name"]: o.get("color", "gray") for o in _schema_options(group_prop)} if group_prop else {}
def sort_key(item: tuple[str, list]) -> Any:
k, members = item
by = order_cfg.get("by", "group_order")
if by == "value":
mvals = _measure_values(members, measure_prop)
return _reduce(kind, mvals, len(members))
if by == "label":
return k.lower()
if group_prop is not None and group_prop.get("prop_type") == "date":
return k
if k in schema_order:
return (0, schema_order.index(k))
return (1, k.lower())
items = sorted(buckets.items(), key=sort_key)
if str(order_cfg.get("direction", "asc")).lower() in ("desc", "descending"):
items = list(reversed(items))
truncated = False
if len(items) > CHART_MAX_GROUPS:
items = items[:CHART_MAX_GROUPS]
truncated = True
groups = []
running = 0.0
for key, members in items:
if key in hidden:
continue
mvals = _measure_values(members, measure_prop)
value = _reduce(kind, mvals, len(members))
if cumulative and kind in ("count", "sum"):
running += value
value = running
sub = []
if sub_prop is not None:
sub_buckets: dict[str, list] = {}
for p in members:
sk = text_of(sub_prop, prop_value(p, sub_prop)) or "(empty)"
sub_buckets.setdefault(sk, []).append(p)
for sk, sp in list(sub_buckets.items())[:CHART_MAX_SUBGROUPS]:
svals = _measure_values(sp, measure_prop)
sub.append({"key": sk, "label": sk, "value": _reduce(kind, svals, len(sp))})
sub.sort(key=lambda g: -g["value"])
groups.append({
"key": key,
"label": key,
"color": opt_colors.get(key, "gray"),
"value": value,
"count": len(members),
"row_ids": [p.get("id") for p in members[:500]],
"sub": sub,
})
total_vals = _measure_values(rows, measure_prop)
total = _reduce(kind, total_vals, len(rows))
grand = sum(g["value"] for g in groups) or 1
for g in groups:
g["percent"] = g["value"] / grand if grand else 0
return {"groups": groups, "total": total, "truncated": truncated,
"scanned": scanned, "measure": kind}
+166
View File
@@ -0,0 +1,166 @@
"""FlowDeck — View Query Service (vues Notion §8.1).
Extraction partagee de la chaine de rendu des vues : charger le schema,
appliquer filtres (and/or) -> tris -> projection des proprietes visibles.
Toutes les vues clientes (Table/Board/... + chart/timeline/feed/map/
dashboard/form) en sont des consommatrices.
ACL : le filtrage des proprietes se fait AVANT tout calcul — une vue
n'agrege / ne geocode / n'affiche jamais une propriete invisible.
En v1 on s'appuie sur les ACL de collection (le granulair propriete est
applique quand `property_acls` est disponible, sinon tout est visible).
"""
from __future__ import annotations
import json
from typing import Any
CHART_MAX_GROUPS = 200
CHART_MAX_SUBGROUPS = 50
VIEW_ROWS_MAX = 1000
MAP_POINTS_MAX = 100
TIMELINE_LOAD_MAX = 200
FEED_PAGE_SIZE = 20
def parse_values(page: dict) -> dict:
try:
v = page.get("property_values_json", "{}")
return json.loads(v) if isinstance(v, str) else (v or {})
except (json.JSONDecodeError, TypeError):
return {}
def prop_value(page: dict, prop: dict) -> Any:
if prop.get("prop_type") == "title":
return page.get("title", "")
vals = parse_values(page)
v = vals.get(str(prop.get("id")))
if v is None:
v = vals.get(prop.get("name"))
return v
def text_of(prop: dict, value: Any) -> str:
if value is None:
return ""
if isinstance(value, dict):
# place / person-single / user ref
return str(value.get("name") or value.get("address")
or value.get("login") or value.get("full_name") or "")
if isinstance(value, list):
parts = []
for x in value:
if isinstance(x, dict):
parts.append(str(x.get("login") or x.get("full_name")
or x.get("name") or x.get("url") or ""))
else:
parts.append(str(x))
return ", ".join(parts)
return str(value)
def _match_op(actual: Any, op: str, expected: Any, prop: dict) -> bool:
actual_text = text_of(prop, actual).lower()
if op in ("is", "equals", "is exactly"):
if isinstance(actual, list):
return str(expected) in [str(x.get("name", x) if isinstance(x, dict) else x) for x in actual]
return actual_text == str(expected or "").lower()
if op in ("is_not", "is not", "not_equals"):
return actual_text != str(expected or "").lower()
if op == "contains":
return str(expected or "").lower() in actual_text
if op in ("not_contains", "does_not_contain"):
return str(expected or "").lower() not in actual_text
if op == "is_empty":
return actual in (None, "", []) or actual_text == ""
if op == "is_not_empty":
return not (actual in (None, "", []) or actual_text == "")
if op in ("gt", ">", "after"):
try:
return float(actual_text) > float(expected) if actual_text else False
except (ValueError, TypeError):
return str(actual_text) > str(expected or "")
if op in ("lt", "<", "before"):
try:
return float(actual_text) < float(expected) if actual_text else False
except (ValueError, TypeError):
return str(actual_text) < str(expected or "")
if op == "is_resolved":
return isinstance(actual, dict) and actual.get("lat") not in (None, "")
if op == "is_not_resolved":
return not (isinstance(actual, dict) and actual.get("lat") not in (None, ""))
return True
def apply_filters(pages: list[dict], properties: list[dict],
filters: list[dict] | None,
conjunction: str = "and") -> list[dict]:
if not filters:
return list(pages)
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
out = []
for page in pages:
results = []
for f in filters:
prop = by_name.get(f.get("property")) or by_id.get(str(f.get("property", "")))
if prop is None:
continue
actual = prop_value(page, prop)
results.append(_match_op(actual, f.get("operator", "contains"), f.get("value"), prop))
if not results:
out.append(page)
elif (conjunction or "and").lower() == "or":
if any(results):
out.append(page)
elif all(results):
out.append(page)
return out
def apply_sorts(pages: list[dict], properties: list[dict],
sorts: list[dict] | None) -> list[dict]:
if not sorts:
return list(pages)
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
rows = list(pages)
for s in reversed(sorts):
prop = by_name.get(s.get("property")) or by_id.get(str(s.get("property", "")))
if prop is None:
continue
reverse = str(s.get("direction", "asc")).lower() in ("desc", "descending")
rows.sort(key=lambda p: text_of(prop, prop_value(p, prop)).lower(), reverse=reverse)
return rows
def query_view_rows(pages: list[dict], properties: list[dict],
config: dict, extra_filters: list[dict] | None = None,
limit: int = VIEW_ROWS_MAX,
cursor: str | None = None) -> tuple[list[dict], str | None]:
"""Filtre + tri + projection + pagination par curseur (id)."""
filters = list(config.get("filters") or [])
if extra_filters:
filters.extend(extra_filters)
conjunction = config.get("filter_conjunction", "and")
rows = apply_filters(pages, properties, filters, conjunction)
rows = apply_sorts(pages and rows, properties, config.get("sorts"))
start = 0
if cursor:
for i, p in enumerate(rows):
if str(p.get("id")) == str(cursor):
start = i + 1
break
window = rows[start:start + limit]
next_cursor = str(window[-1]["id"]) if len(rows) > start + limit and window else None
return window, next_cursor
def visible_props(properties: list[dict], config: dict) -> list[dict]:
vis = config.get("visible_properties")
if not vis:
return list(properties)
wanted = {str(v) for v in vis}
ordered = [p for p in properties if str(p.get("id")) in wanted or p.get("name") in wanted]
return ordered or list(properties)
+84 -3
View File
@@ -510,6 +510,9 @@
<div class="more-menu-item" @click="moreSaveTemplate()">
📑 Save as template
</div>
<div class="more-menu-item" @click="openTplPicker()">
{{ fd_icon("file",14) }} Apply template…
</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
@@ -746,7 +749,7 @@
</button>
<button
class="gs-pill"
@click="gsMoreOpen = !gsMoreOpen"
@click="openTplPicker()"
>
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
</button>
@@ -797,8 +800,8 @@
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
x-transition.opacity
@click.self="closeDbTemplatePicker()">
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
@@ -833,6 +836,84 @@
</div>
</div>
<!-- ═══════════ Unified template picker (registre unifié, §7.1) ═══════════
Toujours une issue visible (P5) : liste, état vide avec création,
ou erreur explicite avec Réessayer — jamais un no-op silencieux. -->
<div class="fd-tpl-overlay" id="fd-tpl-picker" data-testid="tpl-picker" x-show="tplOpen"
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2200;"
x-transition.opacity
@click.self="closeTplPicker()">
<!-- Enveloppe flex SÉPARÉE du x-show : Alpine restaure display:block
sur l'overlay (pas le flex inline) — sans cette enveloppe, le
dialogue reste collé en haut à gauche au lieu d'être centré. -->
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:20px;box-sizing:border-box;">
<div class="fd-tpl" style="width:760px;max-width:96vw;max-height:80vh;display:flex;flex-direction:column;
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Appliquer un template</h3>
<button class="flowdeck-modal-close" @click="closeTplPicker()">&times;</button>
</div>
<div style="padding:12px 20px 0;">
<input type="text" x-model="tplQuery" @input.debounce.300ms="loadTplList()" placeholder="🔍 Rechercher un template…"
style="width:100%;padding:9px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
<div class="fd-gtpl-filters" style="margin-top:10px;">
<template x-for="k in tplKindOptionsList" :key="k">
<button type="button" class="fd-tpl-filter" :class="tplKind === k ? 'active' : ''"
@click="setTplKind(k)" x-text="tplKindLabel(k)"></button>
</template>
<a href="/templates" style="margin-left:auto;font-size:12px;color:var(--text-dim);align-self:center;text-decoration:none;padding-right:6px;">Gérer les templates…</a>
</div>
</div>
<div style="display:flex;gap:0;flex:1;min-height:0;padding:0 20px 12px;">
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;padding-right:8px;">
<div x-show="tplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Chargement…</div>
<div x-show="tplError" style="color:#e5484d;font-size:13px;padding:12px;background:rgba(229,72,77,.1);border-radius:8px;margin-bottom:8px;">
<span x-text="tplError"></span>
<button @click="loadTplList()" style="margin-left:8px;">Réessayer</button>
</div>
<template x-for="t in tplItems" :key="t.id">
<div data-testid="tpl-item" @click="selectTpl(t)" :style="tplSelected && tplSelected.id === t.id ? 'border-color:var(--accent);' : ''"
style="display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:8px;cursor:pointer;">
<span style="font-size:18px;" x-text="t.icon || '📄'"></span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:var(--text,#fff);font-size:13px;" x-text="t.name"></div>
<div style="font-size:11px;color:var(--text-dim,#999);" x-text="(t.kind + ' · ' + t.scope)"></div>
</div>
</div>
</template>
<div x-show="!tplLoading && !tplError && !tplItems.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
Aucun template pour ce filtre — <a href="/templates">créez-en un</a> ou choisissez un preset système.
</div>
</div>
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;border-left:1px solid var(--border);padding-left:12px;" x-show="tplSelected">
<div style="font-weight:600;color:var(--text,#fff);font-size:14px;margin-bottom:4px;" x-text="tplPreviewTitle"></div>
<div x-show="tplPreviewWarnings.length" style="color:#f5a524;font-size:12px;margin-bottom:6px;" x-text="tplPreviewWarnings.join(' · ')"></div>
<template x-for="b in tplPreviewBlocks.slice(0,8)" :key="b.id">
<div style="font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
</template>
<div x-show="tplVarDefs.length" style="margin-top:8px;">
<div style="font-size:12px;font-weight:600;color:var(--text,#fff);margin-bottom:4px;">Variables</div>
<template x-for="v in tplVarDefs" :key="v.name">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:12px;color:var(--text-dim);min-width:80px;" x-text="v.label || v.name"></span>
<input type="text" :placeholder="v.default || ''" @input="setTplVar(v.name, $event.target.value)"
style="flex:1;padding:5px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:12px;">
</div>
</template>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border);">
<button class="flowdeck-modal-close" @click="closeTplPicker()" style="padding:8px 16px;position:static;">Annuler</button>
<button data-testid="tpl-apply" @click="applyTpl()" :disabled="!tplSelected || tplApplying"
style="padding:8px 20px;border-radius:8px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:.95;"
x-text="tplApplying ? 'Application…' : 'Utiliser'"></button>
</div>
</div>
</div>
</div>
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
<div class="versions-popover"
x-show="versionsOpen"
+183 -88
View File
@@ -608,6 +608,9 @@
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a>
<a href="/templates" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Templates</span>
</a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a>
@@ -758,6 +761,9 @@
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('template')">
<span>{{ fd_icon("copy",16) }}</span><span>From template…</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
</button>
@@ -1165,105 +1171,189 @@
},
/**
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
* Create a new page: **blank by default** (Notion behaviour — the "New"
* button creates an empty document immediately). Template-based creation
* lives in {@link createPageFromTemplate}.
*/
createPage: function(parentId) {
var self = this;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
this._createPlainPage();
},
/** Create an empty page and navigate to it. */
_createPlainPage: function() {
var self = this;
var ctx = this._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._createParentId) body.parent_id = this._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
},
/** Open the unified template picker (registry-backed) to create from a template. */
createPageFromTemplate: function(parentId, kind) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
this._openTemplatePicker(kind);
},
/**
* Unified template picker (registre `templates`, §7.1) — self-contained DOM
* modal usable on ANY page (sidebar, workspace local, library). Always shows
* a visible state (list / empty / error) and a working Cancel + ✕ (P5).
*/
_openTemplatePicker: function(kindFilter) {
var self = this;
var existing = document.getElementById('fd-global-tpl-picker');
if (existing) existing.remove();
var state = { kind: kindFilter || 'page', query: '', items: [], selected: null };
var ov = document.createElement('div');
ov.id = 'fd-global-tpl-picker';
ov.className = 'fd-gtpl-overlay open';
ov.innerHTML =
'<div class="fd-gtpl" role="dialog" aria-label="Nouveau depuis un template">'
+ '<div class="fd-gtpl-head"><span class="fd-gtpl-title">Nouveau depuis un template</span>'
+ '<button type="button" class="fd-gtpl-x" data-gtpl="close" aria-label="Fermer">&times;</button></div>'
+ '<div class="fd-gtpl-body">'
+ '<input type="text" class="fd-gtpl-search" data-gtpl="search" placeholder="🔍 Rechercher un template…">'
+ '<div class="fd-gtpl-filters" data-gtpl="filters"></div>'
+ '<div class="fd-gtpl-list" data-gtpl="list"><div class="fd-gtpl-empty">Chargement…</div></div>'
+ '</div>'
+ '<div class="fd-gtpl-foot">'
+ '<button type="button" class="btn-sm" data-gtpl="cancel" style="padding:8px 16px">Annuler</button>'
+ '<button type="button" class="btn-sm" data-gtpl="use" style="padding:8px 20px;background:var(--accent);color:#fff;border:none;font-weight:600;cursor:pointer" disabled>Utiliser</button>'
+ '</div></div>';
document.body.appendChild(ov);
var KINDS = [
{ k: 'page', label: 'Pages' },
{ k: 'database', label: 'Bases' },
{ k: 'row', label: 'Lignes' },
{ k: 'blocks', label: 'Blocs' },
{ k: '', label: 'Tous' },
];
var labels = { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' };
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
var elFilters = ov.querySelector('[data-gtpl="filters"]');
var elList = ov.querySelector('[data-gtpl="list"]');
var elSearch = ov.querySelector('[data-gtpl="search"]');
var elUse = ov.querySelector('[data-gtpl="use"]');
function renderFilters() {
elFilters.innerHTML = KINDS.map(function(k) {
var active = state.kind === k.k ? ' active' : '';
return '<button type="button" class="fd-tpl-filter' + active + '" data-kind="' + k.k + '">' + k.label + '</button>';
}).join('') + '<a href="/templates" style="margin-left:auto;align-self:center;font-size:12px;color:var(--text-dim);text-decoration:none;">Gérer…</a>';
elFilters.querySelectorAll('[data-kind]').forEach(function(b) {
b.onclick = function() { state.kind = b.getAttribute('data-kind'); renderFilters(); load(); };
});
}
function renderList() {
if (!state.items.length) {
elList.innerHTML = '<div class="fd-gtpl-empty">Aucun template pour ce filtre — <a href="/templates">créez-en un</a>.</div>';
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
elList.innerHTML = state.items.map(function(t, i) {
var sel = state.selected && state.selected.id === t.id ? ' sel' : '';
return '<div class="fd-gtpl-card' + sel + '" data-idx="' + i + '">'
+ '<span class="fd-gtpl-ico">' + esc(t.icon || '📄') + '</span>'
+ '<span style="flex:1;min-width:0;"><span class="fd-gtpl-name">' + esc(t.name) + '</span>'
+ '<div class="fd-gtpl-meta">' + esc((labels[t.kind] || t.kind) + ' · ' + t.scope) + '</div></span></div>';
}).join('');
elList.querySelectorAll('[data-idx]').forEach(function(card) {
card.onclick = function() {
state.selected = state.items[parseInt(card.getAttribute('data-idx'), 10)];
elUse.disabled = false;
renderList();
};
});
}
function load() {
elList.innerHTML = '<div class="fd-gtpl-empty">Chargement…</div>';
var p = new URLSearchParams();
if (state.kind) p.set('kind', state.kind);
if (state.query) p.set('q', state.query);
fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' })
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(d) { state.items = d.templates || []; renderList(); })
.catch(function(e) {
elList.innerHTML = '<div class="fd-gtpl-empty" style="color:var(--danger)">Chargement impossible : ' + esc(e.message)
+ ' <button type="button" class="btn-sm" data-retry style="margin-left:8px">Réessayer</button></div>';
var rb = elList.querySelector('[data-retry]');
if (rb) rb.onclick = load;
});
}
function close() { ov.remove(); }
ov.addEventListener('click', function(e) { if (e.target === ov) close(); });
ov.querySelector('[data-gtpl="close"]').onclick = close;
ov.querySelector('[data-gtpl="cancel"]').onclick = close;
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
elSearch.oninput = function() { state.query = elSearch.value; };
elSearch.onkeydown = function(ev) { if (ev.key === 'Enter') load(); };
elUse.onclick = function() { self._applyGlobalTemplate(state.selected, elUse, close); };
renderFilters();
load();
setTimeout(function() { elSearch.focus(); }, 50);
},
_openTemplatePicker: function() {
_applyGlobalTemplate: function(t, btn, close) {
if (!t) return;
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
// Pass the current workspace context so the created page is persisted
// in the active local/Gitea workspace (mirrors _createPlainPage()).
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
var ctx = this._getContext();
var context = {};
if (this._createParentId) context.parent_id = this._createParentId;
if (ctx.wsId > 0) context.workspace_id = ctx.wsId;
if (btn) { btn.disabled = true; btn.textContent = 'Application…'; }
fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
body: JSON.stringify({ variables: {}, context: context })
})
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
.then(function(r) {
if (!r.ok) return r.text().then(function(txt) { throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 160)); });
return r.json();
})
.then(function(d) {
(d.warnings || []).forEach(function(w) { self._toast(w, 'error'); });
if (close) close();
if (d.url) {
if (window.fdNavigate) window.fdNavigate(d.url); else window.location.href = d.url;
} else {
self._toast('Créé depuis « ' + t.name + ' »', 'success');
}
})
.catch(function(e) {
if (btn) { btn.disabled = false; btn.textContent = 'Utiliser'; }
self._toast('Échec : ' + e.message, 'error');
});
},
/** Show the global New Folder modal (parentId = target folder, optional). */
@@ -1836,6 +1926,8 @@
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'template') {
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) window.FlowDeck.createPageFromTemplate();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
@@ -1843,8 +1935,11 @@
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (typeof this.createDatabase === 'function') this.createDatabase();
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) {
window.FlowDeck.createPageFromTemplate(null, 'database');
} else if (typeof this.createDatabase === 'function') {
this.createDatabase();
}
}
},
// ── Sidebar customization ──
+368
View File
@@ -0,0 +1,368 @@
{% extends "base.html" %}
{% block page_icon %}{{ fd_icon("file",18) }}{% endblock %}
{% block page_title %}Templates{% endblock %}
{% block title_prefix %}Templates{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">{{ fd_icon("file",24) }}</span>
<h1>Templates</h1>
</div>
<p style="color:var(--text-dim);font-size:13px;margin:4px 0 0;">
Modèles réutilisables : pages, lignes de base, bases entières, blocs.
<span x-show="loading">Chargement…</span>
</p>
</div>
<div x-data="fdTemplatesManager()" x-init="init()" class="lib-container">
<!-- Toolbar -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
<div style="position:relative;flex:1;min-width:180px;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Rechercher un template…" x-model="search" @input.debounce.300ms="reload()"
style="width:100%;padding:8px 12px 8px 36px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
</div>
<select x-model="kindFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="">Tous genres</option>
<option value="page">Pages</option>
<option value="row">Lignes</option>
<option value="database">Bases</option>
<option value="blocks">Blocs</option>
</select>
<select x-model="scopeFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="">Toutes portées</option>
<option value="personal">Personnel</option>
<option value="workspace">Workspace</option>
<option value="teamspace">Teamspace</option>
<option value="system">Système</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);">
<input type="checkbox" x-model="showArchived" @change="reload()" style="accent-color:var(--accent);"> Archivés
</label>
<button class="btn-sm" @click="createOpen = !createOpen" style="padding:8px 14px;">+ Nouveau ▾</button>
</div>
<!-- Panneau création -->
<div x-show="createOpen" @click.outside="createOpen = false" x-transition
style="background:var(--bg-secondary,#232323);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:640px;">
<input x-model="form.name" placeholder="Nom du template"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<select x-model="form.kind" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="page">Template de page</option>
<option value="row">Template de ligne</option>
<option value="database">Template de base</option>
<option value="blocks">Template de blocs</option>
</select>
<input x-model="form.target_collection_id" x-show="form.kind === 'row'" placeholder="ID de la base cible"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<input x-model="form.from_page_id" placeholder="Depuis la page n°… (optionnel)"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
</div>
<div x-show="formError" x-text="formError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="btn-sm" @click="create()" style="padding:8px 16px;">Créer</button>
<button class="btn-sm" @click="createOpen = false" style="padding:8px 16px;">Annuler</button>
<label style="margin-left:auto;font-size:13px;color:var(--text-dim);cursor:pointer;">
Importer un .json <input type="file" accept=".json,application/json" @change="importFile($event)" style="display:none;">
</label>
</div>
</div>
<!-- Erreur globale -->
<div x-show="error" style="background:rgba(229,72,77,.12);border:1px solid #e5484d;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;">
<span x-text="error"></span>
<button @click="reload()" style="margin-left:8px;">Réessayer</button>
</div>
<!-- Liste -->
<div x-show="!loading && !items.length" style="color:var(--text-dim);font-size:13px;padding:24px 0;text-align:center;">
Aucun template. Créez-en un avec « + Nouveau », ou dupliquez un preset système.
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<template x-for="t in items" :key="t.id">
<div @click="select(t)" :class="{selected: selected && selected.id === t.id}"
style="display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;cursor:pointer;background:var(--bg-secondary,#232323);">
<span style="font-size:20px;" x-text="t.icon || '📄'"></span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:14px;color:var(--text,#fff);display:flex;align-items:center;gap:8px;">
<span x-text="t.name"></span>
<span x-show="t.is_system" style="font-size:11px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;color:var(--text-dim);">système</span>
<span x-show="t.is_default" style="font-size:11px;color:#f5a524;">★ défaut</span>
<span x-show="t.recurrence && t.recurrence.enabled" style="font-size:11px;color:var(--text-dim);">↻ <span x-text="t.recurrence.rrule"></span></span>
</div>
<div style="font-size:12px;color:var(--text-dim);" x-text="(kindLabel(t.kind) + ' · ' + t.scope + (t.usage_count ? ' · ' + t.usage_count + ' utilisations' : ''))"></div>
</div>
<button class="btn-sm" @click.stop="useTemplate(t)" style="padding:6px 12px;">Utiliser</button>
</div>
</template>
</div>
<!-- Side peek : détail + réglages -->
<div x-show="selected" x-transition
style="position:fixed;top:0;right:0;bottom:0;width:min(480px,94vw);background:var(--bg-secondary,#232323);border-left:1px solid var(--border);z-index:1200;padding:18px;overflow-y:auto;box-shadow:-16px 0 50px rgba(0,0,0,.5);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span style="font-size:24px;" x-text="selected.icon"></span>
<input x-model="selected.name" @change="saveMeta()" :disabled="selected.is_system"
style="flex:1;font-size:17px;font-weight:700;background:transparent;border:none;color:var(--text,#fff);outline:none;">
<button @click="selected = null" style="background:none;border:none;color:var(--text-dim);font-size:18px;cursor:pointer;">✕</button>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;" x-text="(kindLabel(selected.kind) + ' · portée ' + selected.scope)"></div>
<textarea x-model="selected.description" @change="saveMeta()" :disabled="selected.is_system" rows="2" placeholder="Description…"
style="width:100%;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;box-sizing:border-box;"></textarea>
<div x-show="detailError" x-text="detailError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
<h4 style="margin:16px 0 6px;font-size:13px;">Aperçu</h4>
<div style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:13px;">
<div style="font-weight:600;margin-bottom:6px;" x-text="detail.title || selected.name"></div>
<template x-for="b in (detail.blocks_preview || []).slice(0,6)" :key="b.id">
<div style="color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
</template>
<div x-show="!(detail.blocks_preview || []).length" style="color:var(--text-dim);">Aucun contenu.</div>
<div x-show="(detail.warnings || []).length" style="color:#f5a524;font-size:12px;margin-top:6px;" x-text="detail.warnings.join(' · ')"></div>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Variables déclarées (JSON)</h4>
<textarea x-model="variablesText" rows="4" :disabled="selected.is_system"
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="btn-sm" @click="saveVariables()" x-show="!selected.is_system">Enregistrer</button>
</div>
<div x-show="selected.kind === 'row'">
<h4 style="margin:16px 0 6px;font-size:13px;">Propriétés par défaut (JSON, par nom)</h4>
<textarea x-model="defaultsText" rows="4" :disabled="selected.is_system"
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
<button class="btn-sm" @click="saveDefaults()" x-show="!selected.is_system">Enregistrer</button>
<button class="btn-sm" @click="toggleDefault()" x-show="!selected.is_system" x-text="selected.is_default ? 'Retirer le défaut' : 'Définir comme défaut'"></button>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Récurrence (RRULE)</h4>
<div style="display:flex;gap:8px;">
<input x-model="rrule" placeholder="FREQ=WEEKLY;BYDAY=MO" :disabled="selected.is_system"
style="flex:1;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;">
<button class="btn-sm" @click="saveRecurrence()" x-show="!selected.is_system">OK</button>
<button class="btn-sm" @click="clearRecurrence()" x-show="!selected.is_system && selected.recurrence">Arrêter</button>
</div>
<div x-show="selected.recurrence" style="font-size:12px;color:var(--text-dim);margin-top:4px;"
x-text="('Prochain passage : ' + (selected.recurrence.next_run_at || '—'))"></div>
<h4 style="margin:16px 0 6px;font-size:13px;">Journal récent</h4>
<div style="font-size:12px;color:var(--text-dim);">
<template x-for="r in runs" :key="r.id">
<div x-text="(r.created_at + ' · ' + r.run_source + ' · ' + r.status + (r.created_id ? ' → ' + r.created_id : ''))"></div>
</template>
<div x-show="!runs.length">Aucune instanciation journalisée.</div>
</div>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Actions</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-sm" @click="useTemplate(selected)">Utiliser</button>
<button class="btn-sm" @click="duplicate(selected)" x-text="selected.is_system ? 'Dupliquer pour modifier' : 'Dupliquer'"></button>
<button class="btn-sm" @click="exportJson(selected)">Exporter</button>
<button class="btn-sm" @click="archive(selected)" x-show="!selected.is_system" x-text="selected.archived_at ? 'Restaurer' : 'Archiver'"></button>
<button class="btn-sm btn-sm-danger" @click="remove(selected)" x-show="!selected.is_system">Supprimer</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
if (window.Alpine) { Alpine.data('fdTemplatesManager', fdTemplatesManager); }
else document.addEventListener('alpine:init', function () { Alpine.data('fdTemplatesManager', fdTemplatesManager); });
function fdTemplatesManager() {
return {
items: [], selected: null, detail: { blocks_preview: [], warnings: [] },
runs: [], loading: true, error: '', detailError: '',
search: '', kindFilter: '', scopeFilter: '', showArchived: false,
createOpen: false, form: { name: '', kind: 'page', target_collection_id: '', from_page_id: '' },
formError: '', variablesText: '[]', defaultsText: '{}', rrule: '',
csrf() { return (window.getCsrf && window.getCsrf()) || ''; },
headers(json) {
const h = { 'X-CSRF-Token': this.csrf() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
kindLabel(k) { return { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' }[k] || k; },
async init() { await this.reload(); },
async reload() {
this.loading = true; this.error = '';
try {
const p = new URLSearchParams();
if (this.kindFilter) p.set('kind', this.kindFilter);
if (this.scopeFilter) p.set('scope', this.scopeFilter);
if (this.search) p.set('q', this.search);
if (this.showArchived) p.set('include_archived', 'true');
const r = await fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
this.items = d.templates || [];
} catch (e) { this.error = 'Chargement impossible : ' + e.message; this.items = []; }
this.loading = false;
},
async select(t) {
this.selected = t; this.detailError = ''; this.runs = [];
this.variablesText = JSON.stringify(t.variables || [], null, 1);
this.defaultsText = JSON.stringify((t.manifest || {}).property_defaults || {}, null, 1);
this.rrule = (t.recurrence && t.recurrence.rrule) || '';
try {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
this.detail = { title: d.name, blocks_preview: d.blocks_preview || [], warnings: [] };
const pv = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/preview',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (pv.ok) { const pd = await pv.json(); this.detail = { title: pd.title, blocks_preview: pd.blocks_preview || [], warnings: pd.warnings || [] }; }
if (t.kind === 'row') {
const rr = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/runs', { credentials: 'same-origin' });
if (rr.ok) this.runs = (await rr.json()).runs || [];
}
} catch (e) { this.detailError = 'Aperçu impossible : ' + e.message; }
},
async create() {
this.formError = '';
if (!this.form.name.trim()) { this.formError = 'Nom requis.'; return; }
if (this.form.kind === 'row' && !this.form.target_collection_id && !this.form.from_page_id) {
this.formError = 'Un template de ligne exige une base cible (ID).'; return;
}
const body = { name: this.form.name.trim(), kind: this.form.kind, scope: 'personal' };
if (this.form.target_collection_id) body.target_collection_id = parseInt(this.form.target_collection_id, 10);
if (this.form.from_page_id) body.from_page_id = parseInt(this.form.from_page_id, 10);
try {
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
if (!r.ok) { const t = await r.text(); throw new Error('HTTP ' + r.status + ' ' + t.slice(0, 200)); }
this.createOpen = false; this.form = { name: '', kind: 'page', target_collection_id: '', from_page_id: '' };
await this.reload();
window.showToast && window.showToast('Template créé');
} catch (e) { this.formError = 'Création impossible : ' + e.message; }
},
async saveMeta() {
this.detailError = '';
try {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id),
{ method: 'PATCH', headers: this.headers(true), credentials: 'same-origin',
body: JSON.stringify({ name: this.selected.name, description: this.selected.description }) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
const i = this.items.findIndex(x => x.id === this.selected.id);
if (i >= 0) this.items[i] = this.selected;
} catch (e) { this.detailError = 'Enregistrement impossible : ' + e.message; }
},
async saveVariables() {
this.detailError = '';
try {
const v = JSON.parse(this.variablesText);
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/variables',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({ variables: v }) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
} catch (e) { this.detailError = 'Variables invalides ou enregistrement impossible : ' + e.message; }
},
async saveDefaults() {
this.detailError = '';
try {
const v = JSON.parse(this.defaultsText);
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/property-defaults',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(v) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
} catch (e) { this.detailError = 'JSON invalide ou enregistrement impossible : ' + e.message; }
},
async toggleDefault() {
const url = '/api/templates/' + encodeURIComponent(this.selected.id) +
(this.selected.is_default ? '/unset-default' : '/set-default');
const r = await fetch(url, { method: 'POST', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { Object.assign(this.selected, await r.json()); this.reload(); }
else this.detailError = 'Défaut : HTTP ' + r.status;
},
async saveRecurrence() {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin',
body: JSON.stringify({ rrule: this.rrule }) });
if (r.ok) { const d = await r.json(); this.selected.recurrence = d.recurrence; }
else this.detailError = 'Récurrence : HTTP ' + r.status;
},
async clearRecurrence() {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (r.ok) { this.selected.recurrence = null; this.rrule = ''; }
},
async useTemplate(t) {
try {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (!r.ok) { const txt = await r.text(); throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 300)); }
const d = await r.json();
(d.warnings || []).forEach(w => window.showToast && window.showToast(w, 'error'));
window.showToast && window.showToast('Créé depuis « ' + t.name + ' »');
if (d.url) { window.fdNavigate ? window.fdNavigate(d.url) : (window.location = d.url); }
else this.reload();
} catch (e) { this.detailError = 'Application impossible : ' + e.message; window.showToast && window.showToast('Échec : ' + e.message, 'error'); }
},
async duplicate(t) {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/duplicate',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (r.ok) { await this.reload(); window.showToast && window.showToast('Template dupliqué'); }
else this.detailError = 'Duplication : HTTP ' + r.status;
},
async archive(t) {
const action = t.archived_at ? 'restore' : 'archive';
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/' + action,
{ method: 'POST', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { Object.assign(t, await r.json()); this.reload(); }
},
async remove(t) {
if (!confirm('Supprimer « ' + t.name + ' » ? (corbeille)')) return;
const r = await fetch('/api/templates/' + encodeURIComponent(t.id),
{ method: 'DELETE', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { this.selected = null; this.reload(); }
else this.detailError = 'Suppression : HTTP ' + r.status;
},
async exportJson(t) {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
const manifest = {
format: 'flowdeck-template', version: 1, kind: d.kind, name: d.name,
description: d.description, icon: d.icon, category: d.category,
variables: d.variables, manifest: d.manifest, content: { blocks: d.blocks_preview || [] },
};
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'template-' + d.name + '.json'; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
},
async importFile(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
try {
const text = await f.text();
const m = JSON.parse(text);
if (m.format !== 'flowdeck-template') throw new Error('format inconnu');
const body = {
kind: m.kind, name: m.name, description: m.description || '', icon: m.icon || '📄',
category: m.category || '', scope: 'personal', blocks: ((m.content || {}).blocks) || [],
manifest: m.manifest || {}, variables: m.variables || [],
target_collection_id: ((m.target || {}).collection_id) || undefined,
};
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.createOpen = false; await this.reload();
window.showToast && window.showToast('Template importé : ' + m.name);
} catch (e) { this.formError = 'Import impossible : ' + e.message; }
ev.target.value = '';
},
};
}
</script>
{% endblock %}