@ham2k/extension-sdk 0.6.0 → 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 +5 -0
- package/dist/hookCategories.js +25 -0
- package/dist/index.d.ts +55 -5
- package/dist/index.js +25 -0
- package/dist/templateContext.js +43 -2
- package/dist/timerQueue.js +187 -0
- package/dist/timers.js +42 -0
- package/docs/distribution.md +22 -4
- package/docs/hooks.md +82 -10
- package/docs/templates.md +68 -1
- package/package.json +5 -1
package/AGENTS.md
CHANGED
|
@@ -104,6 +104,7 @@ import { host } from "@ham2k/extension-sdk"
|
|
|
104
104
|
|
|
105
105
|
await host.fetch(url) // https only, and only domains your manifest lists
|
|
106
106
|
host.webSocket(url) // wss:// to hosts your manifest's webSockets lists
|
|
107
|
+
host.setTimeout(fn, ms) // timers — plain setTimeout reaches these too
|
|
107
108
|
await host.kvGet('key') // storage, namespaced to your extension
|
|
108
109
|
await host.kvSet('key', value)
|
|
109
110
|
await host.showForm(form) // ask the user something
|
|
@@ -132,6 +133,10 @@ host.log('…') // the app's console
|
|
|
132
133
|
reserved.
|
|
133
134
|
- **`host.secret()` returns null** for anything not built into the app. Degrade;
|
|
134
135
|
don't throw. A hook that throws takes its whole feature down.
|
|
136
|
+
- **Timers are budgeted, not free.** An extension's callbacks may average 10%
|
|
137
|
+
of real time; past that, or after five throwing in a row, its timers are
|
|
138
|
+
suspended until the extensions restart. At most 16 at once. Reconnect with a
|
|
139
|
+
growing delay, never straight from `onclose`.
|
|
135
140
|
- **Hook method names are routed by string.** The host finds your hook by its
|
|
136
141
|
registered key and then calls the method by name, so `renderPanel` where
|
|
137
142
|
`render` was meant is an extension that installs, activates, and does nothing.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const HOOK_CATEGORIES = [
|
|
2
|
+
"account",
|
|
3
|
+
"activity",
|
|
4
|
+
"adifFields",
|
|
5
|
+
"adifImport",
|
|
6
|
+
"bench",
|
|
7
|
+
"callNotes",
|
|
8
|
+
"command",
|
|
9
|
+
"dataFile",
|
|
10
|
+
"export",
|
|
11
|
+
"form",
|
|
12
|
+
"lookup",
|
|
13
|
+
"lookupService",
|
|
14
|
+
"panel",
|
|
15
|
+
"recentContextLookup",
|
|
16
|
+
"runtime",
|
|
17
|
+
"scoring",
|
|
18
|
+
"settingsPanel",
|
|
19
|
+
"spots",
|
|
20
|
+
"syncTransport",
|
|
21
|
+
"template"
|
|
22
|
+
];
|
|
23
|
+
export {
|
|
24
|
+
HOOK_CATEGORIES
|
|
25
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
1
|
+
// @ham2k/extension-sdk 0.7.0
|
|
2
2
|
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface SvgScene {
|
|
4
4
|
version: 1;
|
|
@@ -116,6 +116,30 @@ export interface PanelSceneEvent {
|
|
|
116
116
|
export interface PanelSceneEventResult {
|
|
117
117
|
values: Record<string, number>;
|
|
118
118
|
}
|
|
119
|
+
export declare const HOOK_CATEGORIES: readonly [
|
|
120
|
+
"account",
|
|
121
|
+
"activity",
|
|
122
|
+
"adifFields",
|
|
123
|
+
"adifImport",
|
|
124
|
+
"bench",
|
|
125
|
+
"callNotes",
|
|
126
|
+
"command",
|
|
127
|
+
"dataFile",
|
|
128
|
+
"export",
|
|
129
|
+
"form",
|
|
130
|
+
"lookup",
|
|
131
|
+
"lookupService",
|
|
132
|
+
"panel",
|
|
133
|
+
"recentContextLookup",
|
|
134
|
+
"runtime",
|
|
135
|
+
"scoring",
|
|
136
|
+
"settingsPanel",
|
|
137
|
+
"spots",
|
|
138
|
+
"syncTransport",
|
|
139
|
+
"template"
|
|
140
|
+
];
|
|
141
|
+
export type HookCategory = (typeof HOOK_CATEGORIES)[number];
|
|
142
|
+
export type HookCategoryName = HookCategory | `ref:${string}`;
|
|
119
143
|
export type CallInfo = {
|
|
120
144
|
call: string;
|
|
121
145
|
baseCall?: string;
|
|
@@ -793,6 +817,7 @@ export interface CommandAction {
|
|
|
793
817
|
devBench?: {
|
|
794
818
|
qsoCount?: number;
|
|
795
819
|
};
|
|
820
|
+
devLogCat?: Record<string, never>;
|
|
796
821
|
toggleExperiment?: {
|
|
797
822
|
token: string;
|
|
798
823
|
};
|
|
@@ -938,8 +963,12 @@ export interface RegisterHookParams {
|
|
|
938
963
|
priority?: number;
|
|
939
964
|
}
|
|
940
965
|
export interface ActivationApi {
|
|
941
|
-
registerHook(category:
|
|
966
|
+
registerHook(category: HookCategoryName, params: RegisterHookParams): void;
|
|
942
967
|
hostCall(method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
968
|
+
timers?: {
|
|
969
|
+
set(callback: unknown, delay: unknown, repeat: boolean, args: unknown[]): number;
|
|
970
|
+
clear(id: unknown): void;
|
|
971
|
+
};
|
|
943
972
|
}
|
|
944
973
|
export interface ExtensionRelevance {
|
|
945
974
|
entities?: string[];
|
|
@@ -961,6 +990,7 @@ export interface ExtensionManifest {
|
|
|
961
990
|
category?: string;
|
|
962
991
|
enabledByDefault?: boolean;
|
|
963
992
|
hooks?: string[];
|
|
993
|
+
adifFieldsScope?: "always";
|
|
964
994
|
domains?: string[];
|
|
965
995
|
webSockets?: string[];
|
|
966
996
|
allowUserAgentOverride?: boolean;
|
|
@@ -977,6 +1007,8 @@ export interface ExtensionManifest {
|
|
|
977
1007
|
}
|
|
978
1008
|
export interface ExtensionDefinition extends ExtensionManifest {
|
|
979
1009
|
onActivation(api: ActivationApi): void;
|
|
1010
|
+
onShow?(): void | Promise<void>;
|
|
1011
|
+
onHide?(): void | Promise<void>;
|
|
980
1012
|
}
|
|
981
1013
|
export interface FetchOptions {
|
|
982
1014
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
@@ -1409,6 +1441,10 @@ export interface PanelHook {
|
|
|
1409
1441
|
event: PanelSceneEvent;
|
|
1410
1442
|
}, ctx: HookContext): Promise<PanelSceneEventResult>;
|
|
1411
1443
|
}
|
|
1444
|
+
declare function setTimeout$1(callback: (...args: any[]) => unknown, delay?: number, ...args: unknown[]): number;
|
|
1445
|
+
declare function setInterval$1(callback: (...args: any[]) => unknown, delay?: number, ...args: unknown[]): number;
|
|
1446
|
+
declare function clearTimeout$1(id?: number): void;
|
|
1447
|
+
declare function clearInterval$1(id?: number): void;
|
|
1412
1448
|
/**
|
|
1413
1449
|
* The standard per-extension translator: takes the extension's i18next
|
|
1414
1450
|
* resources once and returns a `tFor(ctx)` that hooks call with their
|
|
@@ -1693,25 +1729,35 @@ export interface TemplateContextArgs {
|
|
|
1693
1729
|
log?: LogValues;
|
|
1694
1730
|
appName?: string;
|
|
1695
1731
|
atMillis?: number;
|
|
1732
|
+
keyer?: {
|
|
1733
|
+
messages: string[];
|
|
1734
|
+
current?: number;
|
|
1735
|
+
};
|
|
1696
1736
|
nowMillis?: number;
|
|
1697
1737
|
}
|
|
1698
1738
|
export declare function templateContext(args: TemplateContextArgs): Record<string, unknown>;
|
|
1699
1739
|
export declare function isTestOperation(stationCall: JSONValue | undefined): boolean;
|
|
1700
1740
|
export declare function defineExtension(def: ExtensionDefinition): void;
|
|
1701
1741
|
export declare const hooks: {
|
|
1702
|
-
invokeAll(category:
|
|
1742
|
+
invokeAll(category: HookCategoryName, method: string, args: unknown, online?: boolean): Promise<{
|
|
1743
|
+
key: string;
|
|
1744
|
+
ok: boolean;
|
|
1745
|
+
value?: unknown;
|
|
1746
|
+
error?: string;
|
|
1747
|
+
}[]>;
|
|
1748
|
+
invokeOne(category: HookCategoryName, key: string, method: string, args: unknown, online?: boolean): Promise<{
|
|
1703
1749
|
key: string;
|
|
1704
1750
|
ok: boolean;
|
|
1705
1751
|
value?: unknown;
|
|
1706
1752
|
error?: string;
|
|
1707
1753
|
}[]>;
|
|
1708
|
-
|
|
1754
|
+
invokeForRefs(category: HookCategoryName, method: string, args: unknown, refs: unknown[], online?: boolean): Promise<{
|
|
1709
1755
|
key: string;
|
|
1710
1756
|
ok: boolean;
|
|
1711
1757
|
value?: unknown;
|
|
1712
1758
|
error?: string;
|
|
1713
1759
|
}[]>;
|
|
1714
|
-
invokeAllSequential(category:
|
|
1760
|
+
invokeAllSequential(category: HookCategoryName, method: string, initialArgs: unknown, fold: (args: unknown, hookResult: unknown, hookKey: string) => unknown, online?: boolean): Promise<{
|
|
1715
1761
|
key: string;
|
|
1716
1762
|
ok: boolean;
|
|
1717
1763
|
value?: unknown;
|
|
@@ -1753,6 +1799,10 @@ export declare const host: {
|
|
|
1753
1799
|
dismissNotice(key: string): Promise<void>;
|
|
1754
1800
|
};
|
|
1755
1801
|
log(message: string): void;
|
|
1802
|
+
setTimeout: typeof setTimeout$1;
|
|
1803
|
+
setInterval: typeof setInterval$1;
|
|
1804
|
+
clearTimeout: typeof clearTimeout$1;
|
|
1805
|
+
clearInterval: typeof clearInterval$1;
|
|
1756
1806
|
updateInterpretation(input: string, interpretation: Record<string, unknown>): void;
|
|
1757
1807
|
};
|
|
1758
1808
|
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
2
|
+
import { adoptExtensionTimers, clearInterval, clearTimeout, setInterval, setTimeout } from "./timers.js";
|
|
2
3
|
export * from "./types.js";
|
|
4
|
+
export * from "./hookCategories.js";
|
|
3
5
|
export * from "./svgScene.js";
|
|
4
6
|
export * from "./i18n.js";
|
|
5
7
|
export * from "./dxcc.js";
|
|
@@ -33,8 +35,14 @@ function defineExtension(def) {
|
|
|
33
35
|
currentExtensionKey = def.key;
|
|
34
36
|
kernel().defineExtension({
|
|
35
37
|
...def,
|
|
38
|
+
// Called on the author's own object, as onActivation is below, so `this`
|
|
39
|
+
// is the same object in all three: state kept on it at activation is there
|
|
40
|
+
// to stop on hide.
|
|
41
|
+
onShow: def.onShow && (() => def.onShow()),
|
|
42
|
+
onHide: def.onHide && (() => def.onHide()),
|
|
36
43
|
onActivation(api) {
|
|
37
44
|
if (typeof api.hostCall === "function") privilegedHostCalls[def.key] = api.hostCall;
|
|
45
|
+
adoptExtensionTimers(def.key, currentExtensionKey, api.timers);
|
|
38
46
|
return def.onActivation(api);
|
|
39
47
|
}
|
|
40
48
|
});
|
|
@@ -133,6 +141,15 @@ const hooks = {
|
|
|
133
141
|
invokeOne(category, key, method, args, online) {
|
|
134
142
|
return kernel().invokeLocal(category, method, args, online, key);
|
|
135
143
|
},
|
|
144
|
+
/// Like `invokeAll`, but only the hooks of the extensions that answer for one
|
|
145
|
+
/// of [refs] — for each, those holding the strongest `ref:<type>` claim on it
|
|
146
|
+
/// (a `ref:<type>/<code>` prefix beats the bare type) — plus, for
|
|
147
|
+
/// `adifFields`, any whose manifest sets `adifFieldsScope: "always"`.
|
|
148
|
+
/// For a fan-out over programs that must leave out the ones a contact has
|
|
149
|
+
/// nothing to do with, as the full ADIF export does.
|
|
150
|
+
invokeForRefs(category, method, args, refs, online) {
|
|
151
|
+
return kernel().invokeLocal(category, method, args, online, null, refs);
|
|
152
|
+
},
|
|
136
153
|
/// Like `invokeAll`, but calls hooks one at a time in priority order,
|
|
137
154
|
/// threading each result into the next call's args via `fold` — needed
|
|
138
155
|
/// when later hooks must see earlier hooks' contributions.
|
|
@@ -318,6 +335,14 @@ const host = {
|
|
|
318
335
|
log(message) {
|
|
319
336
|
kernel().log(`[${currentExtensionKey}] ${message}`);
|
|
320
337
|
},
|
|
338
|
+
/// Browser-shaped timers, on real time, bound to this extension and held to
|
|
339
|
+
/// its budget (docs/extensions/README.md, "Timers"). A bundle built for
|
|
340
|
+
/// extension API 3 reaches these from a plain `setTimeout` too, npm
|
|
341
|
+
/// dependencies included (sdk/src/timers.ts).
|
|
342
|
+
setTimeout,
|
|
343
|
+
setInterval,
|
|
344
|
+
clearTimeout,
|
|
345
|
+
clearInterval,
|
|
321
346
|
/// Pushes a later CommandInterpretation for the same `input` a `command`
|
|
322
347
|
/// hook's `interpret()` was called with — see CommandHook's doc. One-way,
|
|
323
348
|
/// like `log`: call it, don't await anything back.
|
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
|
@@ -48,7 +48,9 @@ it does not speak, so an older app meeting a newer bundle says so instead of
|
|
|
48
48
|
failing somewhere deep in a hook — and the catalog does not list it for that
|
|
49
49
|
app at all. Declare the lowest version that has what the bundle uses, so it
|
|
50
50
|
reaches every app that can run it: 2 adds `host.webSocket`, and the packer
|
|
51
|
-
refuses a manifest declaring `webSockets` under anything less
|
|
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").
|
|
52
54
|
|
|
53
55
|
Three fields are **refused** in a distributed bundle, and it is worth
|
|
54
56
|
understanding why:
|
|
@@ -257,7 +259,7 @@ It reports every problem at once rather than one per run:
|
|
|
257
259
|
h2kext-pack: ./build is not ready to package:
|
|
258
260
|
manifest.json: missing required field 'name'
|
|
259
261
|
manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
|
|
260
|
-
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)
|
|
261
263
|
```
|
|
262
264
|
|
|
263
265
|
`--force-name` waives the naming convention below. It exists for Ham2K's own
|
|
@@ -322,7 +324,8 @@ configured.
|
|
|
322
324
|
|
|
323
325
|
The sandbox is otherwise the same one the built-in extensions run in — no
|
|
324
326
|
filesystem, no network beyond the `domains` and `webSockets` the manifest
|
|
325
|
-
declares and the user approved,
|
|
327
|
+
declares and the user approved, and timers only within their budget
|
|
328
|
+
(README's "Timers").
|
|
326
329
|
|
|
327
330
|
## Installing one
|
|
328
331
|
|
|
@@ -345,6 +348,14 @@ that key is compared — a bundle can change completely and still ask for the
|
|
|
345
348
|
same things. That may be worth revisiting once a bundle can be shown to come
|
|
346
349
|
from whoever it claims; it is not worth it while a key is all there is.
|
|
347
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
|
+
|
|
348
359
|
Removing an extension removes what it stored with it.
|
|
349
360
|
|
|
350
361
|
### Installing from a link
|
|
@@ -480,7 +491,14 @@ takes the plain one above.
|
|
|
480
491
|
At most once every six hours — on launch and on resume — the app asks the
|
|
481
492
|
catalog whether anything installed has a newer release, and says so in the
|
|
482
493
|
status bar and on the row; the notice comes down with the last update taken.
|
|
483
|
-
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
|
|
484
502
|
(`extensionRevoked`: version and note) and stops loading on the spot,
|
|
485
503
|
whatever the operator's own switch says — the row shows "Revoked" with the
|
|
486
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
|
|
@@ -598,8 +629,12 @@ true when THIS extension alone was asked — the operator tapped its "Activity
|
|
|
598
629
|
Types" row or typed its key as a scope — which a bare scope otherwise cannot be
|
|
599
630
|
told from the nearby list, since neither carries a `searchTerm`. An extension
|
|
600
631
|
that keeps itself out of the nearby list (`cwt`, offered only around a session)
|
|
601
|
-
answers a scoped call anyway: it is the one being asked. The core fans
|
|
602
|
-
|
|
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
|
|
603
638
|
in TWO BANDS (`app/lib/tools/activity_suggestions.dart`): everything carrying a
|
|
604
639
|
`distance` first, by `distance / relevance` ascending — a more relevant hit
|
|
605
640
|
counts as nearer — then everything without one, by DESCENDING `relevance`, with
|
|
@@ -715,9 +750,38 @@ file claims its own reference; ask every hook and both programs'
|
|
|
715
750
|
program to guess which is its. The delegate cannot work out who called it, so
|
|
716
751
|
naming yourself is not optional.
|
|
717
752
|
|
|
718
|
-
The full ADIF export names no `mainHandler
|
|
719
|
-
polo's `includeOtherRefs`, which only its full export
|
|
720
|
-
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.
|
|
721
785
|
|
|
722
786
|
An export can also name a **list** of other hooks it accepts,
|
|
723
787
|
`includeFieldsFrom` — polo's all-or-nothing flag narrowed to what the program
|
|
@@ -730,13 +794,13 @@ would claim no reference at all.
|
|
|
730
794
|
|
|
731
795
|
**A field name is written once per record, and the first to carry a value
|
|
732
796
|
wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
|
|
733
|
-
the same name — the full export asks
|
|
734
|
-
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
|
|
735
799
|
outrank all of them. Hooks are asked main handler first, then each
|
|
736
800
|
`includeFieldsFrom` key in the order given, so that order decides — whichever
|
|
737
801
|
of the two methods each hook answered. app-polo resolves a collision the same
|
|
738
|
-
way ("keep the first one defined"). The **full export**, which asks
|
|
739
|
-
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
|
|
740
804
|
group and hooks answering `fieldsForOneQSO` as another, so which of two programs
|
|
741
805
|
keeps a shared `MY_SIG` there is not something either of them chose.
|
|
742
806
|
|
|
@@ -1479,6 +1543,14 @@ fires when a lookup is ANSWERED, not while a call is being typed, and the
|
|
|
1479
1543
|
guess is inside `args.qso` either way — declaring `'lookup'` is enough to be
|
|
1480
1544
|
sent the draft, so what it saves over `'qso'` is renders, not the QSO.
|
|
1481
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
|
+
|
|
1482
1554
|
`'lookup'` fires when the panel is shown a callsign that HAS an answer —
|
|
1483
1555
|
which includes opening a QSO whose stored record already carries one, not
|
|
1484
1556
|
only a fresh lookup resolving. It does not fire when a lookup starts, fails,
|
package/docs/templates.md
CHANGED
|
@@ -48,6 +48,7 @@ is `{{ op.refs | refLabels | sentence }}`.
|
|
|
48
48
|
| `qso` | when the placement's triggers ask for it | — | ✓ | the draft contact, as far as it is typed |
|
|
49
49
|
| `config` | ✓ | — | — | — |
|
|
50
50
|
| `log` | — | ✓ | ✓ | — |
|
|
51
|
+
| `keyer` | — | — | — | ✓ (and the keyer area's labels) |
|
|
51
52
|
|
|
52
53
|
A namespace a surface has nothing for is **absent**, not blank — which is
|
|
53
54
|
what makes `{% if qso %}` an honest question. Registered exports get the operation plus the selected file’s `log` values.
|
|
@@ -75,13 +76,46 @@ printing `{{ op.startTime }}–{{ op.endTime }}` understates the session by
|
|
|
75
76
|
that contact's length.
|
|
76
77
|
|
|
77
78
|
### `qso` — one contact
|
|
78
|
-
`call`, `their`, `our` (both whole, so `qso.their.guess.name` reaches the
|
|
79
|
+
`call`, `fullCall`, `partial`, `nextCall`, `nextPartial`, `state`, `name`, `their`, `our` (both whole, so `qso.their.guess.name` reaches the
|
|
79
80
|
lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `serialSent`,
|
|
80
81
|
`serialRcvd`, `notes`, `date`, `dateCompact`, `time`, `at`, `startAtMillis`,
|
|
81
82
|
`refs` — and two short forms for the sent side, `rst` (= `rstSent`) and
|
|
82
83
|
`serial` (= `serialSent`), since a CW message is what we send and that is
|
|
83
84
|
where these get typed: `{{ qso.call }} {{ qso.rst | cut }} {{ qso.serial | pad: 3 | cut }}`.
|
|
84
85
|
|
|
86
|
+
In a draft, `call` is ONE call, read at the cursor as the call-info line
|
|
87
|
+
reads it — not the field as typed, which may hold a `//` stack or a comma
|
|
88
|
+
list ([logging-fields.md](https://github.com/ham2k/halo/blob/main/docs/design/logging-fields.md)). On a stack it is
|
|
89
|
+
the call Enter logs, or — when there is none — the partial under the
|
|
90
|
+
cursor, or the nearest one beside it when the cursor sits in an empty
|
|
91
|
+
segment a just-typed `//` opened; in
|
|
92
|
+
a comma list, where Enter logs every call, it is the one the cursor is on.
|
|
93
|
+
`their.call` stays the field as typed. On a stack, two more name the rest of
|
|
94
|
+
it: `nextCall`, the call the Enter after this one would log, and
|
|
95
|
+
`nextPartial`, the last entry left in the field once this one logs —
|
|
96
|
+
usually the call still being copied. With `KN2//KI2D//NK2Y//FR` and the
|
|
97
|
+
cursor at the end, that is `NK2Y`, `KI2D` and `FR`; with the cursor on
|
|
98
|
+
`KI2D`, it is `KI2D`, `NK2Y` and `FR`. Both are empty outside a stack and on
|
|
99
|
+
a logged contact.
|
|
100
|
+
|
|
101
|
+
`call` may be a partial still being copied, which is right for asking it
|
|
102
|
+
back (`{{ qso.call }}?`) and wrong for answering it. `fullCall` is `call`
|
|
103
|
+
when it reads as a callsign and blank otherwise; `partial` is the reverse.
|
|
104
|
+
Exactly one of them is `call`, so a message can branch on which:
|
|
105
|
+
`{% if qso.fullCall != blank %}…{% elsif qso.partial != blank %}{{ qso.partial }}?{% endif %}`.
|
|
106
|
+
A logged contact's call is always a `fullCall`.
|
|
107
|
+
|
|
108
|
+
`state` and `name` are what was typed, else what the lookup found
|
|
109
|
+
(`their.state` or `their.guess.state`, likewise for the name) — and `name`
|
|
110
|
+
is only the first word, since `GM {{ qso.name }}` is a greeting, not a
|
|
111
|
+
directory entry. Reach the whole name through `qso.their.name` /
|
|
112
|
+
`qso.their.guess.name`.
|
|
113
|
+
|
|
114
|
+
Enter Sends Messages renders its thanks AFTER the contact logs, against the
|
|
115
|
+
entry the log left ([logging-fields.md](https://github.com/ham2k/halo/blob/main/docs/design/logging-fields.md) § Enter
|
|
116
|
+
Sends Messages) — so in a thanks keyed by ESM, `qso.call`, `qso.name` and
|
|
117
|
+
the rest name the NEXT station, or nothing, never the one just logged.
|
|
118
|
+
|
|
85
119
|
`rstSent` is what WE sent — QSON stores each side's report under its own
|
|
86
120
|
`sent`, and these are named for the operator's view of the contact.
|
|
87
121
|
|
|
@@ -100,6 +134,22 @@ Whatever that panel's `form` declared, under the keys it used.
|
|
|
100
134
|
`handlerShortName`, `format`, `exportType`, `modifier`, `extension`,
|
|
101
135
|
`compact`.
|
|
102
136
|
|
|
137
|
+
### `keyer` — the CW messages
|
|
138
|
+
`msg1` … `msg8`: the active set's messages, each RENDERED against the same
|
|
139
|
+
context, so one message can include another —
|
|
140
|
+
`TU {{ keyer.msg1 }}` thanks the station and calls CQ again with whatever F1
|
|
141
|
+
holds. A message is expanded only where a template names it, so a branch
|
|
142
|
+
not taken costs nothing — which is what lets ESM's thanks answer the next
|
|
143
|
+
stacked caller or ask a partial back, and send nothing more when there is
|
|
144
|
+
neither:
|
|
145
|
+
`TU {% if qso.fullCall != blank %}{{ keyer.msg5 }} {{ keyer.msg2 }}{% elsif qso.partial != blank %}{{ qso.partial }}?{% endif %}`.
|
|
146
|
+
|
|
147
|
+
A message that reaches itself — directly, or through others — is an error
|
|
148
|
+
naming the loop (`a message includes itself: F3 → F1 → F3`), and like any
|
|
149
|
+
template error it keys nothing: a message that quietly dropped the part that
|
|
150
|
+
looped would go on the air looking fine. A label may name its own message;
|
|
151
|
+
only a message being SENT is part of the chain.
|
|
152
|
+
|
|
103
153
|
## Timestamps
|
|
104
154
|
|
|
105
155
|
Two forms, and the rule is worth learning once:
|
|
@@ -146,6 +196,23 @@ operation at an unlisted park would print as though it had none. Liquid's own
|
|
|
146
196
|
`array_to_sentence_string` is the near miss for `sentence`: it always writes
|
|
147
197
|
the serial comma (`A, B, & C`).
|
|
148
198
|
|
|
199
|
+
### Fallbacks
|
|
200
|
+
|
|
201
|
+
`default` chains, taking the first value that isn't blank — and the values
|
|
202
|
+
above are `""` when there is nothing to say, which counts as blank:
|
|
203
|
+
|
|
204
|
+
```liquid
|
|
205
|
+
{{ qso.their.state | default: qso.their.guess.state | default: qso.their.county | default: "none" }}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The same as a tag, for when each branch wants more than one value. Compare
|
|
209
|
+
against `blank` rather than testing the bare value: in Liquid only `nil` and
|
|
210
|
+
`false` are false, so `{% if "" %}` takes the branch and prints nothing.
|
|
211
|
+
|
|
212
|
+
```liquid
|
|
213
|
+
{% if qso.their.state != blank %}{{ qso.their.state }}{% elsif qso.their.county != blank %}{{ qso.their.county }}{% else %}none{% endif %}
|
|
214
|
+
```
|
|
215
|
+
|
|
149
216
|
`alnum` is app-polo's `compact` helper under a different name: Liquid already
|
|
150
217
|
has a `compact` (it drops nils from an **array**, and `op.refs`/`qso.refs` are
|
|
151
218
|
arrays), and `registerFilter` overwrites without warning, so taking that name
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ham2k",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"types": "./dist/index.d.ts",
|
|
40
40
|
"default": "./dist/index.js"
|
|
41
41
|
},
|
|
42
|
+
"./timers": {
|
|
43
|
+
"ham2k-source": "./src/timers.ts",
|
|
44
|
+
"default": "./dist/timers.js"
|
|
45
|
+
},
|
|
42
46
|
"./package.json": "./package.json"
|
|
43
47
|
},
|
|
44
48
|
"types": "./dist/index.d.ts",
|