feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0)
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Skipped

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:
2026-09-07 23:12:51 -04:00
parent 32c81f156d
commit f8df0e13b5
13 changed files with 1345 additions and 15 deletions
+64 -7
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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();