feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0)
Moteur d'automatisation complet : declencheurs (evenement / cron / bouton),
conditions combinables (eq, neq, contains, is_empty, is_not_empty, changed)
et actions (webhook, set_property, create_page, notify).
- Migration v5 : tables `automations` + `automation_runs` (avec index).
- Service `app/services/automations.py` : fire_event, cron_due (`*/N`,
minute fixe, @hourly/@daily), scheduler de fond dans le lifespan.
- Router `app/routers/automations.py` : CRUD `/workspace/automations`,
historique des executions, run manuel + run bouton `/api/automations/{id}/run`
(exempt CSRF, comme `/api/agent`).
- Hooks d'evenements dans collections.py / board.py / workspace.py
(collection.* et page.* : created/updated/deleted/moved).
- Bloc `button` dans l'editeur (menu slash) : declenche une regle au clic,
picker d'automation inline, serialisation automations_id/name.
- Panneau Automations dans le Settings (creer/editer/activer/desactiver/
lancer/supprimer + historique), collection scope + JSON conditions/actions.
- 11 tests `tests/test_automations.py` ; suite complete pytest 289 verte.
- Version bump 5.2.0 (VERSION, app/main.py, CHANGELOG).
This commit is contained in:
+13
-2
@@ -16,6 +16,7 @@ from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
@@ -47,20 +48,29 @@ async def lifespan(_app: FastAPI):
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
|
||||
logger.info("FlowDeck v4.10.0 started on port %d", settings.app_port)
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.2.0 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
scheduler_task.cancel()
|
||||
automation_task.cancel()
|
||||
try:
|
||||
await scheduler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await automation_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.1.11",
|
||||
version="5.2.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -90,6 +100,7 @@ app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -172,6 +172,58 @@ def _migration_fts5(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(5, "automations (v5.1.0 rules engine)")
|
||||
def _migration_automations(conn: sqlite3.Connection) -> None:
|
||||
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
|
||||
condition + action) and clickable buttons that trigger actions.
|
||||
|
||||
``automations`` — the rules (event/cron/button trigger, optional
|
||||
condition JSON, actions JSON, run counters).
|
||||
``automation_runs`` — execution history for auditing and the Settings UI.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
|
||||
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
|
||||
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
|
||||
collection_id INTEGER, -- optional scope (event triggers)
|
||||
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
|
||||
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by INTEGER,
|
||||
last_run_at TIMESTAMP,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
trigger_source TEXT NOT NULL DEFAULT 'event',
|
||||
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
collection_id INTEGER,
|
||||
page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(4, "database templates (icon) + property validation")
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.automations import run_automation, get_page_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
|
||||
def _json_or_dumps(val, default="[]"):
|
||||
"""Store JSON string columns without double-encoding."""
|
||||
if val is None:
|
||||
return default
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
json.loads(val)
|
||||
return val
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return json.dumps(val)
|
||||
return json.dumps(val)
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user if user and user.get("id") else {}
|
||||
|
||||
|
||||
def _validate_payload(body: dict) -> None:
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
trigger_type = body.get("trigger_type", "event")
|
||||
if trigger_type not in TRIGGER_TYPES:
|
||||
raise HTTPException(status_code=400, detail="invalid trigger_type")
|
||||
if trigger_type == "event" and not body.get("event"):
|
||||
raise HTTPException(status_code=400, detail="event required for event trigger")
|
||||
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
|
||||
for key in ("condition_json", "actions_json"):
|
||||
val = body.get(key, "[]")
|
||||
try:
|
||||
if isinstance(val, str):
|
||||
json.loads(val)
|
||||
else:
|
||||
json.dumps(val)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON")
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async def list_automations(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
return {"automations": items}
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
(workspace, name, trigger_type, event, cron_expression, collection_id,
|
||||
condition_json, actions_json, enabled, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
body.get("workspace", "") or "",
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
by,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
return {"id": new_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async def get_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Automation not found")
|
||||
conn.execute(
|
||||
"""UPDATE automations SET
|
||||
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
|
||||
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(
|
||||
(body.get("name") or "").strip(),
|
||||
body.get("trigger_type", "event"),
|
||||
body.get("event", "page.created"),
|
||||
body.get("cron_expression", "") or "",
|
||||
body.get("collection_id") or None,
|
||||
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
|
||||
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
|
||||
int(body.get("enabled", True)),
|
||||
auto_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "status": "deleted"}
|
||||
|
||||
|
||||
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
|
||||
page_id = body.get("page_id") if isinstance(body, dict) else None
|
||||
collection_id = body.get("collection_id") if isinstance(body, dict) else None
|
||||
context = {"collection_id": collection_id, "page_id": page_id}
|
||||
if page_id:
|
||||
context.update(get_page_context(int(page_id), collection_id or 0))
|
||||
result = await run_automation(automation_id, trigger_source, context)
|
||||
result["automation_id"] = automation_id
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/run")
|
||||
async def run_automation_endpoint(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "manual", body)
|
||||
|
||||
|
||||
@router.post("/api/automations/{auto_id}/run")
|
||||
async def run_automation_button(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
return await _execute(auto_id, "button", body)
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?""",
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
+11
-1
@@ -10,8 +10,9 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -836,6 +837,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -866,6 +869,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown"})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -886,6 +891,8 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -932,6 +939,8 @@ async def move_page(request: Request, page_id: int):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -945,6 +954,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.property_types import validate_property_rule
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
@@ -162,6 +163,7 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -200,6 +202,7 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@@ -216,6 +219,8 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""})
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -276,6 +281,13 @@ async def update_page_api(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@@ -292,6 +304,11 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -754,8 +771,16 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
|
||||
await fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
@@ -1897,4 +1922,11 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -289,6 +290,12 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""FlowDeck — Automations engine (v5.1.0).
|
||||
|
||||
Implements the "if-this-then-that" rule engine: automations match an event (or a
|
||||
cron schedule, or a clickable button), optionally guard on a condition, then run
|
||||
a list of actions.
|
||||
|
||||
Condition clauses (``condition_json``), all combined with AND:
|
||||
{"property": "Status", "op": "eq", "value": "Done"}
|
||||
{"property": "Priority", "op": "not_contains", "value": "Low"}
|
||||
{"property": "Assignee", "op": "is_empty"}
|
||||
{"property": "Estimate", "op": "changed"} (only event triggers)
|
||||
Flags:
|
||||
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
|
||||
|
||||
Actions (``actions_json``), executed sequentially:
|
||||
{"type": "webhook", "url": "...", "secret": "..."}
|
||||
{"type": "set_property", "property": "Status", "value": "Done"}
|
||||
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
|
||||
{"type": "notify", "message": "Automation fired"}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
|
||||
# Events that fire on collection pages (payload carries a `properties` dict).
|
||||
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
|
||||
|
||||
|
||||
def _prop_value(props: dict, key) -> tuple[bool, object]:
|
||||
"""Resolve a property value by id or name. Returns ``(found, value)``.
|
||||
|
||||
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
|
||||
"""
|
||||
if props is None:
|
||||
return False, None
|
||||
if key is None:
|
||||
return True, None
|
||||
skey = str(key)
|
||||
if skey in props:
|
||||
return True, props[skey]
|
||||
if isinstance(key, int) and str(key) in props:
|
||||
return True, props[str(key)]
|
||||
return False, None
|
||||
|
||||
|
||||
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
|
||||
"""Evaluate a single condition clause against page property values."""
|
||||
op = clause.get("op", "eq")
|
||||
if op not in COND_OPS:
|
||||
return False
|
||||
if op == "changed":
|
||||
key = clause.get("property")
|
||||
if before_props is None:
|
||||
return False
|
||||
found_before, before_val = _prop_value(before_props, key)
|
||||
found_after, after_val = _prop_value(props, key)
|
||||
return found_before and found_after and before_val != after_val
|
||||
|
||||
found, val = _prop_value(props, clause.get("property"))
|
||||
|
||||
if op == "is_empty":
|
||||
if not found:
|
||||
return True
|
||||
return val is None or str(val).strip() == ""
|
||||
if op == "is_not_empty":
|
||||
return found and val is not None and str(val).strip() != ""
|
||||
|
||||
if not found:
|
||||
return False
|
||||
want = clause.get("value")
|
||||
if op == "eq":
|
||||
return _norm(val) == _norm(want)
|
||||
if op == "neq":
|
||||
return _norm(val) != _norm(want)
|
||||
if op == "contains":
|
||||
return _norm(want) in _norm(val) if _norm(val) else False
|
||||
if op == "not_contains":
|
||||
return _norm(want) not in _norm(val) if _norm(val) else True
|
||||
return False
|
||||
|
||||
|
||||
def _norm(v) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, (list, dict)):
|
||||
return json.dumps(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
|
||||
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
|
||||
try:
|
||||
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
clauses = []
|
||||
for clause in clauses or []:
|
||||
if not match_condition_props(props, before_props, clause):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_page_context(page_id: int, collection_id: int) -> dict:
|
||||
"""Load a collection page's property values for condition evaluation."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": "", "properties": {}, "icon": "file"}
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
return {"page_id": page_id, "collection_id": collection_id,
|
||||
"title": row["title"], "icon": row["icon"], "properties": props}
|
||||
|
||||
|
||||
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
|
||||
collection_id: int | None = None, page_id: int | None = None) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO automation_runs
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(automation_id, trigger_source, status, detail, collection_id, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(automation_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
|
||||
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
|
||||
if not isinstance(value, str):
|
||||
return True, value
|
||||
replaced = value
|
||||
for key in props:
|
||||
if f"[[" + str(key) + "]]" in replaced:
|
||||
replaced = replaced.replace(f"[[" + str(key) + "]]", str(props[key]))
|
||||
if "{{title}}" in replaced:
|
||||
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
|
||||
if "{{id}}" in replaced:
|
||||
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
|
||||
return True, replaced
|
||||
|
||||
|
||||
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
"""Execute a single action. Returns a human summary. Raises on failure."""
|
||||
atype = action.get("type")
|
||||
|
||||
if atype == "webhook":
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
return f"webhook → {url} ({resp.status_code})"
|
||||
|
||||
if atype == "set_property":
|
||||
prop = action.get("property")
|
||||
value = action.get("value")
|
||||
page_id = context.get("page_id")
|
||||
if not prop or not page_id:
|
||||
raise ValueError("set_property requires property + page context")
|
||||
_, resolved = _maybe_convert_prediction(value, context)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"page {page_id} not found")
|
||||
try:
|
||||
props = json.loads(row["property_values_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
props = {}
|
||||
props[prop] = resolved
|
||||
from app.routers.collections import _validate_page_properties
|
||||
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return f"set property {prop} = {resolved}"
|
||||
|
||||
if atype == "create_page":
|
||||
coll_id = action.get("collection_id")
|
||||
title = action.get("title", "Automation page")
|
||||
properties = action.get("properties", {}) or {}
|
||||
if not coll_id:
|
||||
raise ValueError("create_page requires a collection_id")
|
||||
_, resolved_title = _maybe_convert_prediction(title, context)
|
||||
resolved_props = {}
|
||||
for k, v in properties.items():
|
||||
_, pv = _maybe_convert_prediction(v, context)
|
||||
resolved_props[k] = pv
|
||||
with get_conn() as conn:
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(coll_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
|
||||
)
|
||||
conn.commit()
|
||||
return f"created page {cur.lastrowid} in collection {coll_id}"
|
||||
|
||||
if atype == "notify":
|
||||
message = action.get("message", "Automation fired")
|
||||
user_id = action.get("user_id")
|
||||
if not user_id:
|
||||
user_id = context.get("created_by") or 1
|
||||
_, resolved = _maybe_convert_prediction(message, context)
|
||||
notifications.create_notification(
|
||||
user_id=user_id,
|
||||
actor_id=context.get("created_by") or 1,
|
||||
ntype="page",
|
||||
title=context.get("automation_name", "Automation"),
|
||||
message=resolved,
|
||||
resource_type="collection_page" if context.get("page_id") else "page",
|
||||
resource_id=context.get("page_id") or context.get("collection_id") or 0,
|
||||
url=context.get("url", ""),
|
||||
)
|
||||
return f"notified user {user_id}"
|
||||
|
||||
raise ValueError(f"unknown action type: {atype!r}")
|
||||
|
||||
|
||||
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
|
||||
"""Load, condition-check and execute an automation. Records a run row."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
|
||||
if not row:
|
||||
return {"status": "skipped", "detail": "automation not found"}
|
||||
auto = dict(row)
|
||||
|
||||
if not auto["enabled"]:
|
||||
return {"status": "skipped", "detail": "automation disabled"}
|
||||
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
if not evaluate_conditions(auto["condition_json"], props, before):
|
||||
_save_run(automation_id, trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
|
||||
try:
|
||||
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
actions = []
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
|
||||
results = []
|
||||
try:
|
||||
for action in actions or []:
|
||||
results.append(await _run_action(action, ctx, trigger_source))
|
||||
detail = "; ".join(results)
|
||||
_save_run(automation_id, trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001 — record every failure in history
|
||||
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||
_save_run(automation_id, trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as fire_webhooks
|
||||
await fire_webhooks(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Webhook dispatch failed for %s", event)
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automations
|
||||
WHERE trigger_type='event' AND event=? AND enabled=1""",
|
||||
(event,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
|
||||
continue
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
|
||||
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
|
||||
|
||||
_SUPPORTED_CRON = {
|
||||
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
|
||||
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
|
||||
}
|
||||
|
||||
|
||||
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
|
||||
"""True when a ``*/N`-style or fixed-minute cron expression is due.
|
||||
|
||||
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
|
||||
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
|
||||
"""
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
if expr in ("@hourly", "hourly"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
|
||||
|
||||
if expr in ("@daily", "daily"):
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
|
||||
|
||||
# "*/N * * * *" → every N minutes
|
||||
if fields and fields[0].startswith("*/"):
|
||||
val = fields[0][2:]
|
||||
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
|
||||
return False
|
||||
n = int(val)
|
||||
if last_run_at is None:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
|
||||
except Exception:
|
||||
return True
|
||||
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
|
||||
|
||||
# "H * * * *" → at a fixed minute of each hour
|
||||
if len(fields) == 5 and fields[0].isdigit():
|
||||
return int(fields[0]) == minute
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
try:
|
||||
if cron_due(auto["cron_expression"], auto["last_run_at"]):
|
||||
context = {
|
||||
"collection_id": auto["collection_id"] or 0,
|
||||
"page_id": None,
|
||||
"properties": None,
|
||||
}
|
||||
await run_automation(auto["id"], "cron", context)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Cron automation %s errored", auto["id"])
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
@@ -9,7 +9,7 @@
|
||||
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
||||
{id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
{id:'table',name:'Table',icon:'⊞'},
|
||||
{id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='table_of_contents')return renderTOC();
|
||||
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
@@ -414,6 +415,18 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAutomationButton(b,idx){
|
||||
var label=(b.content&&b.content.trim())?b.content:((b.automation_name||'Run automation'));
|
||||
var bound=b.automation_id>0;
|
||||
var badge=bound?'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">⚡ '+esc(b.automation_name||'')+'</span>':'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">Choose…</span>';
|
||||
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<div class="block-content"><button type="button" class="fd-automation-btn" onclick="E.runAutomationButton('${b.id}')">⚡ ${esc(label)}${badge}</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderColumnsBlock(b){
|
||||
var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}];
|
||||
var cells=cols.map(function(c){
|
||||
@@ -742,14 +755,14 @@
|
||||
}
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];return b;},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue;
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
|
||||
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
||||
@@ -772,7 +785,7 @@
|
||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
@@ -1164,7 +1177,7 @@ applyAIBlocks(text){
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||
else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);}
|
||||
@@ -1379,12 +1392,56 @@ applyAIBlocks(text){
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
runAutomationButton(bid){
|
||||
const idx=this.getIdx(bid);if(idx<0)return;
|
||||
const b=this.blocks[idx];
|
||||
if(!b.automation_id){this.pickAutomation(bid);return;}
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const self=this;
|
||||
this.showToast('⚡ '+ (b.automation_name||'Automation') + '…');
|
||||
fetch('/api/automations/'+b.automation_id+'/run',{method:'POST',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
body:JSON.stringify({page_id:this.pid})})
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
if(d.status==='fired'){self.showToast('✅ Automation exécutée');}
|
||||
else if(d.status==='skipped'){self.showToast('⏭️ Condition non remplie (skippée)');}
|
||||
else{self.showToast((d.detail||'Erreur')+'','error');}
|
||||
})
|
||||
.catch(()=>{self.showToast('Échec de l\u2019exécution','error');});
|
||||
},
|
||||
pickAutomation(bid){
|
||||
const self=this;
|
||||
fetch('/workspace/automations',{credentials:'same-origin'})
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
const list=d.automations||[];
|
||||
if(!list.length){self.showToast('Aucune automation — créez-en dans Paramètres → Automations','error');return;}
|
||||
const ov=document.createElement('div');ov.id='fdAutomationOverlay';
|
||||
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;';
|
||||
const box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:20px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;';
|
||||
box.innerHTML='<div style="font-weight:600;font-size:14px;margin-bottom:12px;">Choisir une automation</div>'+
|
||||
list.filter(a=>a.enabled).map(function(a){
|
||||
return `<button type="button" data-id="${a.id}" style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:9px 10px;background:var(--bg-hover,transparent);border:1px solid var(--border,#333);color:var(--text-primary);border-radius:8px;margin-bottom:8px;cursor:pointer;"><span>⚡</span><span style="flex:1;font-size:13px;">${esc(a.name||'Automation')}</span><span style="font-size:11px;color:var(--text-tertiary);">${esc(a.trigger_type||'event')}</span></button>`;
|
||||
}).join('')+
|
||||
'<button type="button" data-cancel="1" style="width:100%;text-align:center;padding:8px;border:none;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px;">Annuler</button>';
|
||||
ov.appendChild(box);
|
||||
document.body.appendChild(ov);
|
||||
box.addEventListener('click',function(ev){
|
||||
const btn=ev.target.closest('[data-id]');if(btn){
|
||||
const idx=self.getIdx(bid);if(idx>=0){self.blocks[idx].automation_id=parseInt(btn.getAttribute('data-id'),10);const a=list.find(function(x){return x.id===parseInt(btn.getAttribute('data-id'),10);});self.blocks[idx].automation_name=a?a.name:'';self.dirty=true;self.autoSave();self.render();}
|
||||
ov.remove();
|
||||
}else if(ev.target.closest('[data-cancel]')){ov.remove();}
|
||||
});
|
||||
})
|
||||
.catch(()=>{self.showToast('Erreur de chargement','error');});
|
||||
},
|
||||
md2b(md){const bl=[];const lines=String(md).split('\n');let i=0;for(;i<lines.length;i++){const raw=lines[i];const t=raw.trim();if(!t)continue;
|
||||
if(t.startsWith('```')){const lang=t.substring(3).trim()||'Plain Text';let code='';i++;for(;i<lines.length;i++){if(lines[i].trim()==='```')break;code+=(code?'\n':'')+lines[i];}bl.push(this.mkB('code',code,{language:lang}));continue;}
|
||||
if(_tblIsData(t)&&i+1<lines.length&&_tblIsSep(lines[i+1])){const header=_tblSplitLine(t);const rows=[header];let j=i+2;while(j<lines.length&&_tblIsData(lines[j])){rows.push(_tblSplitLine(lines[j]));j++;}let w=0;rows.forEach(function(r){if(r.length>w)w=r.length;});if(w<1)w=1;const rows2=rows.map(function(r){const a=r.slice();while(a.length<w)a.push('');return a;});bl.push(this.mkB('table','',{rows:rows2}));i=j-1;continue;}
|
||||
|
||||
+217
-1
@@ -115,6 +115,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -388,6 +389,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automations -->
|
||||
<div x-show="activeSection==='automations'">
|
||||
<h2>Automations</h2>
|
||||
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||||
|
||||
<!-- New / Edit form -->
|
||||
<div class="setting-group">
|
||||
<h3 x-text="editAutomationId ? 'Modifier' : 'Nouvelle automation'"></h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Nom</div>
|
||||
<input class="settings-input" style="width:100%;" type="text" x-model="af.name" placeholder="Ex : Notifier le statut Done">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-wrap:wrap;gap:10px;">
|
||||
<div>
|
||||
<div class="setting-label">Déclencheur</div>
|
||||
<select class="settings-input" style="min-width:180px;" x-model="af.trigger_type" @change="af.event='page.created'">
|
||||
<option value="event">Événement (base de données)</option>
|
||||
<option value="cron">Planifié (cron)</option>
|
||||
<option value="button">Bouton cliquable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='event'">
|
||||
<div class="setting-label">Événement</div>
|
||||
<select class="settings-input" style="min-width:200px;" x-model="af.event">
|
||||
<option value="page.created">Page créée</option>
|
||||
<option value="page.updated">Page modifiée</option>
|
||||
<option value="page.deleted">Page supprimée</option>
|
||||
<option value="collection.created">Base créée</option>
|
||||
<option value="collection.updated">Base modifiée</option>
|
||||
<option value="collection.deleted">Base supprimée</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='cron'">
|
||||
<div>
|
||||
<div class="setting-label">Expression cron</div>
|
||||
<input class="settings-input" style="min-width:180px;" type="text" x-model="af.cron_expression" placeholder="*/15 * * * * | @hourly | @daily">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="af.trigger_type==='event'">
|
||||
<div>
|
||||
<div class="setting-label">Base (optionnel)</div>
|
||||
<select class="settings-input" style="min-width:180px;" x-model="af.collection_id">
|
||||
<option value="">Toutes les bases</option>
|
||||
<template x-for="c in globalCollections" :key="c.id">
|
||||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Actions (JSON)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="setting-group">
|
||||
<h3>Mes automatisations <span style="font-size:11px;color:var(--text-tertiary);" x-text="automations.length + ' règle(s)'"></span></h3>
|
||||
<div x-show="!automations.length" class="setting-desc" style="padding:8px 0;">Aucune automation pour l'instant.</div>
|
||||
<template x-for="a in automations" :key="a.id">
|
||||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="setting-label">
|
||||
<span x-text="'⚡ '+a.name"></span>
|
||||
<span x-show="!a.enabled" style="font-size:10px;background:#666;color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">OFF</span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'('+a.trigger_type+' · '+(a.event||a.cron_expression||'')+')'"></span>
|
||||
</div>
|
||||
<div class="setting-desc" style="font-size:11px;">
|
||||
<span x-text="a.run_count + ' exécution(s)'"></span>
|
||||
<span x-show="a.last_run_at" x-text="' · dernière : '+a.last_run_at"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control" style="gap:6px;">
|
||||
<button class="btn-sm" @click="runAutomation(a.id)">▶ Lancer</button>
|
||||
<button class="btn-sm" @click="toggleAutomation(a.id, a.enabled)"><span x-text="a.enabled ? 'Désactiver' : 'Activer'"></span></button>
|
||||
<button class="btn-sm" @click="editAutomation(a)">✎</button>
|
||||
<button class="btn-sm" style="color:var(--danger);" @click="deleteAutomation(a.id)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Runs history -->
|
||||
<div class="setting-group" x-show="autoRuns.length">
|
||||
<h3>Historique d'exécutions</h3>
|
||||
<template x-for="r in autoRuns" :key="r.id">
|
||||
<div class="setting-row" style="border-bottom:1px solid var(--border);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<span style="font-size:12px;" x-text="r.created_at"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:8px;" x-text="'['+r.trigger_source+']'"></span>
|
||||
</div>
|
||||
<span style="font-size:11px;" x-text="r.detail" :style="r.status==='error' ? 'color:var(--danger);' : 'color:var(--text-secondary);'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Users & Roles -->
|
||||
<div x-show="activeSection==='admin-users'">
|
||||
<h2>Users & Roles</h2>
|
||||
@@ -734,6 +845,13 @@ document.addEventListener('alpine:init', function() {
|
||||
// Per-user provider keys (multi-providers)
|
||||
keyForms: {},
|
||||
|
||||
// Automations (v5.1.0)
|
||||
automations: [],
|
||||
autoRuns: [],
|
||||
editAutomationId: null,
|
||||
globalCollections: [],
|
||||
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
@@ -1327,8 +1445,106 @@ document.addEventListener('alpine:init', function() {
|
||||
if (r.ok) { this.githubLinked = false; }
|
||||
} catch(e) {}
|
||||
},
|
||||
async loadAutomations() {
|
||||
try { await this.loadCollectionsForForm(); } catch(e) {}
|
||||
try {
|
||||
var r = await fetch('/workspace/automations', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.automations = d.automations || [];
|
||||
} catch(e) { this.automations = []; }
|
||||
},
|
||||
async loadCollectionsForForm() {
|
||||
try {
|
||||
var r = await fetch('/db/api', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.globalCollections = d.collections || [];
|
||||
} catch(e) { this.globalCollections = []; }
|
||||
},
|
||||
async saveAutomation() {
|
||||
if (!this.af.name.trim()) { window.showToast && window.showToast('Nom requis', 'error'); return; }
|
||||
try { JSON.parse(this.af.condition_json || '[]'); } catch(e) { window.showToast && window.showToast('Conditions JSON invalide', 'error'); return; }
|
||||
try { JSON.parse(this.af.actions_json || '[]'); } catch(e) { window.showToast && window.showToast('Actions JSON invalide', 'error'); return; }
|
||||
var payload = {
|
||||
name: this.af.name,
|
||||
trigger_type: this.af.trigger_type,
|
||||
event: this.af.event,
|
||||
cron_expression: this.af.cron_expression,
|
||||
collection_id: this.af.collection_id ? parseInt(this.af.collection_id, 10) : null,
|
||||
condition_json: this.af.condition_json,
|
||||
actions_json: this.af.actions_json,
|
||||
enabled: true
|
||||
};
|
||||
var url = '/workspace/automations' + (this.editAutomationId ? '/' + this.editAutomationId : '');
|
||||
var method = this.editAutomationId ? 'PUT' : 'POST';
|
||||
try {
|
||||
var r = await fetch(url, { method: method, credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify(payload) });
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Échec', 'error'); return; }
|
||||
window.showToast && window.showToast(this.editAutomationId ? 'Automation mise à jour' : 'Automation créée');
|
||||
this.cancelEditAutomation();
|
||||
await this.loadAutomations();
|
||||
} catch(e) { window.showToast && window.showToast('Erreur réseau', 'error'); }
|
||||
},
|
||||
editAutomation(a) {
|
||||
this.editAutomationId = a.id;
|
||||
this.af = {
|
||||
name: a.name || '',
|
||||
trigger_type: a.trigger_type || 'event',
|
||||
event: a.event || 'page.created',
|
||||
cron_expression: a.cron_expression || '*/15 * * * *',
|
||||
collection_id: a.collection_id ? String(a.collection_id) : '',
|
||||
condition_json: a.condition_json || '[]',
|
||||
actions_json: a.actions_json || '[]'
|
||||
};
|
||||
},
|
||||
cancelEditAutomation() {
|
||||
this.editAutomationId = null;
|
||||
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
|
||||
},
|
||||
async toggleAutomation(id, currentlyEnabled) {
|
||||
try {
|
||||
var row = this.automations.find(function(x){ return x.id === id; });
|
||||
if (!row) return;
|
||||
var payload = {
|
||||
name: row.name,
|
||||
trigger_type: row.trigger_type || 'event',
|
||||
event: row.event || 'page.created',
|
||||
cron_expression: row.cron_expression || '',
|
||||
collection_id: row.collection_id || null,
|
||||
condition_json: row.condition_json || '[]',
|
||||
actions_json: row.actions_json || '[]',
|
||||
enabled: !currentlyEnabled
|
||||
};
|
||||
var r = await fetch('/workspace/automations/' + id, { method:'PUT', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify(payload) });
|
||||
if (r.ok) await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
async runAutomation(id) {
|
||||
try {
|
||||
this.autoRuns = [];
|
||||
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: 'running', detail: '…' });
|
||||
var r = await fetch('/workspace/automations/' + id + '/run', { method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, body: '{}' });
|
||||
var d = await r.json();
|
||||
this.autoRuns = [];
|
||||
this.autoRuns.push({ created_at: new Date().toLocaleString(), trigger_source: 'manual', status: d.status || 'fired', detail: d.detail || '' });
|
||||
window.showToast && window.showToast('Résultat : ' + (d.status || '') + ' — ' + (d.detail || ''));
|
||||
await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
async deleteAutomation(id) {
|
||||
if (!confirm('Supprimer cette automation ?')) return;
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + id, { method:'DELETE', credentials:'same-origin',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()} });
|
||||
if (r.ok) await this.loadAutomations();
|
||||
} catch(e) {}
|
||||
},
|
||||
closeSettings() {
|
||||
// Refresh parent page after settings changes, then navigate back
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.location.reload();
|
||||
window.close();
|
||||
|
||||
Reference in New Issue
Block a user