Automations: when this happens, do that (daily time, task finished, new mail)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f80b718bd4
commit
94d13b82d7
10 files changed
+165
-4
No files matched your search
@@ -33,6 +33,7 @@ jobs:
|
|||||||
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||||
|
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||||
|
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Automations: "when this happens, do that" rules, one set per person. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to the Shopping
|
||||||
|
// list, or notify me. Rules only ever act on the person's own things, a rule runs at most 20 times a day, and what a rule does never starts another rule.
|
||||||
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
|
const users = require('./users');
|
||||||
|
const { readBody } = require('./body');
|
||||||
|
|
||||||
|
const WHEN = ['time', 'task.done', 'mail.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
|
||||||
|
const file = () => path.join(users.dir(), 'automations.json');
|
||||||
|
const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||||
|
const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { mode: 0o600 });
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
|
||||||
|
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||||
|
|
||||||
|
/** A rule from what the browser sent, or an error that says what is missing. */
|
||||||
|
function clean(b, old) {
|
||||||
|
const wt = String(b.when && b.when.type), tt = String(b.then && b.then.type);
|
||||||
|
if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do');
|
||||||
|
const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80);
|
||||||
|
if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)');
|
||||||
|
const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task');
|
||||||
|
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
|
||||||
|
}
|
||||||
|
const fill = (text, ctx) => text.replace(/\{(title|from|subject)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
|
||||||
|
/** Do a rule's action for the person it belongs to (run inside their context). */
|
||||||
|
async function act(rule, ctx) {
|
||||||
|
const me = users.current(), text = fill(rule.then.text, ctx);
|
||||||
|
if (rule.then.type === 'task' || rule.then.type === 'shopping') { require('./tasks').addLocalTask(rule.then.type === 'shopping' ? 'Shopping' : '', text); return; }
|
||||||
|
require('./inbox').add(me.id, { kind: 'info', title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id });
|
||||||
|
try { await require('./push').notify(me.id, { title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); } catch { /* the Notifications list has it */ }
|
||||||
|
}
|
||||||
|
async function run(rule, ctx) {
|
||||||
|
const l = load(), r = l.find(x => x.id === rule.id); if (!r) return false;
|
||||||
|
if (r.day !== today()) { r.day = today(); r.today = 0; } if (r.today >= DAILY_CAP) return false;
|
||||||
|
r.today++; r.runs = (r.runs || 0) + 1; r.last = Date.now(); save(l);
|
||||||
|
try { await act(r, ctx); return true; } catch (e) { console.warn('[automations]', r.name, e.message); return false; }
|
||||||
|
}
|
||||||
|
/** Something happened for a person: run their matching rules. type: 'task.done' ({ title }) or 'mail.new' ({ from, subject }). */
|
||||||
|
async function fire(uid, type, ctx) {
|
||||||
|
const u = users.get(uid); if (!u || u.disabled) return;
|
||||||
|
await users.runAs(u, async () => {
|
||||||
|
for (const r of load()) {
|
||||||
|
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : ctx.title || '').toLowerCase();
|
||||||
|
if (m && !hay.includes(m)) continue; await run(r, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */
|
||||||
|
async function tickUser(u, lt) {
|
||||||
|
await users.runAs(u, async () => {
|
||||||
|
for (const r of load()) if (r.on && r.when.type === 'time' && r.when.at <= lt.hm && r.doneDay !== lt.day) {
|
||||||
|
const l = load(), x = l.find(y => y.id === r.id); if (!x) continue; x.doneDay = lt.day; save(l); // (marked first, so a slow action is never run twice)
|
||||||
|
if (lt.hm >= r.when.at && minutes(lt.hm) - minutes(r.when.at) <= 180) await run(x, { title: x.name }); // (not for a time that passed hours ago, such as a rule made in the evening)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3);
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/auto'.length) || '/';
|
||||||
|
try {
|
||||||
|
if (!me) throw fail(401, 'Sign in');
|
||||||
|
if (p === '/' && req.method === 'GET') return json(res, 200, { rules: load().map(({ day, doneDay, ...r }) => r) });
|
||||||
|
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||||
|
const b = JSON.parse(await readBody(req, 2e4) || '{}'), l = load();
|
||||||
|
if (p === '/') { const i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (b.id && i < 0) throw fail(404, 'That rule is gone'); if (i < 0 && l.length >= MAX_RULES) throw fail(400, 'That is the most rules (' + MAX_RULES + ')'); const r = clean(b, i >= 0 ? l[i] : null); if (i >= 0) l[i] = r; else l.push(r); save(l); return json(res, 200, { rules: l }); }
|
||||||
|
const i = l.findIndex(x => x.id === b.id); if (i < 0) throw fail(404, 'That rule is gone');
|
||||||
|
if (p === '/toggle') { l[i].on = !!b.on; save(l); return json(res, 200, { rules: l }); }
|
||||||
|
if (p === '/delete') { l.splice(i, 1); save(l); return json(res, 200, { rules: l }); }
|
||||||
|
if (p === '/test') { const ok = await run(l[i], { title: 'a test', from: 'Test', subject: 'a test' }); return json(res, 200, { ok }); }
|
||||||
|
throw fail(404, 'Not found');
|
||||||
|
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||||
|
}
|
||||||
|
module.exports = { handle, fire, tickUser };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to
|
||||||
|
// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened.
|
||||||
|
const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||||
|
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
|
||||||
|
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
|
||||||
|
function autoSettings(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const draw = rules => {
|
||||||
|
el.innerHTML = `<h2>Automations</h2><p class="sub">Little rules that run for you. Each can run up to 20 times a day.</p>
|
||||||
|
<div class="list">${rules.map(r => `<div class="row" style="align-items:flex-start"><label class="grow" style="cursor:pointer;white-space:normal"><b>${esc(r.name)}</b><small style="white-space:normal">${esc(auSummary(r))}${r.runs ? ' · ran ' + r.runs + ' time' + (r.runs === 1 ? '' : 's') : ''}</small></label>
|
||||||
|
<input type="checkbox" data-on="${esc(r.id)}" aria-label="Turn ${esc(r.name)} on or off"${r.on ? ' checked' : ''}><button class="ibtn" data-e="${esc(r.id)}" aria-label="Edit ${esc(r.name)}" title="Edit">${icon('edit')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>No automations yet.</small></span></div>'}</div>
|
||||||
|
<div class="bar"><button class="btn ghost" id="auadd">${icon('plus')}Add an automation</button></div>`;
|
||||||
|
el.querySelector('#auadd').onclick = () => edit(null);
|
||||||
|
el.querySelectorAll('[data-on]').forEach(c => c.onchange = async () => { try { draw((await auApi('/toggle', auOpt({ id: c.dataset.on, on: c.checked }))).rules); } catch (e) { toast(e.message); c.checked = !c.checked; } });
|
||||||
|
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => edit(rules.find(r => r.id === b.dataset.e)));
|
||||||
|
};
|
||||||
|
const edit = r => {
|
||||||
|
const w = r ? r.when : { type: 'time', at: '08:00', match: '' }, t = r ? r.then : { type: 'task', text: '' };
|
||||||
|
const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `<form id="auf"><label for="aun">Name (optional)</label><input type="text" id="aun" value="${esc(r ? r.name : '')}" autocomplete="off">
|
||||||
|
<label for="auw">When</label><select id="auw">${AU_WHEN.map(([v, l]) => `<option value="${v}"${v === w.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||||
|
<div id="auwd"></div><label for="aut">Then</label><select id="aut">${AU_THEN.map(([v, l]) => `<option value="${v}"${v === t.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||||
|
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task or message) and {from}.</p><div class="err" id="aue"></div>
|
||||||
|
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button>${r ? '<button type="button" class="btn ghost" id="autest">Test</button><button type="button" class="btn danger" id="aude">Delete</button>' : ''}</div></form>`);
|
||||||
|
const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; };
|
||||||
|
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' ? ' required' : ''}>`; label(); };
|
||||||
|
detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label;
|
||||||
|
const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; };
|
||||||
|
sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } };
|
||||||
|
if (r) {
|
||||||
|
sh.querySelector('#autest').onclick = async () => { try { const o = await auApi('/test', auOpt({ id: r.id })); toast(o.ok ? 'Ran it once' : 'Did not run (the daily limit?)'); } catch (e) { toast(e.message); } };
|
||||||
|
sh.querySelector('#aude').onclick = () => confirmSheet('Delete this automation?', r.name, 'Delete', async () => { try { draw((await auApi('/delete', auOpt({ id: r.id }))).rules); } catch (e) { toast(e.message); } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auApi('/').then(r => draw(r.rules)).catch(() => {});
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<script defer src="/settings-family.js"></script>
|
<script defer src="/settings-family.js"></script>
|
||||||
<script defer src="/simple-mode.js"></script>
|
<script defer src="/simple-mode.js"></script>
|
||||||
<script defer src="/themes.js"></script>
|
<script defer src="/themes.js"></script>
|
||||||
|
<script defer src="/automations-view.js"></script>
|
||||||
<script defer src="/boot.js"></script>
|
<script defer src="/boot.js"></script>
|
||||||
<script defer src="/shell.js"></script>
|
<script defer src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+2
-1
@@ -86,7 +86,7 @@ function viewSettings(args) {
|
|||||||
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
|
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><div id="autoset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
|
||||||
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||||
@@ -202,6 +202,7 @@ function viewSettings(args) {
|
|||||||
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
||||||
if (typeof famSettings === 'function') famSettings($('#famset'));
|
if (typeof famSettings === 'function') famSettings($('#famset'));
|
||||||
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
|
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
|
||||||
|
if (typeof autoSettings === 'function') autoSettings($('#autoset'));
|
||||||
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
|
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
|
||||||
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
|
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
|
||||||
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
|
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v113';
|
const CACHE = 'hub-v115';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ async function tick(now = new Date(), force = false) {
|
|||||||
if (u.disabled) continue;
|
if (u.disabled) continue;
|
||||||
const dir = users.dirOf(u.id), st = load(dir);
|
const dir = users.dirOf(u.id), st = load(dir);
|
||||||
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
|
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
|
||||||
|
await require('./automations').tickUser(u, localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now)).catch(e => console.warn('[push] automations failed:', e.message)); // (daily rules)
|
||||||
if (!st.subs.length) continue;
|
if (!st.subs.length) continue;
|
||||||
await users.runAs(u, async () => {
|
await users.runAs(u, async () => {
|
||||||
const lt = localNow(st.tz, now); let dirty = false;
|
const lt = localNow(st.tz, now); let dirty = false;
|
||||||
@@ -118,6 +119,7 @@ async function tick(now = new Date(), force = false) {
|
|||||||
const fresh = now2.filter(m => !seen.includes(m.id));
|
const fresh = now2.filter(m => !seen.includes(m.id));
|
||||||
if (fresh.length) {
|
if (fresh.length) {
|
||||||
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
||||||
|
for (const m of fresh.slice(0, 10)) require('./automations').fire(u.id, 'mail.new', { from: m.from || '', subject: m.subject || '' }).catch(() => {});
|
||||||
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
|
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const contacts = require('./contacts'); // each person's address book (mail su
|
|||||||
const activity = require('./activity'), alertParents = activity.alertParents;
|
const activity = require('./activity'), alertParents = activity.alertParents;
|
||||||
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
||||||
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
||||||
|
const automations = require('./automations'); // when this happens, do that (per person)
|
||||||
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
|
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
|
||||||
const chores = require('./chores'); // chores for the family, with points
|
const chores = require('./chores'); // chores for the family, with points
|
||||||
const notes = require('./notes'); // shared and private notes and checklists
|
const notes = require('./notes'); // shared and private notes and checklists
|
||||||
@@ -709,6 +710,7 @@ async function handleRequest(req, res) {
|
|||||||
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
||||||
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
||||||
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
||||||
|
if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res);
|
||||||
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
|
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
|
||||||
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
||||||
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
|
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ async function handle(req, res) {
|
|||||||
const b = JSON.parse(await readBody(req));
|
const b = JSON.parse(await readBody(req));
|
||||||
if (isLocal(b.list)) {
|
if (isLocal(b.list)) {
|
||||||
const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
|
const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
|
||||||
const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled });
|
const was = !!t.done, rolled = finishLocal(t, !!b.done); save(d); if (b.done && !was) require('./automations').fire(users.current().id, 'task.done', { title: t.title }).catch(() => {});
|
||||||
|
return json(res, 200, { ok: true, rolled });
|
||||||
}
|
}
|
||||||
const cur = await caldav.getResource(b.list, b.href);
|
const cur = await caldav.getResource(b.list, b.href);
|
||||||
const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task');
|
const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task');
|
||||||
@@ -190,4 +191,9 @@ async function allTasks() {
|
|||||||
|
|
||||||
/** Open tasks on the shared Family list, soonest first, for the family board. */
|
/** Open tasks on the shared Family list, soonest first, for the family board. */
|
||||||
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 40).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); };
|
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 40).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); };
|
||||||
module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
|
/** Add a task to one of the current person's own lists by name (the first list when none is given or found). Used by automations. */
|
||||||
|
function addLocalTask(listName, title) {
|
||||||
|
const d = load(), n = String(listName || '').toLowerCase(), l = (n && d.lists.find(x => x.id !== FAMILY && x.name.toLowerCase() === n)) || d.lists[0];
|
||||||
|
d.tasks.push({ id: crypto.randomUUID(), list: l.id, title: String(title).slice(0, 200), notes: 'From an automation', due: '', done: false, completedAt: null, repeat: 'none', created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); save(d);
|
||||||
|
}
|
||||||
|
module.exports = { addLocalTask, familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Automations: rules are checked when saved, run when their trigger happens (and only then), keep to a daily cap, and belong to one person.
|
||||||
|
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-auto-'));
|
||||||
|
const users = require('../users'), auto = require('../automations');
|
||||||
|
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['tasks'], handle: 'ZP' });
|
||||||
|
const tasksOf = u => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'tasks.json'), 'utf8')).tasks; } catch { return []; } };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => auto.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/auto', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = who => fetch(base + '/', { headers: who ? { 'x-user': who } : {} });
|
||||||
|
try {
|
||||||
|
// validation
|
||||||
|
assert.equal((await post('/', { when: { type: 'nope' }, then: { type: 'task', text: 'x' } })).status, 400); assert.equal((await post('/', { when: { type: 'time', at: '25:00' }, then: { type: 'task', text: 'x' } })).status, 400, 'a real time of day');
|
||||||
|
assert.equal((await post('/', { when: { type: 'mail.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'mail rules need something to match, so it is not every email');
|
||||||
|
assert.equal((await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: ' ' } })).status, 400, 'and something to do');
|
||||||
|
// a daily rule runs once a day, at its time
|
||||||
|
let r = await j(await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: 'Water the plants' } })); assert.equal(r.rules.length, 1); const daily = r.rules[0];
|
||||||
|
await auto.tickUser(ann, { day: '2026-11-02', hm: '07:59' }); assert.equal(tasksOf(ann).length, 0, 'not before its time');
|
||||||
|
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:00' }); assert.deepEqual(tasksOf(ann).map(t => t.title), ['Water the plants']);
|
||||||
|
await auto.tickUser(ann, { day: '2026-11-02', hm: '08:01' }); assert.equal(tasksOf(ann).length, 1, 'once a day'); await auto.tickUser(ann, { day: '2026-11-03', hm: '08:00' }); assert.equal(tasksOf(ann).length, 2, 'and again tomorrow');
|
||||||
|
await auto.tickUser(ann, { day: '2026-11-04', hm: '15:00' }); assert.equal(tasksOf(ann).length, 2, 'a time that passed hours ago is not caught up');
|
||||||
|
// a finished task starts matching rules, with the title filled in; the shopping action goes to the Shopping list
|
||||||
|
await post('/', { when: { type: 'task.done', match: 'milk' }, then: { type: 'task', text: 'Buy more after {title}' } }); await post('/', { when: { type: 'task.done', match: '' }, then: { type: 'notify', text: 'Nice, {title} is done' }, name: 'Cheer' });
|
||||||
|
await auto.fire(ann.id, 'task.done', { title: 'Buy bread' }); assert.ok(!tasksOf(ann).some(t => /Buy more/.test(t.title)), 'a rule that does not match does nothing');
|
||||||
|
await auto.fire(ann.id, 'task.done', { title: 'Buy milk' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy more after Buy milk'));
|
||||||
|
const inbox = require('../inbox').load(ann.id).filter(x => x.title === 'Cheer'); assert.ok(inbox.length >= 1 && /Nice, Buy (bread|milk) is done/.test(inbox[0].body), 'notify lands in the Notifications list');
|
||||||
|
// mail
|
||||||
|
await post('/', { when: { type: 'mail.new', match: 'invoice' }, then: { type: 'shopping', text: 'Pay {subject}' } }); await auto.fire(ann.id, 'mail.new', { from: 'Bob', subject: 'Lunch?' }); assert.ok(!tasksOf(ann).some(t => /^Pay /.test(t.title)));
|
||||||
|
await auto.fire(ann.id, 'mail.new', { from: 'Acme', subject: 'Your invoice 42' }); assert.ok(tasksOf(ann).some(t => t.title === 'Pay Your invoice 42'));
|
||||||
|
// off, private, capped
|
||||||
|
r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else');
|
||||||
|
const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run');
|
||||||
|
await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day');
|
||||||
|
assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false);
|
||||||
|
console.log('automations test ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user