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 | })(); |