feat(templates): refonte complète des templates façon Notion + vues/agents-skills
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:
@@ -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:":
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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()">×</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
@@ -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">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
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 ──
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user