Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/triggers.js

15.9 KiB, 1 run

created by r2519314175:1459, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — triggered actions (DaimondTriggers)
3 ------------------------------------------------------------
4 Notes2, §Diamonds: "Lets allow diamonds to have some
5 automation. … new triggered actions (TAs) can be added with a
6 + icon, and selected for editing from a pulldown."
7
8 A triggered action is: something happens WITHOUT THE USER
9 ASKING, and a Diamond's daimon is sent an instruction about it.
10 Two triggers ship:
11
12 N minutes of user activity a timer that only runs while
13 you are actually working
14 Email arrival in folder X mail landed where you said
15
16 Notes2 named a third, "Daimon Prompted", on every Diamond by
17 default. It was built and then removed: prompting a Diamond is
18 the user asking, so there was nothing there to arm, and the row
19 ended up with a spacer where every other one has a widget. A
20 Diamond nobody has automated therefore has NO actions -- which
21 is what takes the traffic light off its tile, since it has
22 nothing that can spend unbidden.
23
24 ── The three rules that decide everything below ──
25
26 1. A TRIGGER SPENDS, so it is a leaf of the pause tree. The
27 tree already has the shape for it -- pause.js documents
28 `root/diamonds/<id>/triggers/<n>` -- and a Diamond with one
29 trigger held and its daimon running reads amber without
30 anyone setting amber. Nothing here decides whether to fire:
31 it asks the tree, at the moment of firing.
32
33 2. CONTEXT IS SENT ONCE. Notes2: "Context prepends this if the
34 target daimon has not received it before." So the store has
35 to remember what each daimon has been told, and it does --
36 per TA, as a hash of the context text. Change the context
37 and it is sent again, which is what a person means by
38 changing it.
39
40 3. THE FILES ARE THE SETTING. They live at
41 `diamonds/<id>/triggers.json`, in the open, where the
42 System section shows them and a daimon can read and edit
43 them with the file tools it already has. Notes2 asks for
44 jdat; this is JSON, and the reason is that every other
45 browser-side store in this app is JSON, a jdat door from JS
46 would be a new wasm surface built for one file, and a model
47 reading it as text cannot tell the difference. NOT under
48 `.daimond/`, which both trees hide -- "an intuitive system
49 directory hierarchy" means one you can see.
50
51 ── What this module does NOT do ──
52
53 It does not run turns. It decides that a turn is owed, to which
54 Diamond, with what text, and hands that to a caller which owns
55 the daimon. Firing a turn from here would put a second path to
56 the model beside `doSteer`, and there would then be two places
57 that know how a Diamond spends.
58
59 Attaches `window.DaimondTriggers`. Also exported for Node, so
60 the pure decisions can be tested without a browser.
61 ============================================================ */
62(function () {
63 'use strict';
64
65 /// Every trigger kind, with what it needs and what it sends.
66 ///
67 /// `needs` is what a kind cannot fire without. What REACHES the daimon is the
68 /// same for both of them -- the instruction the user wrote when they set the
69 /// TA up -- so there is no longer a `payload` field to say which; the kind
70 /// whose payload was "whatever the user just typed" is gone.
71 ///
72 /// THERE IS NO `prompted` KIND. There was: notes2 said every Diamond carries
73 /// a "Daimon Prompted" TA, defaulting to on. It was built, and it was
74 /// decoration -- it had no light of its own (the daimon's `/self` leaf is
75 /// already that control), no edit, no copy, no delete, and a spacer where
76 /// every other row has a widget. The user's ruling, and it is the simpler
77 /// model: TYPING A PROMPT IS THE ACTIVATION. There is nothing to arm, so
78 /// there is nothing to represent, and a Diamond nobody has automated has no
79 /// triggered actions at all.
80 ///
81 /// Removing the kind is also the migration. `normalise` drops an action whose
82 /// kind it does not know, so a `triggers.json` written by an earlier release
83 /// loses its `prompted` row the first time it is read.
84 var KINDS = {
85 activity: { needs: ['minutes'] },
86 mail: { needs: ['mailbox', 'folder'] },
87 };
88
89 /// A TA as stored. `id` is stable for the life of the action, because it is
90 /// half of the pause-tree leaf id and a renumbering would silently resume
91 /// something the user paused.
92 function blank(kind) {
93 return {
94 id: '',
95 kind: kind || 'activity',
96 on: true,
97 target: '', // '' means the Diamond that owns the TA
98 instruction: '',
99 context: '',
100 // What context this TA has already sent, so it is sent once. A HASH
101 // rather than a flag: changing the context means it has not been sent,
102 // which is what a person means by changing it.
103 contextSent: '',
104 minutes: 30,
105 mailbox: '',
106 folder: 'INBOX',
107 priority: 'normal',
108 };
109 }
110
111 /// The record a Diamond starts with, which is an EMPTY one.
112 ///
113 /// Notes2 asked for a first TA on every Diamond -- "Daimon Prompted", always
114 /// on -- and it was built. The user has since ruled it out, and the reason is
115 /// the rule this whole module is built on: a TA is an arrangement for the
116 /// Diamond to spend WITHOUT being asked. Prompting it is being asked. There
117 /// is nothing there to arm or hold, which is why that row ended up with a
118 /// spacer where every other one has a light.
119 ///
120 /// So a Diamond nobody has automated has no actions, and therefore nothing
121 /// spendable of its own -- which is what takes the traffic light off an
122 /// ordinary Diamond's tile. See `pauseTree` in daimond.js.
123 function defaults() {
124 return { v: 1, actions: [] };
125 }
126
127 /// Normalise whatever came off disk. A file the user has edited by hand is
128 /// the ordinary case here, not the exceptional one, so a missing field is
129 /// filled and an unknown kind is dropped rather than throwing.
130 function normalise(raw) {
131 var out = { v: 1, actions: [] };
132 var list = (raw && Array.isArray(raw.actions)) ? raw.actions : [];
133 var seen = {};
134 list.forEach(function (r, i) {
135 if (!r || typeof r !== 'object') return;
136 if (!KINDS[r.kind]) return;
137 var t = blank(r.kind);
138 Object.keys(t).forEach(function (k) {
139 if (r[k] !== undefined && r[k] !== null) t[k] = r[k];
140 });
141 t.on = r.on !== false;
142 t.minutes = Math.max(1, Math.round(Number(t.minutes) || 30));
143 // An id that is absent or already taken gets one, so two TAs can never
144 // share a pause leaf.
145 if (!t.id || seen[t.id]) t.id = t.kind + '-' + (i + 1) + '-' + Date.now().toString(36);
146 seen[t.id] = 1;
147 out.actions.push(t);
148 });
149 // Nothing is added. There used to be an always-there `prompted` action put
150 // back here; a Diamond with no actions is now the ordinary case and means
151 // exactly what it says -- this Diamond acts when you prompt it, and at no
152 // other time.
153 return out;
154 }
155
156 /// Does this TA have everything it needs to fire?
157 ///
158 /// Asked before firing rather than at edit time: a file edited by hand can be
159 /// half-written, and a mail trigger with no folder would otherwise watch
160 /// every folder of every mailbox.
161 function ready(t) {
162 if (!t || !KINDS[t.kind]) return false;
163 if (t.on === false) return false;
164 var needs = KINDS[t.kind].needs;
165 for (var i = 0; i < needs.length; i++) {
166 if (!t[needs[i]]) return false;
167 }
168 // Every kind sends the instruction the user wrote, so an empty one is a
169 // TA that would send nothing.
170 if (!String(t.instruction || '').trim()) return false;
171 return true;
172 }
173
174 /// A short, stable hash of a context, for "has this daimon been told?".
175 ///
176 /// FNV-1a: not a security question, and a 32-bit answer is plenty for
177 /// "is this the same paragraph I sent last time".
178 function hash(text) {
179 var h = 0x811c9dc5, s = String(text || '');
180 for (var i = 0; i < s.length; i++) {
181 h ^= s.charCodeAt(i);
182 h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
183 }
184 return h.toString(16);
185 }
186
187 /// What the daimon is actually sent, and whether the context went with it.
188 ///
189 /// Returns `{ text, sentContext }`. The caller writes `contextSent` back only
190 /// when the turn was accepted -- composing it here and recording it there is
191 /// what stops a refused dispatch from consuming the one chance the context
192 /// had to be delivered.
193 ///
194 /// # Arguments
195 /// * `t` - The triggered action.
196 function compose(t) {
197 var body = String(t.instruction || '');
198 var ctx = String(t.context || '').trim();
199 var fresh = ctx && hash(ctx) !== t.contextSent;
200 return {
201 text: fresh ? (ctx + '\n\n---\n\n' + body) : body,
202 sentContext: fresh ? hash(ctx) : t.contextSent,
203 };
204 }
205
206 /// The pause-tree leaf for one TA. Kept here so the one shape is written
207 /// once; `pause.js` documents it and `daimond.js` builds the tree from it.
208 function node(diamondId, actionId) {
209 return 'root/diamonds/' + diamondId + '/triggers/' + actionId;
210 }
211
212 /// Is this TA allowed to spend right now?
213 ///
214 /// The tree is the authority and it is asked HERE, at the moment of firing,
215 /// rather than being copied into the record: a pause that arrived from
216 /// another device between the schedule and the fire has to be honoured.
217 function allowed(diamondId, t) {
218 if (!ready(t)) return false;
219 try {
220 if (typeof window !== 'undefined' && window.DaimondPause) {
221 return !window.DaimondPause.isPaused(node(diamondId, t.id));
222 }
223 } catch (e) { /* module not up: fall through to allowed */ }
224 return true;
225 }
226
227 // ── The activity clock ─────────────────────────────────────
228 //
229 // "N minutes of USER ACTIVITY", not N minutes of wall clock. A timer that
230 // ran while the tab sat untouched overnight would greet the user with eight
231 // turns they did not ask for and a bill to match -- which is the failure this
232 // whole section of notes2 is written against.
233 //
234 // Activity is a keystroke, a pointer press or a scroll, and the clock counts
235 // a minute only if something happened in it. Held in memory rather than on
236 // disk: a reload is not activity, and carrying a part-finished interval
237 // across one would make a closed tab count towards the next fire.
238 //
239 // ONE STOPWATCH PER TRIGGERED ACTION, not one for the account. A single
240 // counter was the first shape and it starved the longer TAs: firing anything
241 // zeroed the one clock, so a 5-minute TA on one Diamond reset the count a
242 // 30-minute TA on another was waiting on, every five minutes, for ever. The
243 // longer one could not fire -- not late, impossible -- while its light said
244 // it was armed. The shortest TA anywhere governed every other one.
245 //
246 // The per-TA clocks are MARKS against one monotonic total rather than a
247 // counter each advanced in step. `activeMs` only ever goes up; a TA remembers
248 // the reading it started from, and its age is the difference. A tick then
249 // costs the same whatever anybody has armed, and a TA nobody has asked about
250 // cannot be advanced by accident.
251 //
252 // Keyed by the pause-tree leaf, which is the identity a TA already has here --
253 // the same string its hold is written against -- so there is no second naming
254 // scheme to keep in step with the first.
255 //
256 // A HELD TA has no clock: `due` drops it before asking its age, so nothing
257 // starts counting until it is released. That is what a Diamond seeded paused
258 // wants -- the Optimiser's 30-minute timer counts thirty minutes from the day
259 // its owner lets it go, not from the day it was made. A TA that WAS running
260 // and is then held keeps the mark it already had, so a short hold does not
261 // throw away the work that went past before it; whether that should also be
262 // put back to the moment of release is a question nobody has answered yet.
263
264 var activeMs = 0; // Total activity this page has seen. Monotonic.
265 var tickStart = 0; // The reading at the start of the minute being counted.
266 var marks = {}; // TA leaf -> the reading that TA's own clock started at.
267 var sawInput = false;
268
269 /// Called by the page on any sign of life.
270 function noteActivity() { sawInput = true; }
271
272 /// Advance the clock by `ms` if the user did anything in that window.
273 ///
274 /// Returns the running total, which is NOT what any TA is measured against --
275 /// `activityMinutes` is, because each one counts from its own mark.
276 function tickActivity(ms) {
277 tickStart = activeMs;
278 if (sawInput) activeMs += Math.max(0, ms | 0);
279 sawInput = false;
280 return activeMs;
281 }
282
283 /// How long this one TA has been counting, in minutes.
284 ///
285 /// Asking starts its clock, so a TA first seen now needs its full N minutes
286 /// from now and cannot inherit an hour that accrued before it existed. It
287 /// starts at the BEGINNING of the minute it is first asked about, because the
288 /// tick counts the minute and then asks what is owed: mark it at the reading
289 /// in hand and every timer is a minute late, for its whole first period.
290 function activityMinutes(diamondId, actionId) {
291 var k = node(diamondId, actionId);
292 if (marks[k] === undefined) marks[k] = tickStart;
293 return (activeMs - marks[k]) / 60000;
294 }
295
296 /// Start this TA's clock again, which the caller does when it FIRED, and only
297 /// then.
298 ///
299 /// A dispatch that was refused leaves the mark where it is, so the time it had
300 /// accrued is still there on the next tick rather than thrown away. The caller
301 /// is the only one who knows the difference: `due` is pure, and cannot see
302 /// that a turn was already running or that the Diamond was not on screen.
303 function resetActivity(diamondId, actionId) {
304 marks[node(diamondId, actionId)] = activeMs;
305 }
306
307 // ── What is owed ───────────────────────────────────────────
308
309 /// Which of a Diamond's TAs are due on this occasion.
310 ///
311 /// Pure: it takes the occasion and the records and returns the ones that
312 /// should fire. Nothing here reads a clock, opens a file or sends anything,
313 /// which is what makes the decision testable and what keeps a second path to
314 /// the model out of this module.
315 ///
316 /// # Arguments
317 /// * `diamondId` - Whose TAs these are.
318 /// * `actions` - The Diamond's normalised list.
319 /// * `occasion` - `{ kind, minutesFor, mailbox, folder }`. `kind` is what
320 /// happened; the rest narrows it for the kinds that need narrowing.
321 /// `minutesFor(diamondId, t)` is how long that ONE TA has been counting,
322 /// supplied by the caller because every TA now has its own stopwatch and
323 /// this function may not read one -- a lookup handed in keeps the decision
324 /// pure, where a module variable read from under it would not be. An
325 /// occasion with no lookup is an occasion where nothing has accrued: fail
326 /// closed, so a caller that forgets it spends nothing rather than spending
327 /// everything.
328 function due(diamondId, actions, occasion) {
329 var kind = occasion && occasion.kind;
330 if (!kind || !KINDS[kind]) return [];
331 return (actions || []).filter(function (t) {
332 if (t.kind !== kind) return false;
333 if (!allowed(diamondId, t)) return false;
334 if (kind === 'activity') {
335 var mins = (typeof occasion.minutesFor === 'function')
336 ? Number(occasion.minutesFor(diamondId, t)) || 0
337 : 0;
338 return mins >= (t.minutes || 30);
339 }
340 if (kind === 'mail') {
341 // A folder is watched by name; a mailbox by address. Both must match,
342 // because "Sent" means something different in two accounts.
343 return t.mailbox === occasion.mailbox && t.folder === occasion.folder;
344 }
345 return true;
346 });
347 }
348
349 var api = {
350 KINDS: KINDS,
351 blank: blank,
352 defaults: defaults,
353 normalise: normalise,
354 ready: ready,
355 compose: compose,
356 hash: hash,
357 node: node,
358 allowed: allowed,
359 due: due,
360 // The activity clock.
361 noteActivity: noteActivity,
362 tickActivity: tickActivity,
363 activityMinutes: activityMinutes,
364 resetActivity: resetActivity,
365 };
366
367 if (typeof window !== 'undefined') window.DaimondTriggers = api;
368 if (typeof module !== 'undefined' && module.exports) module.exports = api;
369})();