@ham2k/extension-sdk 0.5.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -0
- package/dist/base64.js +38 -0
- package/dist/copyOnWrite.js +71 -0
- package/dist/hookCategories.js +25 -0
- package/dist/index.d.ts +92 -6
- package/dist/index.js +110 -0
- package/dist/scoring.js +53 -14
- package/dist/templateContext.js +43 -2
- package/dist/timerQueue.js +187 -0
- package/dist/timers.js +42 -0
- package/docs/distribution.md +27 -6
- package/docs/hooks.md +135 -32
- package/docs/templates.md +69 -2
- package/package.json +5 -1
package/dist/templateContext.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TemplateError, renderTemplate } from "./templates.js";
|
|
1
2
|
function utcDate(millis) {
|
|
2
3
|
return new Date(millis).toISOString().slice(0, 10);
|
|
3
4
|
}
|
|
@@ -59,10 +60,19 @@ function opValues(operation, extra = {}) {
|
|
|
59
60
|
function qsoValues(qso) {
|
|
60
61
|
const their = qso.their ?? {};
|
|
61
62
|
const our = qso.our ?? {};
|
|
63
|
+
const callField = qso.callField ?? {};
|
|
64
|
+
const guess = their.guess ?? {};
|
|
62
65
|
const startMillis = Number(qso.startAtMillis ?? 0);
|
|
63
66
|
const serialSent = refField(qso.refs, "ourSerial");
|
|
64
67
|
return {
|
|
65
|
-
|
|
68
|
+
// `||`, not `??`: a blank reading still means "no call", and the field
|
|
69
|
+
// as typed is a better answer than keying nothing.
|
|
70
|
+
call: callField.call || their.call || "",
|
|
71
|
+
// The panel's split of `call` — a logged contact's call is a full one.
|
|
72
|
+
fullCall: callField.fullCall ?? their.call ?? "",
|
|
73
|
+
partial: callField.partial ?? "",
|
|
74
|
+
nextCall: callField.nextCall ?? "",
|
|
75
|
+
nextPartial: callField.nextPartial ?? "",
|
|
66
76
|
their,
|
|
67
77
|
our,
|
|
68
78
|
band: qso.band ?? "",
|
|
@@ -80,12 +90,26 @@ function qsoValues(qso) {
|
|
|
80
90
|
// that is where these get typed.
|
|
81
91
|
rst: our.sent ?? "",
|
|
82
92
|
serial: serialSent,
|
|
93
|
+
// What was typed, else what the lookup found — the two places a CW
|
|
94
|
+
// message's "GM BOB" or "NY" would otherwise have to be chained by hand.
|
|
95
|
+
// `name` is the first word only: a lookup's "Robert J Smith" keyed in
|
|
96
|
+
// full is not how anyone greets a station.
|
|
97
|
+
state: firstText(their.state, guess.state),
|
|
98
|
+
name: firstText(their.name, guess.name).split(/\s+/)[0],
|
|
83
99
|
notes: qso.notes ?? "",
|
|
84
100
|
refs: qso.refs ?? [],
|
|
85
101
|
...startMillis > 0 ? dateValues(startMillis) : { date: "", dateCompact: "", time: "", at: "" },
|
|
86
102
|
startAtMillis: startMillis
|
|
87
103
|
};
|
|
88
104
|
}
|
|
105
|
+
function firstText(...values) {
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
if (typeof value !== "string") continue;
|
|
108
|
+
const text = value.trim();
|
|
109
|
+
if (text !== "") return text;
|
|
110
|
+
}
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
89
113
|
function refField(refs, field) {
|
|
90
114
|
if (!Array.isArray(refs)) return "";
|
|
91
115
|
for (const ref of refs) {
|
|
@@ -115,7 +139,7 @@ function logValues(values) {
|
|
|
115
139
|
}
|
|
116
140
|
function templateContext(args) {
|
|
117
141
|
const nowMillis = args.nowMillis ?? Date.now();
|
|
118
|
-
|
|
142
|
+
const context = {
|
|
119
143
|
app: { name: args.appName ?? "" },
|
|
120
144
|
now: utcIso(nowMillis),
|
|
121
145
|
...args.operation ? { op: opValues(args.operation, { qsoCount: args.qsoCount, atMillis: args.atMillis }) } : {},
|
|
@@ -123,6 +147,23 @@ function templateContext(args) {
|
|
|
123
147
|
...args.log ? { log: logValues(args.log) } : {},
|
|
124
148
|
...args.config ? { config: args.config } : {}
|
|
125
149
|
};
|
|
150
|
+
if (args.keyer) context.keyer = keyerValues(context, args.keyer.messages ?? [], args.keyer.current ? [args.keyer.current] : []);
|
|
151
|
+
return context;
|
|
152
|
+
}
|
|
153
|
+
function keyerValues(base, messages, chain) {
|
|
154
|
+
const keyer = {};
|
|
155
|
+
messages.forEach((template, i) => {
|
|
156
|
+
const n = i + 1;
|
|
157
|
+
Object.defineProperty(keyer, `msg${n}`, {
|
|
158
|
+
enumerable: true,
|
|
159
|
+
get() {
|
|
160
|
+
const path = [...chain, n];
|
|
161
|
+
if (chain.includes(n)) throw new TemplateError(`a message includes itself: ${path.map((k) => `F${k}`).join(" \u2192 ")}`);
|
|
162
|
+
return renderTemplate(template ?? "", { ...base, keyer: keyerValues(base, messages, path) });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
return keyer;
|
|
126
167
|
}
|
|
127
168
|
export {
|
|
128
169
|
dateValues,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const MAX_TIMERS_PER_EXTENSION = 16;
|
|
2
|
+
const FIRE_COST_MILLIS = 1;
|
|
3
|
+
const TIMER_DUTY_CYCLE = 0.1;
|
|
4
|
+
const TIMER_BURST_MILLIS = 5e3;
|
|
5
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
6
|
+
const RUN_SLICE_MILLIS = 1e3;
|
|
7
|
+
const MAX_DELAY_MILLIS = 2 ** 31 - 1;
|
|
8
|
+
function createTimerQueue(host) {
|
|
9
|
+
const timers = /* @__PURE__ */ new Map();
|
|
10
|
+
const accounts = /* @__PURE__ */ new Set();
|
|
11
|
+
let nextId = 1;
|
|
12
|
+
let armed = null;
|
|
13
|
+
let inCallback = null;
|
|
14
|
+
function rearm() {
|
|
15
|
+
let earliest = null;
|
|
16
|
+
for (const t of timers.values()) {
|
|
17
|
+
if (earliest === null || t.deadline < earliest) earliest = t.deadline;
|
|
18
|
+
}
|
|
19
|
+
if (earliest === armed) return;
|
|
20
|
+
armed = earliest;
|
|
21
|
+
host.wake(earliest === null ? null : Math.max(0, Math.ceil(earliest - host.realNow())));
|
|
22
|
+
}
|
|
23
|
+
function suspend(owner, reason) {
|
|
24
|
+
if (owner.suspended || owner.revoked) return;
|
|
25
|
+
owner.suspended = reason;
|
|
26
|
+
for (const t of [...timers.values()]) {
|
|
27
|
+
if (t.owner === owner) timers.delete(t.id);
|
|
28
|
+
}
|
|
29
|
+
const why = {
|
|
30
|
+
budget: `its callbacks used more than ${TIMER_DUTY_CYCLE * 100}% of the time`,
|
|
31
|
+
failures: `${MAX_CONSECUTIVE_FAILURES} callbacks in a row failed`,
|
|
32
|
+
interrupted: "a callback ran past the engine's time limit"
|
|
33
|
+
}[reason];
|
|
34
|
+
host.log(`ERROR ${owner.key} timers suspended: ${why}`);
|
|
35
|
+
host.suspended(owner.key, reason);
|
|
36
|
+
rearm();
|
|
37
|
+
}
|
|
38
|
+
function cancelAll(owner) {
|
|
39
|
+
for (const t of [...timers.values()]) {
|
|
40
|
+
if (t.owner === owner) timers.delete(t.id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function refuse(owner, why) {
|
|
44
|
+
if (!owner.refusalsLogged.has(why)) {
|
|
45
|
+
owner.refusalsLogged.add(why);
|
|
46
|
+
host.log(`ERROR ${owner.key} set a timer ${why}; it will not fire`);
|
|
47
|
+
}
|
|
48
|
+
return nextId++;
|
|
49
|
+
}
|
|
50
|
+
function charge(owner, cost) {
|
|
51
|
+
const now = host.realNow();
|
|
52
|
+
owner.debt = Math.max(0, owner.debt - (now - owner.chargedAt) * TIMER_DUTY_CYCLE) + cost;
|
|
53
|
+
owner.chargedAt = now;
|
|
54
|
+
if (owner.debt > TIMER_BURST_MILLIS) suspend(owner, "budget");
|
|
55
|
+
}
|
|
56
|
+
function failed(owner, e) {
|
|
57
|
+
host.log(`ERROR ${owner.key} timer callback: ${host.describeError(e)}`);
|
|
58
|
+
owner.failures++;
|
|
59
|
+
if (owner.failures >= MAX_CONSECUTIVE_FAILURES) suspend(owner, "failures");
|
|
60
|
+
}
|
|
61
|
+
function succeeded(owner) {
|
|
62
|
+
owner.failures = 0;
|
|
63
|
+
}
|
|
64
|
+
function fire(t, wakeCost) {
|
|
65
|
+
const startedAt = host.realNow();
|
|
66
|
+
let result;
|
|
67
|
+
let threw = false;
|
|
68
|
+
inCallback = t;
|
|
69
|
+
try {
|
|
70
|
+
result = t.callback(...t.args);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
threw = true;
|
|
73
|
+
failed(t.owner, e);
|
|
74
|
+
}
|
|
75
|
+
inCallback = null;
|
|
76
|
+
charge(t.owner, host.realNow() - startedAt + wakeCost);
|
|
77
|
+
if (threw) return;
|
|
78
|
+
succeeded(t.owner);
|
|
79
|
+
if (result instanceof Promise) {
|
|
80
|
+
result.catch((e) => host.log(`ERROR ${t.owner.key} timer callback: ${host.describeError(e)}`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
owner(key) {
|
|
85
|
+
const owner = { key, debt: 0, chargedAt: host.realNow(), failures: 0, suspended: null, revoked: false, refusalsLogged: /* @__PURE__ */ new Set() };
|
|
86
|
+
accounts.add(owner);
|
|
87
|
+
return owner;
|
|
88
|
+
},
|
|
89
|
+
set(handle, callback, delay, repeat, args) {
|
|
90
|
+
const owner = handle;
|
|
91
|
+
if (typeof callback !== "function") throw new TypeError("timer callback must be a function");
|
|
92
|
+
if (owner.revoked) return refuse(owner, "after it was unloaded");
|
|
93
|
+
if (owner.suspended) return refuse(owner, "while its timers are suspended");
|
|
94
|
+
let live = 0;
|
|
95
|
+
for (const t of timers.values()) if (t.owner === owner) live++;
|
|
96
|
+
if (live >= MAX_TIMERS_PER_EXTENSION) return refuse(owner, `beyond its ${MAX_TIMERS_PER_EXTENSION} live timers`);
|
|
97
|
+
const n = Number(delay);
|
|
98
|
+
const ms = n > 0 ? Math.min(Math.trunc(n), MAX_DELAY_MILLIS) : 0;
|
|
99
|
+
const id = nextId++;
|
|
100
|
+
timers.set(id, {
|
|
101
|
+
id,
|
|
102
|
+
owner,
|
|
103
|
+
callback,
|
|
104
|
+
args,
|
|
105
|
+
deadline: host.realNow() + ms,
|
|
106
|
+
interval: repeat ? ms : null
|
|
107
|
+
});
|
|
108
|
+
rearm();
|
|
109
|
+
return id;
|
|
110
|
+
},
|
|
111
|
+
/// Ids are guessable, so one owner's clear never reaches another's timer.
|
|
112
|
+
clear(handle, id) {
|
|
113
|
+
const t = timers.get(id);
|
|
114
|
+
if (!t || t.owner !== handle) return;
|
|
115
|
+
timers.delete(t.id);
|
|
116
|
+
rearm();
|
|
117
|
+
},
|
|
118
|
+
/// Fires what was due when it started. A timer set by one of these
|
|
119
|
+
/// callbacks waits for the next wake even at zero delay: running it here
|
|
120
|
+
/// would let a zero-delay chain spin inside a single evaluation.
|
|
121
|
+
run() {
|
|
122
|
+
armed = null;
|
|
123
|
+
if (inCallback) {
|
|
124
|
+
const cutShort = inCallback;
|
|
125
|
+
inCallback = null;
|
|
126
|
+
suspend(cutShort.owner, "interrupted");
|
|
127
|
+
}
|
|
128
|
+
const now = host.realNow();
|
|
129
|
+
const due = [...timers.values()].filter((t) => t.deadline <= now).sort((a, b) => a.deadline - b.deadline || a.id - b.id);
|
|
130
|
+
const woken = /* @__PURE__ */ new Set();
|
|
131
|
+
for (const t of due) {
|
|
132
|
+
if (host.realNow() - now >= RUN_SLICE_MILLIS) break;
|
|
133
|
+
if (timers.get(t.id) !== t) continue;
|
|
134
|
+
if (t.interval === null) timers.delete(t.id);
|
|
135
|
+
const wakeCost = woken.has(t.owner) ? 0 : FIRE_COST_MILLIS;
|
|
136
|
+
woken.add(t.owner);
|
|
137
|
+
fire(t, wakeCost);
|
|
138
|
+
if (t.interval !== null && timers.get(t.id) === t) t.deadline = host.realNow() + t.interval;
|
|
139
|
+
}
|
|
140
|
+
rearm();
|
|
141
|
+
},
|
|
142
|
+
/// Cancels what one activation — one that failed — has set, and nothing
|
|
143
|
+
/// of a newer activation's: a dev reload can start the new one before the
|
|
144
|
+
/// old one's failure arrives. The handle stays good, because the hooks
|
|
145
|
+
/// that activation registered before failing are still called.
|
|
146
|
+
cancel(handle) {
|
|
147
|
+
cancelAll(handle);
|
|
148
|
+
rearm();
|
|
149
|
+
},
|
|
150
|
+
/// Revokes every activation of [keys]: their timers go, their handles
|
|
151
|
+
/// ignore new ones, and a reloaded extension starts clean on a new handle.
|
|
152
|
+
drop(keys) {
|
|
153
|
+
for (const owner of [...accounts]) {
|
|
154
|
+
if (!keys.includes(owner.key)) continue;
|
|
155
|
+
owner.revoked = true;
|
|
156
|
+
accounts.delete(owner);
|
|
157
|
+
cancelAll(owner);
|
|
158
|
+
}
|
|
159
|
+
rearm();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function removeAmbientTimers(global = globalThis) {
|
|
164
|
+
for (const name of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) {
|
|
165
|
+
try {
|
|
166
|
+
delete global[name];
|
|
167
|
+
} catch (e) {
|
|
168
|
+
}
|
|
169
|
+
if (typeof global[name] === "function") {
|
|
170
|
+
try {
|
|
171
|
+
global[name] = void 0;
|
|
172
|
+
} catch (e) {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return ["setTimeout", "setInterval", "clearTimeout", "clearInterval"].every((name) => typeof global[name] !== "function");
|
|
177
|
+
}
|
|
178
|
+
export {
|
|
179
|
+
FIRE_COST_MILLIS,
|
|
180
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
181
|
+
MAX_TIMERS_PER_EXTENSION,
|
|
182
|
+
RUN_SLICE_MILLIS,
|
|
183
|
+
TIMER_BURST_MILLIS,
|
|
184
|
+
TIMER_DUTY_CYCLE,
|
|
185
|
+
createTimerQueue,
|
|
186
|
+
removeAmbientTimers
|
|
187
|
+
};
|
package/dist/timers.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const bound = {};
|
|
2
|
+
let preferredKey = null;
|
|
3
|
+
let activated = false;
|
|
4
|
+
function adoptExtensionTimers(key, currentKey, timers) {
|
|
5
|
+
activated = true;
|
|
6
|
+
preferredKey = currentKey;
|
|
7
|
+
if (timers) bound[key] = timers;
|
|
8
|
+
}
|
|
9
|
+
function boundTimers() {
|
|
10
|
+
return (preferredKey !== null ? bound[preferredKey] : void 0) ?? Object.values(bound)[0];
|
|
11
|
+
}
|
|
12
|
+
const refusalsLogged = /* @__PURE__ */ new Set();
|
|
13
|
+
function set(callback, delay, repeat, args) {
|
|
14
|
+
const timers = boundTimers();
|
|
15
|
+
if (timers) return timers.set(callback, delay, repeat, args);
|
|
16
|
+
const why = activated ? "this version of HaLo has no timers" : "set before the extension activated";
|
|
17
|
+
if (!refusalsLogged.has(why)) {
|
|
18
|
+
refusalsLogged.add(why);
|
|
19
|
+
const who = preferredKey !== null ? `${preferredKey}: ` : "";
|
|
20
|
+
globalThis.__polo?.log(`ERROR ${who}a timer was ignored: ${why}`);
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
function setTimeout(callback, delay, ...args) {
|
|
25
|
+
return set(callback, delay, false, args);
|
|
26
|
+
}
|
|
27
|
+
function setInterval(callback, delay, ...args) {
|
|
28
|
+
return set(callback, delay, true, args);
|
|
29
|
+
}
|
|
30
|
+
function clearTimeout(id) {
|
|
31
|
+
boundTimers()?.clear(id);
|
|
32
|
+
}
|
|
33
|
+
function clearInterval(id) {
|
|
34
|
+
clearTimeout(id);
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
adoptExtensionTimers,
|
|
38
|
+
clearInterval,
|
|
39
|
+
clearTimeout,
|
|
40
|
+
setInterval,
|
|
41
|
+
setTimeout
|
|
42
|
+
};
|
package/docs/distribution.md
CHANGED
|
@@ -37,7 +37,7 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
37
37
|
"version": "1.2.3",
|
|
38
38
|
"description": "Notes about the stations I work",
|
|
39
39
|
"category": "dashboard",
|
|
40
|
-
"api":
|
|
40
|
+
"api": 2,
|
|
41
41
|
"domains": ["notes.example.org"],
|
|
42
42
|
"icon": "note-text"
|
|
43
43
|
}
|
|
@@ -45,7 +45,12 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
45
45
|
|
|
46
46
|
`api` is the extension API the bundle was built against. The host refuses one
|
|
47
47
|
it does not speak, so an older app meeting a newer bundle says so instead of
|
|
48
|
-
failing somewhere deep in a hook
|
|
48
|
+
failing somewhere deep in a hook — and the catalog does not list it for that
|
|
49
|
+
app at all. Declare the lowest version that has what the bundle uses, so it
|
|
50
|
+
reaches every app that can run it: 2 adds `host.webSocket`, and the packer
|
|
51
|
+
refuses a manifest declaring `webSockets` under anything less; 3 adds timers
|
|
52
|
+
and `onShow`/`onHide`, and from 3 the build rewrites a bare `setTimeout` into
|
|
53
|
+
the SDK's (README's "Timers").
|
|
49
54
|
|
|
50
55
|
Three fields are **refused** in a distributed bundle, and it is worth
|
|
51
56
|
understanding why:
|
|
@@ -254,7 +259,7 @@ It reports every problem at once rather than one per run:
|
|
|
254
259
|
h2kext-pack: ./build is not ready to package:
|
|
255
260
|
manifest.json: missing required field 'name'
|
|
256
261
|
manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
|
|
257
|
-
manifest.json: missing 'api' — declare the extension API this was built against (currently
|
|
262
|
+
manifest.json: missing 'api' — declare the extension API this was built against (currently 3)
|
|
258
263
|
```
|
|
259
264
|
|
|
260
265
|
`--force-name` waives the naming convention below. It exists for Ham2K's own
|
|
@@ -318,8 +323,9 @@ needs one degrades the same way it does in a build without that value
|
|
|
318
323
|
configured.
|
|
319
324
|
|
|
320
325
|
The sandbox is otherwise the same one the built-in extensions run in — no
|
|
321
|
-
filesystem, no network beyond the `domains`
|
|
322
|
-
approved,
|
|
326
|
+
filesystem, no network beyond the `domains` and `webSockets` the manifest
|
|
327
|
+
declares and the user approved, and timers only within their budget
|
|
328
|
+
(README's "Timers").
|
|
323
329
|
|
|
324
330
|
## Installing one
|
|
325
331
|
|
|
@@ -342,6 +348,14 @@ that key is compared — a bundle can change completely and still ask for the
|
|
|
342
348
|
same things. That may be worth revisiting once a bundle can be shown to come
|
|
343
349
|
from whoever it claims; it is not worth it while a key is all there is.
|
|
344
350
|
|
|
351
|
+
**Update all is the one exception.** It takes every pending catalog update on
|
|
352
|
+
a single confirmation that names each extension and the version it moves to,
|
|
353
|
+
and skips the per-extension screen for each of them: asking once per entry for
|
|
354
|
+
the one thing the operator asked for once is how a screen gets pressed through
|
|
355
|
+
unread. It is a confirmation of a job the operator started, never a way to
|
|
356
|
+
start one — `install_consent_scope_test` holds it to the one function that
|
|
357
|
+
shows that confirmation first.
|
|
358
|
+
|
|
345
359
|
Removing an extension removes what it stored with it.
|
|
346
360
|
|
|
347
361
|
### Installing from a link
|
|
@@ -477,7 +491,14 @@ takes the plain one above.
|
|
|
477
491
|
At most once every six hours — on launch and on resume — the app asks the
|
|
478
492
|
catalog whether anything installed has a newer release, and says so in the
|
|
479
493
|
status bar and on the row; the notice comes down with the last update taken.
|
|
480
|
-
It never installs unasked
|
|
494
|
+
It never installs unasked: the notice's **Update all** is the panel's own,
|
|
495
|
+
behind the same confirmation, and **More info** opens the panel.
|
|
496
|
+
|
|
497
|
+
An install or update narrates itself as one status-bar progress item, from the
|
|
498
|
+
first downloaded byte to "Installed", and the Extensions panel mirrors that
|
|
499
|
+
item on the row being installed. Installs share one queue
|
|
500
|
+
(`ExtensionInstaller`): downloads run side by side, while the writes go one at
|
|
501
|
+
a time, since each restarts the runtime. A release the catalog has revoked is put on record
|
|
481
502
|
(`extensionRevoked`: version and note) and stops loading on the spot,
|
|
482
503
|
whatever the operator's own switch says — the row shows "Revoked" with the
|
|
483
504
|
note and no switch, and the record dies with an install of another version
|
package/docs/hooks.md
CHANGED
|
@@ -2,11 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Hooks are registered under a **category** with an optional **key** (defaults
|
|
4
4
|
to the extension key) and **priority** (higher runs first; default 0). The
|
|
5
|
-
host invokes them
|
|
5
|
+
host invokes them three ways:
|
|
6
6
|
|
|
7
7
|
- `invokeHook(category, key?, method, args)` — best (highest-priority) hook.
|
|
8
8
|
- `invokeHookAll(category, method, args)` — every hook in the category, one
|
|
9
9
|
bridge round-trip, per-source error isolation.
|
|
10
|
+
- `invokeHookBatch(calls)` — several `invokeHook` calls, one bridge
|
|
11
|
+
round-trip, per-call error isolation. Each call reaches the same hook it
|
|
12
|
+
would reach on its own, so a hook cannot tell a batched call from a single
|
|
13
|
+
one.
|
|
14
|
+
|
|
15
|
+
The categories are a closed list. `registerHook` takes a `HookCategory`
|
|
16
|
+
([`extensions/sdk/src/hookCategories.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/hookCategories.ts))
|
|
17
|
+
or `ref:<type>`, so a misspelled category fails the typecheck rather than
|
|
18
|
+
registering a hook nothing calls, and the build rejects a manifest `hooks`
|
|
19
|
+
entry that is neither. A new category goes into that list — and, when the host
|
|
20
|
+
calls it, into the host's catalog as well
|
|
21
|
+
(`packages/halo_core/lib/src/hook_names.dart`).
|
|
22
|
+
|
|
23
|
+
In both shapes that answer many hooks at once, one hook failing costs its own
|
|
24
|
+
entry and nothing else — whether it throws, has no handler, or answers
|
|
25
|
+
something JSON cannot carry (a circular object, a `BigInt`). One that runs out
|
|
26
|
+
of time does too, when the caller asks for partial results; otherwise, and for
|
|
27
|
+
a batch of several calls that runs out of time before any answer has reached
|
|
28
|
+
the host, the whole call fails.
|
|
10
29
|
|
|
11
30
|
All argument and result types below are defined in
|
|
12
31
|
[`extensions/sdk/src/types.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/types.ts). Every
|
|
@@ -422,6 +441,18 @@ interface RefHandlerHook {
|
|
|
422
441
|
etc.); results update ref chips asynchronously in the UI. Implemented by:
|
|
423
442
|
`pota` (both types).
|
|
424
443
|
|
|
444
|
+
**The core asks about references in batches.** Every reference an ADIF import
|
|
445
|
+
or a deep link brings is decorated in one bridge round-trip, and so is every
|
|
446
|
+
link a list of search results needs, every outline a map draws, and every
|
|
447
|
+
phrase of an operation's title. Each reference is still its own call, to the
|
|
448
|
+
handler `invokeHook` would pick for it (qualified claims included), so a
|
|
449
|
+
handler implements the single-reference methods above and nothing else. The
|
|
450
|
+
time budget belongs to the batch, though: ten seconds for all of its calls
|
|
451
|
+
together. A handler that has not answered by then costs only its own
|
|
452
|
+
reference — it stays as it was, with no name, link or outline — and every
|
|
453
|
+
other reference keeps its answer. A `decorateRef` that goes to the network
|
|
454
|
+
should answer well within that.
|
|
455
|
+
|
|
425
456
|
`linkForRef` says where a reference can be read about on the web — the
|
|
426
457
|
program's own page for it (POTA's park page, SOTA's summit page), or, for a
|
|
427
458
|
contest, the rules the event is run under. The Operation Setup sheet asks for
|
|
@@ -499,11 +530,13 @@ interface ActivityHook {
|
|
|
499
530
|
suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
|
|
500
531
|
processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
|
|
501
532
|
}
|
|
502
|
-
// LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?,
|
|
503
|
-
// optionType?, allowsMultiple?, editable?, input}
|
|
533
|
+
// LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
|
|
534
|
+
// order?, optionType?, allowsMultiple?, editable?, input}
|
|
504
535
|
// `shortLabel` is the same name in fewer characters, for a field the logging
|
|
505
536
|
// row has squeezed below its label's width. The core measures and picks; the
|
|
506
537
|
// extension only says what the short form is.
|
|
538
|
+
// `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
|
|
539
|
+
// the arrow keys still reach it. Every shipped contest sets it on Our Serial.
|
|
507
540
|
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
|
|
508
541
|
// ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
|
|
509
542
|
|
|
@@ -596,8 +629,12 @@ true when THIS extension alone was asked — the operator tapped its "Activity
|
|
|
596
629
|
Types" row or typed its key as a scope — which a bare scope otherwise cannot be
|
|
597
630
|
told from the nearby list, since neither carries a `searchTerm`. An extension
|
|
598
631
|
that keeps itself out of the nearby list (`cwt`, offered only around a session)
|
|
599
|
-
answers a scoped call anyway: it is the one being asked. The core fans
|
|
600
|
-
|
|
632
|
+
answers a scoped call anyway: it is the one being asked. The core fans out to
|
|
633
|
+
every `activity` hook via `invokeHookAll`; a scoped search asks only its own
|
|
634
|
+
hooks, in one batch (`invokeHookBatch`); and a search naming a type without the
|
|
635
|
+
colon asks that type's hooks in one batch AND fans out to every hook — two
|
|
636
|
+
round-trips, kept apart so a fan-out that runs out of time cannot cost the named
|
|
637
|
+
type's answers. The core merges results and ranks them
|
|
601
638
|
in TWO BANDS (`app/lib/tools/activity_suggestions.dart`): everything carrying a
|
|
602
639
|
`distance` first, by `distance / relevance` ascending — a more relevant hit
|
|
603
640
|
counts as nearer — then everything without one, by DESCENDING `relevance`, with
|
|
@@ -713,9 +750,38 @@ file claims its own reference; ask every hook and both programs'
|
|
|
713
750
|
program to guess which is its. The delegate cannot work out who called it, so
|
|
714
751
|
naming yourself is not optional.
|
|
715
752
|
|
|
716
|
-
The full ADIF export names no `mainHandler
|
|
717
|
-
polo's `includeOtherRefs`, which only its full export
|
|
718
|
-
claiming no program: the complete copy the operator
|
|
753
|
+
The full ADIF export names no `mainHandler`, and every program the contact
|
|
754
|
+
belongs to contributes — polo's `includeOtherRefs`, which only its full export
|
|
755
|
+
sets. That is the export claiming no program: the complete copy the operator
|
|
756
|
+
keeps.
|
|
757
|
+
|
|
758
|
+
**The full export asks only the extensions a contact belongs to.** For each
|
|
759
|
+
reference on the contact's operation (the segment-effective one) and on the
|
|
760
|
+
contact itself, the kernel takes every extension holding the strongest claim
|
|
761
|
+
on it — the longest `ref:<type>/<code>` prefix that begins the reference's
|
|
762
|
+
code, or else the bare `ref:<type>` — and asks those extensions' hooks, and no
|
|
763
|
+
other's. A hunted reference on the contact counts as much as an activation on
|
|
764
|
+
the operation, so a chaser's log keeps its `SOTA_REF`s. A longer claim excludes
|
|
765
|
+
a shorter one: an old `{type: 'qp', ref: 'CA'}` reference belongs to the party
|
|
766
|
+
that claims `qp/ca`, not also to the extension claiming all of `qp`. Equal
|
|
767
|
+
claims are all asked, whatever their priority: WCA also registers
|
|
768
|
+
`ref:ecaActivation` to resolve a castle while ECA is off, and ECA's fields
|
|
769
|
+
must not depend on which of the two registered first. An installed extension the
|
|
770
|
+
contact has nothing to do with is never asked, so a hook that forgets to check
|
|
771
|
+
its own references costs nothing here; checking them is still how a hook
|
|
772
|
+
behaves correctly when named by a program's own export.
|
|
773
|
+
|
|
774
|
+
An extension that genuinely has something to say about every contact opts in
|
|
775
|
+
from its **manifest**, `"adifFieldsScope": "always"`, and is asked whatever the
|
|
776
|
+
contact carries. The field lives in the manifest, not on the hook, so the
|
|
777
|
+
packer can check it without running the bundle: a manifest listing
|
|
778
|
+
`adifFields` with neither a `ref:` type nor the opt-in is refused at packing
|
|
779
|
+
and at publishing, and the app's own build refuses it too. The kernel reads the
|
|
780
|
+
opt-in from the extension's definition, so spread the manifest into
|
|
781
|
+
`defineExtension`, and it logs an error at activation for an extension that
|
|
782
|
+
registers `adifFields` but answers for no reference and does not opt in.
|
|
783
|
+
`mainHandler` and `includeFieldsFrom` are unaffected: a named hook is asked
|
|
784
|
+
whatever the contact carries.
|
|
719
785
|
|
|
720
786
|
An export can also name a **list** of other hooks it accepts,
|
|
721
787
|
`includeFieldsFrom` — polo's all-or-nothing flag narrowed to what the program
|
|
@@ -728,13 +794,13 @@ would claim no reference at all.
|
|
|
728
794
|
|
|
729
795
|
**A field name is written once per record, and the first to carry a value
|
|
730
796
|
wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
|
|
731
|
-
the same name — the full export asks
|
|
732
|
-
answer `MY_SIG` — the later one is dropped, and the contact's own fields
|
|
797
|
+
the same name — the full export asks every program on the contact, and a park
|
|
798
|
+
and a summit both answer `MY_SIG` — the later one is dropped, and the contact's own fields
|
|
733
799
|
outrank all of them. Hooks are asked main handler first, then each
|
|
734
800
|
`includeFieldsFrom` key in the order given, so that order decides — whichever
|
|
735
801
|
of the two methods each hook answered. app-polo resolves a collision the same
|
|
736
|
-
way ("keep the first one defined"). The **full export**, which asks
|
|
737
|
-
has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
|
|
802
|
+
way ("keep the first one defined"). The **full export**, which asks every
|
|
803
|
+
program on the contact, has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
|
|
738
804
|
group and hooks answering `fieldsForOneQSO` as another, so which of two programs
|
|
739
805
|
keeps a shared `MY_SIG` there is not something either of them chose.
|
|
740
806
|
|
|
@@ -1161,8 +1227,10 @@ standing beside your credit, on the one row that can show only one.
|
|
|
1161
1227
|
A scorer's internal *rules* stay private to its scoresheet: contests don't
|
|
1162
1228
|
share a rule vocabulary, so only notices, alerts, points and summary tallies
|
|
1163
1229
|
cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
|
|
1164
|
-
return it; copying accumulated state per QSO is quadratic
|
|
1165
|
-
copies
|
|
1230
|
+
return it; copying accumulated state per QSO is quadratic. The batch pass
|
|
1231
|
+
copies the checkpoint it resumes from, once; the live paths hand the scorer a
|
|
1232
|
+
copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
|
|
1233
|
+
writes land in the view and the held sheet is never touched.
|
|
1166
1234
|
|
|
1167
1235
|
```ts
|
|
1168
1236
|
// The wire shape the harness produces, for reference:
|
|
@@ -1171,17 +1239,19 @@ interface ScoringHook {
|
|
|
1171
1239
|
scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
|
|
1172
1240
|
scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
|
|
1173
1241
|
}
|
|
1174
|
-
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
|
|
1242
|
+
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
|
|
1175
1243
|
// ScoreQsosResult: {
|
|
1176
1244
|
// qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
|
|
1177
1245
|
// daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
|
|
1178
1246
|
// operationSummary: Record<tallyKey, ScoreTally>,
|
|
1179
1247
|
// scoresheet, // checkpoint to resume from
|
|
1180
1248
|
// scoresheetDay?, // the day it ends on, handed back as resumeDay
|
|
1249
|
+
// checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
|
|
1181
1250
|
// }
|
|
1182
|
-
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1251
|
+
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1252
|
+
// QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
|
|
1253
|
+
// ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1254
|
+
// ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
|
|
1185
1255
|
```
|
|
1186
1256
|
|
|
1187
1257
|
All three arrive already implemented by `contestScorer()`; a scorer writes the
|
|
@@ -1192,23 +1262,48 @@ both live paths for free.
|
|
|
1192
1262
|
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1193
1263
|
automatic) reaches the scorer stamped with the current time on both live
|
|
1194
1264
|
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1195
|
-
candidate that states a time keeps it. On both live paths a
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1199
|
-
scores the candidate against the result. The host sends a
|
|
1200
|
-
it can prove the log is what the checkpoint folded plus
|
|
1201
|
-
otherwise the harness reads the log through `ctx.getQsos`
|
|
1202
|
-
The batched `scoreQsos` resumes the same way: after the
|
|
1203
|
-
session, an append arrives as
|
|
1204
|
-
their verdicts come back.
|
|
1265
|
+
candidate that states a time keeps it. On both live paths a checkpoint, with
|
|
1266
|
+
the `qsos` logged since it, stands in for the whole-log read: the harness
|
|
1267
|
+
views the checkpoint copy-on-write, replays that tail onto the view
|
|
1268
|
+
(continuing the day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1269
|
+
mid-day) and scores the candidate against the result. The host sends a
|
|
1270
|
+
checkpoint only when it can prove the log is what the checkpoint folded plus
|
|
1271
|
+
appended contacts; otherwise the harness reads the log through `ctx.getQsos`
|
|
1272
|
+
and folds it cold. The batched `scoreQsos` resumes the same way: after the
|
|
1273
|
+
first pass of a session, an append arrives as a checkpoint plus the appended
|
|
1274
|
+
contacts, and only their verdicts come back.
|
|
1275
|
+
|
|
1276
|
+
**A checkpoint is held in the runtime and named, not sent, once it is
|
|
1277
|
+
there.** A sheet the size of a long log's worked-call map crossing the bridge
|
|
1278
|
+
on every keystroke would be most of the keystroke's cost. So the host names
|
|
1279
|
+
each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
|
|
1280
|
+
the batch pass produces it, and the harness keeps the end sheet under that
|
|
1281
|
+
name — one per operation, the last few operations. A live call, or the next
|
|
1282
|
+
append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
|
|
1283
|
+
`resumeKey` is filed under it too. A name the harness does not hold — a
|
|
1284
|
+
runtime restarted since, or one operation more than it keeps — is an error
|
|
1285
|
+
(`resume-missing`), never a silent cold fold, and the host answers it by
|
|
1286
|
+
sending the sheet once more. The host names a checkpoint only to a harness
|
|
1287
|
+
that has said `checkpointHeld: true` for it; a scorer built on an SDK without
|
|
1288
|
+
the cache never says so and keeps receiving the sheet. A `scoreCandidates`
|
|
1289
|
+
answer says it under the reserved key `$checkpointHeld`, since its other
|
|
1290
|
+
entries are the caller's candidate keys — a candidate key never starts with
|
|
1291
|
+
`$`.
|
|
1292
|
+
|
|
1293
|
+
The view a scorer gets on the live paths takes every kind of write —
|
|
1294
|
+
assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
|
|
1295
|
+
that would reach the base sheet by other means: `Object.freeze` (or `seal`,
|
|
1296
|
+
`preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
|
|
1297
|
+
or carries accessors is outside the contract and throws at the first touch.
|
|
1298
|
+
|
|
1205
1299
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1206
1300
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1207
|
-
scorer and every candidate scored against
|
|
1208
|
-
can become another's duplicate
|
|
1209
|
-
|
|
1301
|
+
scorer and every candidate scored against its own copy-on-write VIEW of the
|
|
1302
|
+
result, so no candidate can become another's duplicate and no candidate pays
|
|
1303
|
+
for a copy of the log. Candidates carry no uuid — a spot is not a record — so
|
|
1304
|
+
results come back under caller-chosen keys.
|
|
1210
1305
|
|
|
1211
|
-
`
|
|
1306
|
+
`QsosRepository.forOperation` already filters deleted rows and orders by
|
|
1212
1307
|
`startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
|
|
1213
1308
|
`deleted`/`event` row bookkeeping on this side of the bridge.
|
|
1214
1309
|
`ScoringService` (`app/lib/services/scoring_service.dart`) writes
|
|
@@ -1448,6 +1543,14 @@ fires when a lookup is ANSWERED, not while a call is being typed, and the
|
|
|
1448
1543
|
guess is inside `args.qso` either way — declaring `'lookup'` is enough to be
|
|
1449
1544
|
sent the draft, so what it saves over `'qso'` is renders, not the QSO.
|
|
1450
1545
|
|
|
1546
|
+
Beside the QSON, a draft carries one key a logged record never does:
|
|
1547
|
+
`callField`, the call field read at the cursor (`call`, `fullCall`,
|
|
1548
|
+
`partial`, `nextCall`, `nextPartial`). `their.call` is the field as typed, which may be a whole
|
|
1549
|
+
`//` stack or comma list; `callField.call` is the one call Enter logs. It
|
|
1550
|
+
is what [templates.md](templates.md)'s `qso.call` renders from, so a panel
|
|
1551
|
+
rendering a template needs nothing more, and moving the cursor within a
|
|
1552
|
+
stack or a list wakes a `'qso'` panel even when the QSON is unchanged.
|
|
1553
|
+
|
|
1451
1554
|
`'lookup'` fires when the panel is shown a callsign that HAS an answer —
|
|
1452
1555
|
which includes opening a QSO whose stored record already carries one, not
|
|
1453
1556
|
only a fresh lookup resolving. It does not fire when a lookup starts, fails,
|