@ham2k/extension-sdk 0.6.0 → 0.8.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 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.
@@ -1,5 +1,6 @@
1
1
  import { createCachedTranslator } from "./i18n.js";
2
2
  import { modesMatchForDupe } from "./modes.js";
3
+ import { parseCallsign } from "@ham2k/lib-callsigns";
3
4
  import { fmtInteger } from "@ham2k/lib-format-tools";
4
5
  import en from "./i18n/activityScoring.en.json";
5
6
  import es from "./i18n/activityScoring.es.json";
@@ -34,6 +35,7 @@ function activityScorer(rules) {
34
35
  }
35
36
  const freshRefRescuesActivation = rules.freshRefRescuesActivation ?? true;
36
37
  const tracksActivation = rules.tracksActivation ?? true;
38
+ const tracksOperators = rules.tracksOperators ?? true;
37
39
  const allowsMultiple = rules.allowsMultipleReferences ?? false;
38
40
  const activatesDaily = (rules.activates ?? "daily") === "daily";
39
41
  const unique = new Set(rules.uniquePer ?? []);
@@ -79,11 +81,15 @@ function activityScorer(rules) {
79
81
  return {
80
82
  workedByCall: {},
81
83
  activatedRefs: {},
84
+ operatorRefs: {},
85
+ guestOperators: false,
82
86
  huntedRefs: {},
83
87
  activatedQsos: 0,
84
88
  huntedQsos: 0,
85
89
  duplicates: 0,
86
90
  dayActivatedRefs: {},
91
+ dayOperatorRefs: {},
92
+ dayGuestOperators: false,
87
93
  dayHuntedRefs: {},
88
94
  dayActivatedQsos: 0,
89
95
  dayHuntedQsos: 0,
@@ -95,9 +101,14 @@ function activityScorer(rules) {
95
101
  // per QSO makes a full pass quadratic. The harness copies any resume
96
102
  // checkpoint at its boundary, so nothing cached is ever aliased.
97
103
  scoreQso({ scoresheet, qso, operation, isNewDay }) {
104
+ var _a, _b;
98
105
  const sheet = scoresheet;
106
+ sheet.operatorRefs ?? (sheet.operatorRefs = {});
107
+ sheet.dayOperatorRefs ?? (sheet.dayOperatorRefs = {});
99
108
  if (isNewDay && activatesDaily) {
100
109
  sheet.dayActivatedRefs = {};
110
+ sheet.dayOperatorRefs = {};
111
+ sheet.dayGuestOperators = false;
101
112
  sheet.dayHuntedRefs = {};
102
113
  sheet.dayActivatedQsos = 0;
103
114
  sheet.dayHuntedQsos = 0;
@@ -136,9 +147,20 @@ function activityScorer(rules) {
136
147
  sheet.dayDuplicates += 1;
137
148
  return { scoresheet: sheet, score };
138
149
  }
150
+ const operator = tracksOperators ? str((qso.our ?? {}).operatorCall)?.trim().toUpperCase() : void 0;
151
+ if (operator && isGuestOperator(operator, operation)) {
152
+ sheet.guestOperators = true;
153
+ sheet.dayGuestOperators = true;
154
+ }
139
155
  for (const ref of activationRefs) {
140
156
  sheet.activatedRefs[ref] = (sheet.activatedRefs[ref] ?? 0) + activationCredit;
141
157
  sheet.dayActivatedRefs[ref] = (sheet.dayActivatedRefs[ref] ?? 0) + activationCredit;
158
+ if (operator) {
159
+ const shares = (_a = sheet.operatorRefs)[ref] ?? (_a[ref] = {});
160
+ shares[operator] = (shares[operator] ?? 0) + activationCredit;
161
+ const dayShares = (_b = sheet.dayOperatorRefs)[ref] ?? (_b[ref] = {});
162
+ dayShares[operator] = (dayShares[operator] ?? 0) + activationCredit;
163
+ }
142
164
  }
143
165
  sheet.activatedQsos += activationCredit;
144
166
  sheet.dayActivatedQsos += activationCredit;
@@ -160,6 +182,7 @@ function activityScorer(rules) {
160
182
  if (tracksActivation && Object.keys(activatedRefs).length > 0) {
161
183
  tallies.activation = summarizeActivation(
162
184
  activatedRefs,
185
+ (perDay ? scoresheet.dayGuestOperators : scoresheet.guestOperators) ? perDay ? scoresheet.dayOperatorRefs : scoresheet.operatorRefs : {},
163
186
  perDay ? scoresheet.dayActivatedQsos : scoresheet.activatedQsos,
164
187
  perDay ? scoresheet.dayDuplicates : scoresheet.duplicates,
165
188
  huntedQsos,
@@ -176,7 +199,15 @@ function activityScorer(rules) {
176
199
  }
177
200
  };
178
201
  }
179
- function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope, qsosToActivate, rules, t, ctx) {
202
+ function isGuestOperator(operator, operation) {
203
+ const stations = String(operation.stationCall ?? "").split(",").map((call) => baseCallOf(call)).filter((call) => call.length > 0);
204
+ return !stations.includes(baseCallOf(operator));
205
+ }
206
+ function baseCallOf(call) {
207
+ const trimmed = call.trim().toUpperCase();
208
+ return parseCallsign(trimmed).baseCall || trimmed;
209
+ }
210
+ function summarizeActivation(activatedRefs, operatorRefs, qsos, duplicates, huntedQsos, scope, qsosToActivate, rules, t, ctx) {
180
211
  const refKeys = Object.keys(activatedRefs).sort();
181
212
  const lowest = Math.min(...refKeys.map((key) => activatedRefs[key]));
182
213
  const activated = lowest >= qsosToActivate;
@@ -184,9 +215,14 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
184
215
  if (!activated) summary = `${fmtInteger(lowest)}/${fmtInteger(qsosToActivate)}`;
185
216
  else if (refKeys.length < 6) summary = `${fmtInteger(lowest)} ${"\u2713".repeat(refKeys.length)}`;
186
217
  else summary = `${fmtInteger(lowest)} \u2713 x ${fmtInteger(refKeys.length)}`;
187
- const detail = refKeys.map(
188
- (key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
189
- );
218
+ const detail = refKeys.flatMap((key) => {
219
+ const line = activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`;
220
+ const byOperator = operatorRefs[key] ?? {};
221
+ const operators = Object.keys(byOperator).sort();
222
+ if (operators.length === 0) return [line];
223
+ const shares = operators.map((op) => byOperator[op] >= qsosToActivate ? `${op} \u2713` : `${op} ${fmtInteger(byOperator[op])}/${fmtInteger(qsosToActivate)}`);
224
+ return [line, `\xA0\xA0\xA0\xA0${shares.join(" \xB7 ")}`];
225
+ });
190
226
  const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
191
227
  if (huntedQsos > 0) {
192
228
  const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
@@ -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.6.0
1
+ // @ham2k/extension-sdk 0.8.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: string, params: RegisterHookParams): void;
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
@@ -1513,6 +1549,7 @@ export interface ActivityScoringRules {
1513
1549
  huntingType?: string;
1514
1550
  qsosToActivate?: number | ((activatedRefs: string[]) => number);
1515
1551
  tracksActivation?: boolean;
1552
+ tracksOperators?: boolean;
1516
1553
  freshRefRescuesActivation?: boolean;
1517
1554
  allowsMultipleReferences?: boolean;
1518
1555
  uniquePer?: readonly UniqueAxis[];
@@ -1524,11 +1561,15 @@ export interface ActivityScoringRules {
1524
1561
  export type ActivityScoresheet = {
1525
1562
  workedByCall: Record<string, string[]>;
1526
1563
  activatedRefs: Record<string, number>;
1564
+ operatorRefs: Record<string, Record<string, number>>;
1565
+ guestOperators: boolean;
1527
1566
  huntedRefs: Record<string, number>;
1528
1567
  activatedQsos: number;
1529
1568
  huntedQsos: number;
1530
1569
  duplicates: number;
1531
1570
  dayActivatedRefs: Record<string, number>;
1571
+ dayOperatorRefs: Record<string, Record<string, number>>;
1572
+ dayGuestOperators: boolean;
1532
1573
  dayHuntedRefs: Record<string, number>;
1533
1574
  dayActivatedQsos: number;
1534
1575
  dayHuntedQsos: number;
@@ -1693,25 +1734,35 @@ export interface TemplateContextArgs {
1693
1734
  log?: LogValues;
1694
1735
  appName?: string;
1695
1736
  atMillis?: number;
1737
+ keyer?: {
1738
+ messages: string[];
1739
+ current?: number;
1740
+ };
1696
1741
  nowMillis?: number;
1697
1742
  }
1698
1743
  export declare function templateContext(args: TemplateContextArgs): Record<string, unknown>;
1699
1744
  export declare function isTestOperation(stationCall: JSONValue | undefined): boolean;
1700
1745
  export declare function defineExtension(def: ExtensionDefinition): void;
1701
1746
  export declare const hooks: {
1702
- invokeAll(category: string, method: string, args: unknown, online?: boolean): Promise<{
1747
+ invokeAll(category: HookCategoryName, method: string, args: unknown, online?: boolean): Promise<{
1748
+ key: string;
1749
+ ok: boolean;
1750
+ value?: unknown;
1751
+ error?: string;
1752
+ }[]>;
1753
+ invokeOne(category: HookCategoryName, key: string, method: string, args: unknown, online?: boolean): Promise<{
1703
1754
  key: string;
1704
1755
  ok: boolean;
1705
1756
  value?: unknown;
1706
1757
  error?: string;
1707
1758
  }[]>;
1708
- invokeOne(category: string, key: string, method: string, args: unknown, online?: boolean): Promise<{
1759
+ invokeForRefs(category: HookCategoryName, method: string, args: unknown, refs: unknown[], online?: boolean): Promise<{
1709
1760
  key: string;
1710
1761
  ok: boolean;
1711
1762
  value?: unknown;
1712
1763
  error?: string;
1713
1764
  }[]>;
1714
- invokeAllSequential(category: string, method: string, initialArgs: unknown, fold: (args: unknown, hookResult: unknown, hookKey: string) => unknown, online?: boolean): Promise<{
1765
+ invokeAllSequential(category: HookCategoryName, method: string, initialArgs: unknown, fold: (args: unknown, hookResult: unknown, hookKey: string) => unknown, online?: boolean): Promise<{
1715
1766
  key: string;
1716
1767
  ok: boolean;
1717
1768
  value?: unknown;
@@ -1753,6 +1804,10 @@ export declare const host: {
1753
1804
  dismissNotice(key: string): Promise<void>;
1754
1805
  };
1755
1806
  log(message: string): void;
1807
+ setTimeout: typeof setTimeout$1;
1808
+ setInterval: typeof setInterval$1;
1809
+ clearTimeout: typeof clearTimeout$1;
1810
+ clearInterval: typeof clearInterval$1;
1756
1811
  updateInterpretation(input: string, interpretation: Record<string, unknown>): void;
1757
1812
  };
1758
1813
 
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.
@@ -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
- call: their.call ?? "",
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
- return {
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
+ };
@@ -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 2)
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, no timers.
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,16 @@ 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. Pressed on the update notice when the notice's text already named every
356
+ extension it takes, it skips that confirmation too: the press under those names
357
+ was it. It is a confirmation of a job the operator started, never a way to
358
+ start one — `install_consent_scope_test` holds it to the one function that
359
+ shows that confirmation first.
360
+
348
361
  Removing an extension removes what it stored with it.
349
362
 
350
363
  ### Installing from a link
@@ -480,7 +493,15 @@ takes the plain one above.
480
493
  At most once every six hours — on launch and on resume — the app asks the
481
494
  catalog whether anything installed has a newer release, and says so in the
482
495
  status bar and on the row; the notice comes down with the last update taken.
483
- It never installs unasked. A release the catalog has revoked is put on record
496
+ It never installs unasked: the notice's **Update all** is the panel's own,
497
+ behind the same confirmation unless the notice named every extension it takes,
498
+ and **More info** opens the panel.
499
+
500
+ An install or update narrates itself as one status-bar progress item, from the
501
+ first downloaded byte to "Installed", and the Extensions panel mirrors that
502
+ item on the row being installed. Installs share one queue
503
+ (`ExtensionInstaller`): downloads run side by side, while the writes go one at
504
+ a time, since each restarts the runtime. A release the catalog has revoked is put on record
484
505
  (`extensionRevoked`: version and note) and stops loading on the spot,
485
506
  whatever the operator's own switch says — the row shows "Revoked" with the
486
507
  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 two ways:
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
- out to every `activity` hook via `invokeHookAll`, merges results, and ranks them
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` and every hook contributes —
719
- polo's `includeOtherRefs`, which only its full export sets. That is the export
720
- claiming no program: the complete copy the operator keeps.
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 everyone, and a park and a summit both
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 everyone,
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.6.0",
3
+ "version": "0.8.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",