@ham2k/extension-sdk 0.12.9 → 0.12.11

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
@@ -81,7 +81,7 @@ will actually call.
81
81
 
82
82
  | Doing this | Read |
83
83
  | --- | --- |
84
- | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-panel-scenes` (experimental interactive panel scene) |
84
+ | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hello-world` (the smallest panel), `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-panel-scenes` (experimental interactive panel scene), `k2hrc-panafall` (panadapter and waterfall scene) |
85
85
  | Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
86
86
  | A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
87
87
  | Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
@@ -1,5 +1,9 @@
1
1
  function copyOnWrite(base) {
2
- return isContainer(base) ? wrap(base) : base;
2
+ return isContainer(base) ? wrap(wrappable(base)) : base;
3
+ }
4
+ function wrappable(value) {
5
+ if (!Object.isFrozen(value)) return value;
6
+ return Array.isArray(value) ? [...value] : { ...value };
3
7
  }
4
8
  function isContainer(value) {
5
9
  return typeof value === "object" && value !== null;
@@ -13,7 +17,7 @@ function wrap(target) {
13
17
  if (deleted.has(key)) return void 0;
14
18
  const value = Reflect.get(t, key);
15
19
  if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
16
- const view = wrap(value);
20
+ const view = wrap(wrappable(value));
17
21
  own.set(key, view);
18
22
  return view;
19
23
  }
@@ -5,6 +5,8 @@ const HOOK_CATEGORIES = [
5
5
  "adifImport",
6
6
  "bench",
7
7
  "callNotes",
8
+ "chasing",
9
+ "cluster",
8
10
  "command",
9
11
  "dataFile",
10
12
  "export",
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.9
1
+ // @ham2k/extension-sdk 0.12.11
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -474,6 +474,8 @@ export declare const HOOK_CATEGORIES: readonly [
474
474
  "adifImport",
475
475
  "bench",
476
476
  "callNotes",
477
+ "chasing",
478
+ "cluster",
477
479
  "command",
478
480
  "dataFile",
479
481
  "export",
@@ -732,6 +734,39 @@ export interface SpotsHook {
732
734
  postSelfSpot?(args: PostSelfSpotRequest, ctx: HookContext): Promise<PostResult>;
733
735
  postOtherSpot?(args: PostOtherSpotRequest, ctx: HookContext): Promise<PostResult>;
734
736
  }
737
+ export interface ClusterLine {
738
+ seq: number;
739
+ at: number;
740
+ text: string;
741
+ }
742
+ export interface ClusterStatus {
743
+ state: "idle" | "connecting" | "connected" | "failed";
744
+ node: {
745
+ name: string;
746
+ host: string;
747
+ port: number;
748
+ };
749
+ call?: string;
750
+ error?: string;
751
+ message?: string;
752
+ lastSeq: number;
753
+ }
754
+ export interface ClusterReply {
755
+ ok: boolean;
756
+ lines: string[];
757
+ error?: string;
758
+ message?: string;
759
+ }
760
+ export interface ClusterPostSpotRequest {
761
+ freq: number;
762
+ call: string;
763
+ comment?: string;
764
+ }
765
+ export interface ClusterHook {
766
+ onLines(args: {
767
+ lines: ClusterLine[];
768
+ }, ctx: HookContext): Promise<void>;
769
+ }
735
770
  export interface Ref {
736
771
  type: string;
737
772
  ref?: string;
@@ -1119,6 +1154,82 @@ export interface ContestScorer<S extends Scoresheet = Scoresheet> {
1119
1154
  scope: "day" | "operation";
1120
1155
  }, ctx: HookContext): Record<string, ScoreTally>;
1121
1156
  }
1157
+ export interface ChasingProgram {
1158
+ label: string;
1159
+ icon?: string;
1160
+ color?: string;
1161
+ version: number;
1162
+ editions: "lifetime" | "annual" | "event";
1163
+ supersedes?: string[];
1164
+ refLabel?: string;
1165
+ imports?: ChasingImporter[];
1166
+ }
1167
+ export interface ChasingImporter {
1168
+ key: string;
1169
+ label: string;
1170
+ extensions: string[];
1171
+ }
1172
+ export interface ImportedClaim extends QsoClaim {
1173
+ at?: number;
1174
+ match?: {
1175
+ theirCall?: string;
1176
+ startAtMillis?: number;
1177
+ band?: string;
1178
+ mode?: string;
1179
+ };
1180
+ }
1181
+ export type ClaimLevel = "worked" | "confirmed" | "verified";
1182
+ export interface QsoClaim {
1183
+ holder: string;
1184
+ ref: string;
1185
+ band: string | null;
1186
+ mode: string | null;
1187
+ edition?: string;
1188
+ level: ClaimLevel;
1189
+ by?: string;
1190
+ label?: string;
1191
+ guess?: boolean;
1192
+ }
1193
+ export interface ClaimSlot {
1194
+ ref: string;
1195
+ band: string | null;
1196
+ mode: string | null;
1197
+ level: ClaimLevel;
1198
+ label?: string;
1199
+ }
1200
+ export type ClaimsTally = Omit<ScoreTally, "for">;
1201
+ export interface ChasingHook {
1202
+ program: ChasingProgram;
1203
+ claimsForQsos(args: {
1204
+ qsos: Record<string, JSONValue>[];
1205
+ }, ctx: HookContext): Promise<Record<string, QsoClaim[]>> | Record<string, QsoClaim[]>;
1206
+ holdersFor(args: {
1207
+ station: {
1208
+ call: string;
1209
+ } & Record<string, JSONValue>;
1210
+ }, ctx: HookContext): Promise<string[]> | string[];
1211
+ summarizeClaims(args: {
1212
+ holder: string;
1213
+ edition?: string;
1214
+ slots: ClaimSlot[];
1215
+ }, ctx: HookContext): Promise<Record<string, ClaimsTally>> | Record<string, ClaimsTally>;
1216
+ importClaims?(args: {
1217
+ importer: string;
1218
+ file: {
1219
+ name: string;
1220
+ text: string;
1221
+ };
1222
+ station: {
1223
+ call: string;
1224
+ } & Record<string, JSONValue>;
1225
+ }, ctx: HookContext): Promise<{
1226
+ claims: ImportedClaim[];
1227
+ skipped?: number;
1228
+ }> | {
1229
+ claims: ImportedClaim[];
1230
+ skipped?: number;
1231
+ };
1232
+ }
1122
1233
  export interface CommandAction {
1123
1234
  setVfo?: {
1124
1235
  freq?: number;
@@ -1142,6 +1253,10 @@ export interface CommandAction {
1142
1253
  comment?: string;
1143
1254
  target?: "self" | "last";
1144
1255
  };
1256
+ autoRespot?: {
1257
+ minutes?: number;
1258
+ off?: boolean;
1259
+ };
1145
1260
  syncAll?: Record<string, never>;
1146
1261
  purgeTests?: Record<string, never>;
1147
1262
  updateSettings?: {
@@ -1203,6 +1318,7 @@ export interface CommandAction {
1203
1318
  };
1204
1319
  devLogCat?: Record<string, never>;
1205
1320
  devMapInfo?: Record<string, never>;
1321
+ devIdle?: Record<string, never>;
1206
1322
  toggleExperiment?: {
1207
1323
  token: string;
1208
1324
  };
@@ -1264,11 +1380,13 @@ export interface CommandHook {
1264
1380
  export interface SyncPayload {
1265
1381
  operations?: Record<string, JSONValue>[];
1266
1382
  qsos?: Record<string, JSONValue>[];
1383
+ claims?: Record<string, JSONValue>[];
1267
1384
  meta?: Record<string, JSONValue>;
1268
1385
  }
1269
1386
  export interface SyncResponse {
1270
1387
  operations?: Record<string, JSONValue>[];
1271
1388
  qsos?: Record<string, JSONValue>[];
1389
+ claims?: Record<string, JSONValue>[];
1272
1390
  meta?: Record<string, JSONValue>;
1273
1391
  account?: Record<string, JSONValue>;
1274
1392
  success?: boolean;
@@ -1359,7 +1477,7 @@ export interface AccountHook {
1359
1477
  synchronizable?: boolean;
1360
1478
  testCredentials(credentials: Record<string, string>, ctx: HookContext): Promise<string>;
1361
1479
  }
1362
- export type Hook = LookupHook | LookupServiceHook | SpotsHook | ExportHook | ScoringHook | RefHandlerHook | ActivityHook | AdifFieldsHook | AdifImportHook | CommandHook | SyncTransportHook | AccountHook | DataFileDefinition | FormHook | SettingsPanelHook | PanelHook | Record<string, unknown>;
1480
+ export type Hook = LookupHook | LookupServiceHook | SpotsHook | ExportHook | ScoringHook | ChasingHook | RefHandlerHook | ActivityHook | AdifFieldsHook | AdifImportHook | CommandHook | SyncTransportHook | AccountHook | DataFileDefinition | FormHook | SettingsPanelHook | PanelHook | Record<string, unknown>;
1363
1481
  export interface RegisterHookParams {
1364
1482
  hook: Hook;
1365
1483
  key?: string;
@@ -1400,6 +1518,9 @@ export interface ExtensionManifest {
1400
1518
  requiresLocation?: boolean;
1401
1519
  requiresRadioRead?: boolean;
1402
1520
  requiresRadioWrite?: boolean;
1521
+ requiresClusterRead?: boolean;
1522
+ requiresClusterSpot?: boolean;
1523
+ requiresClusterCommand?: boolean;
1403
1524
  experiments?: string[];
1404
1525
  relevance?: ExtensionRelevance;
1405
1526
  translations?: Record<string, {
@@ -1543,6 +1664,7 @@ export interface FormField {
1543
1664
  templateContext?: "export" | "adif" | "text";
1544
1665
  templateSample?: Record<string, JSONValue>;
1545
1666
  uppercase?: boolean;
1667
+ storedText?: string;
1546
1668
  suggestions?: {
1547
1669
  code: string;
1548
1670
  name?: string;
@@ -1805,6 +1927,12 @@ export interface PanelRadioState {
1805
1927
  * cannot read it (extension API 7).
1806
1928
  */
1807
1929
  tuner?: "inline" | "bypassed" | "tuning" | "failed" | null;
1930
+ /**
1931
+ * Whether the radio starts a tune apart from putting its tuner in line — `setRadio`'s `startTune`.
1932
+ * False on a FlexRadio, whose in line already is the tune, and wherever `tuner` is null
1933
+ * (extension API 9).
1934
+ */
1935
+ canStartTune?: boolean;
1808
1936
  /**
1809
1937
  * The receive filter's edges in Hz from the tuned frequency, as the radio shows them — `100`…`2800`
1810
1938
  * in USB, `-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot read it
@@ -1921,6 +2049,13 @@ export interface PanelRadioSet extends PanelRadioTarget {
1921
2049
  * On a FlexRadio, in line is a TUNE — a carrier on the air — since its tuner has no other way in.
1922
2050
  */
1923
2051
  tunerInline?: boolean;
2052
+ /**
2053
+ * Starts a tune — a carrier on the air until the tuner settles. Only for an operator's explicit
2054
+ * press. Goes alone: with any other setting in the same call it is `invalid`; `unsupported` where
2055
+ * `canStartTune` is false (extension API 9). Not a keying call: it reaches the radio the panel
2056
+ * shows, with keying on or off. `host.stopRadioTransmit()` ends a tune the app started.
2057
+ */
2058
+ startTune?: true;
1924
2059
  /**
1925
2060
  * The receive filter, as `passband` reports it; refused as `unsupported` where that is null. A
1926
2061
  * radio that sets only a width from a table takes the nearest, keeping its own shift — read the
@@ -2406,8 +2541,22 @@ export declare const host: {
2406
2541
  setRadioPtt(args: PanelRadioTarget & {
2407
2542
  on: boolean;
2408
2543
  }): Promise<PanelRadioResult>;
2409
- /** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
2544
+ /** Stops all CW, voice and PTT the app is keying, and a tune it started (extension API 9), as Escape does. Needs `requiresRadioWrite` and extension API 7. */
2410
2545
  stopRadioTransmit(args: PanelRadioTarget): Promise<PanelRadioResult>;
2546
+ /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
2547
+ clusterStatus(): Promise<ClusterStatus>;
2548
+ /** The node's recent lines after `after`, oldest first, at most `limit` of the newest. Needs `requiresClusterRead` and extension API 10. */
2549
+ clusterLines(args?: {
2550
+ after?: number;
2551
+ limit?: number;
2552
+ }): Promise<ClusterLine[]>;
2553
+ /** Connects if need be and delivers every new line to this extension's `cluster` hook until `clusterUnsubscribe`. Needs `requiresClusterRead` and extension API 10. */
2554
+ clusterSubscribe(): Promise<ClusterStatus>;
2555
+ clusterUnsubscribe(): Promise<ClusterStatus>;
2556
+ /** Posts a spot, logged in as the operator. Needs `requiresClusterSpot` and extension API 10. */
2557
+ clusterPostSpot(args: ClusterPostSpotRequest): Promise<ClusterReply>;
2558
+ /** Sends any one command line, in the operator's name. Needs `requiresClusterCommand` and extension API 10. */
2559
+ clusterSend(command: string): Promise<ClusterReply>;
2411
2560
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
2412
2561
  webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
2413
2562
  kvGet(key: string): Promise<JSONValue | null>;
package/dist/index.js CHANGED
@@ -193,10 +193,33 @@ const host = {
193
193
  async setRadioPtt(args) {
194
194
  return await hostCallFn()("setRadioPtt", { ...args });
195
195
  },
196
- /** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
196
+ /** Stops all CW, voice and PTT the app is keying, and a tune it started (extension API 9), as Escape does. Needs `requiresRadioWrite` and extension API 7. */
197
197
  async stopRadioTransmit(args) {
198
198
  return await hostCallFn()("stopRadioTransmit", { ...args });
199
199
  },
200
+ /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
201
+ async clusterStatus() {
202
+ return await hostCallFn()("clusterStatus", {});
203
+ },
204
+ /** The node's recent lines after `after`, oldest first, at most `limit` of the newest. Needs `requiresClusterRead` and extension API 10. */
205
+ async clusterLines(args = {}) {
206
+ return await hostCallFn()("clusterLines", { ...args });
207
+ },
208
+ /** Connects if need be and delivers every new line to this extension's `cluster` hook until `clusterUnsubscribe`. Needs `requiresClusterRead` and extension API 10. */
209
+ async clusterSubscribe() {
210
+ return await hostCallFn()("clusterSubscribe", {});
211
+ },
212
+ async clusterUnsubscribe() {
213
+ return await hostCallFn()("clusterUnsubscribe", {});
214
+ },
215
+ /** Posts a spot, logged in as the operator. Needs `requiresClusterSpot` and extension API 10. */
216
+ async clusterPostSpot(args) {
217
+ return await hostCallFn()("clusterPostSpot", { ...args });
218
+ },
219
+ /** Sends any one command line, in the operator's name. Needs `requiresClusterCommand` and extension API 10. */
220
+ async clusterSend(command) {
221
+ return await hostCallFn()("clusterSend", { command });
222
+ },
200
223
  async fetch(url, options) {
201
224
  return await hostCallFn()("fetch", { url, ...options });
202
225
  },
@@ -0,0 +1,87 @@
1
+ const FROZEN_QSOS_API = 9;
2
+ const UNSUPPORTED = "Host call method getQsoChanges not implemented";
3
+ const KEEP = 3;
4
+ const startOf = (qso) => typeof qso.startAtMillis === "number" ? qso.startAtMillis : -Infinity;
5
+ function deepFreeze(value) {
6
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
7
+ Object.freeze(value);
8
+ for (const child of Object.values(value)) deepFreeze(child);
9
+ }
10
+ return value;
11
+ }
12
+ function createLogCopies(source, keep = KEEP) {
13
+ const copies = /* @__PURE__ */ new Map();
14
+ let unsupported = false;
15
+ function copyFor(operationUuid) {
16
+ let copy = copies.get(operationUuid);
17
+ if (copy) copies.delete(operationUuid);
18
+ else copy = { rows: /* @__PURE__ */ new Map(), list: [], queue: Promise.resolve() };
19
+ copies.set(operationUuid, copy);
20
+ for (const stale of copies.keys()) {
21
+ if (copies.size <= keep) break;
22
+ copies.delete(stale);
23
+ }
24
+ return copy;
25
+ }
26
+ async function sync(operationUuid, copy) {
27
+ let changes = await source.changes(operationUuid, copy.cursor);
28
+ if (changes === null) return false;
29
+ const held = apply(copy, changes);
30
+ if (!changes.reset && typeof changes.count === "number" && held !== changes.count) {
31
+ changes = await source.changes(operationUuid, void 0);
32
+ if (changes === null) return false;
33
+ apply(copy, changes);
34
+ }
35
+ if (changes.reset || changes.qsos.length > 0 || changes.removed.length > 0) {
36
+ copy.list = [...copy.rows.values()].sort((a, b) => startOf(a) - startOf(b));
37
+ copy.json = void 0;
38
+ }
39
+ copy.cursor = changes.cursor;
40
+ return true;
41
+ }
42
+ function apply(copy, changes) {
43
+ if (changes.reset) copy.rows.clear();
44
+ for (const qso of changes.qsos) copy.rows.set(String(qso.uuid), deepFreeze(qso));
45
+ for (const uuid of changes.removed) copy.rows.delete(uuid);
46
+ return copy.rows.size;
47
+ }
48
+ async function synced(operationUuid) {
49
+ if (unsupported) return UNSUPPORTED;
50
+ const copy = copyFor(operationUuid);
51
+ const run = copy.queue.then(() => sync(operationUuid, copy));
52
+ copy.queue = run.catch(() => void 0);
53
+ let known;
54
+ try {
55
+ known = await run;
56
+ } catch (error) {
57
+ if (!String(error?.message ?? error).includes(UNSUPPORTED)) throw error;
58
+ unsupported = true;
59
+ copies.clear();
60
+ return UNSUPPORTED;
61
+ }
62
+ if (known) return copy;
63
+ copies.delete(operationUuid);
64
+ return null;
65
+ }
66
+ return {
67
+ async read(operationUuid, { frozen }) {
68
+ const copy = await synced(operationUuid);
69
+ if (copy === null || copy === UNSUPPORTED) return copy === null ? null : source.whole(operationUuid);
70
+ if (frozen) return copy.list.slice();
71
+ copy.json ?? (copy.json = JSON.stringify(copy.list));
72
+ return JSON.parse(copy.json);
73
+ },
74
+ async pick(operationUuid, uuids, { frozen }) {
75
+ const copy = await synced(operationUuid);
76
+ if (copy === null) return null;
77
+ const rows = copy === UNSUPPORTED ? new Map((await source.whole(operationUuid))?.map((qso) => [String(qso.uuid), qso]) ?? []) : copy.rows;
78
+ const picked = uuids.flatMap((uuid) => rows.has(uuid) ? [rows.get(uuid)] : []);
79
+ return frozen || copy === UNSUPPORTED ? picked : JSON.parse(JSON.stringify(picked));
80
+ }
81
+ };
82
+ }
83
+ export {
84
+ FROZEN_QSOS_API,
85
+ createLogCopies,
86
+ deepFreeze
87
+ };
package/dist/scoring.js CHANGED
@@ -19,6 +19,11 @@ function chronological(qsos) {
19
19
  function isEvent(qso) {
20
20
  return qso.band === "event";
21
21
  }
22
+ function hasIncompleteCall(qso) {
23
+ const their = qso.their;
24
+ const call = their && typeof their === "object" && !Array.isArray(their) ? their.call : void 0;
25
+ return typeof call === "string" && call.includes("?");
26
+ }
22
27
  function startAtMillisOf(qso) {
23
28
  const at = qso.startAtMillis;
24
29
  return typeof at === "number" ? at : 0;
@@ -61,7 +66,7 @@ function contestScorer(scorer, options = {}) {
61
66
  const timeline = segments && segments.length > 1 ? segments : void 0;
62
67
  let segmentIndex = 0;
63
68
  for (const qso of qsos) {
64
- if (isEvent(qso)) continue;
69
+ if (isEvent(qso) || hasIncompleteCall(qso)) continue;
65
70
  let operation = args.operation;
66
71
  let ref = args.ref;
67
72
  if (timeline) {
@@ -240,7 +240,7 @@ what you want, which is how you use a version the host does not carry, say so:
240
240
  await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
241
241
  ```
242
242
 
243
- `extensions/samples/hello-world` is the shortest complete example. Declaring
243
+ `extensions/samples/k2hrc-hello-world` is the shortest complete example. Declaring
244
244
  what it uses rather than carrying it took the packaged bundle from 185 KB to
245
245
  **4 KB**.
246
246
 
package/docs/forms.md CHANGED
@@ -38,6 +38,10 @@ export interface FormField {
38
38
  value?: any; // initial state value if not provided in host call state
39
39
  placeholder?: string;
40
40
  uppercase?: boolean; // force typed input to uppercase (callsign fields always are)
41
+ // Only used when fieldType === 'secret' in a settings list: what the row
42
+ // says while a value is stored, in place of the fixed mask. A host older
43
+ // than this shows the mask.
44
+ storedText?: string;
41
45
  // Only used when fieldType === 'text': the codes the field takes, offered
42
46
  // in a list under it as the operator types (matched on code and name, as a
43
47
  // `kind: 'options'` logging control matches) and named beside it — a value
package/docs/hooks.md CHANGED
@@ -60,9 +60,12 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
60
60
  raw QSON, oldest first, excluding soft-deleted records but *including*
61
61
  event markers (`band: 'event'`) — or null when the operation is unknown or
62
62
  the host has no log access, distinct from `[]` (the operation exists and
63
- simply has no QSOs yet). Each call is a bridge round-trip carrying a whole
64
- log — fine at command-execution or export scale, not on per-keystroke
65
- paths.
63
+ simply has no QSOs yet). Every extension reads one copy of the log the
64
+ runtime keeps, which asks the host only what changed since the last read:
65
+ a call is never staler than itself, and the whole log crosses the bridge
66
+ only the first time. Each call gets a new array. From extension API 9 its
67
+ QSOs are that shared copy, frozen — a write throws, so copy what you mean
68
+ to change; under an older `api` each call gets QSOs of its own.
66
69
  * `getSpotsForCall?(call, maxAgeMinutes = 30)` — the spots the host has
67
70
  already fetched for `call`, no older than `maxAgeMinutes` (measured on the
68
71
  spot's own `spot.timeInMillis`, not on when it was fetched), newest first.
@@ -327,6 +330,48 @@ directly to the POTA API, no auth required). `sota`'s spot API requires an
327
330
  authenticated account app-halo doesn't have yet, so `sota` stays
328
331
  fetch-only for now.
329
332
 
333
+ ## ✅ `cluster` — the DX cluster connection
334
+
335
+ ```ts
336
+ interface ClusterHook {
337
+ onLines(args: { lines: ClusterLine[] }, ctx): Promise<void>
338
+ }
339
+ // ClusterLine: {seq, at, text}
340
+ // host.clusterSubscribe(): Promise<ClusterStatus> // then onLines, until
341
+ // host.clusterUnsubscribe(): Promise<ClusterStatus> // this
342
+ // host.clusterLines({after?, limit?}): Promise<ClusterLine[]>
343
+ // host.clusterStatus(): Promise<ClusterStatus>
344
+ // host.clusterPostSpot({freq, call, comment?}): Promise<ClusterReply>
345
+ // host.clusterSend(command): Promise<ClusterReply>
346
+ // ClusterReply: {ok, lines: string[], error?, message?}
347
+ ```
348
+
349
+ The app holds one telnet connection to the DX cluster node the operator picked
350
+ in Settings, logged in as the operator. Extensions share it rather than open
351
+ their own, because a node logs a call in once
352
+ ([docs/design/cluster.md](https://github.com/ham2k/halo/blob/main/docs/design/cluster.md)). Not on web, where every call
353
+ answers `error: 'unsupported'`. Extension API 10.
354
+
355
+ `clusterPostSpot` is how a `spots` hook posts to the cluster: the host builds
356
+ the `DX` command, sends at most 60 characters of comment, and spaces spots five
357
+ seconds apart. A reply with `ok: true` means the node took the spot without
358
+ refusing it. It does not prove the spot left that node: a DXSpider node can keep
359
+ a self-spot local and still echo it.
360
+
361
+ `onLines` is called only for an extension that called `clusterSubscribe()`,
362
+ with the lines since the last batch, at most once a second. Register the hook
363
+ under the extension's own key (the default): that is the key the host delivers
364
+ to. A subscription lasts until `clusterUnsubscribe()` or the runtime restarts,
365
+ and keeps the session open while it lasts. A reader that only polls
366
+ `clusterLines({ after })` holds nothing open.
367
+
368
+ Three manifest grants gate it, each including the ones before it:
369
+ `"requiresClusterRead": true` for status, lines and subscribing;
370
+ `"requiresClusterSpot": true` adds `clusterPostSpot`;
371
+ `"requiresClusterCommand": true` adds `clusterSend`, which sends any command in
372
+ the operator's name. Declare the least you need: the consent screen names it.
373
+ Implemented by: `ki2d-cluster-spotter` (spot).
374
+
330
375
  ## ✅ `export` — log file generation
331
376
 
332
377
  ```ts
@@ -733,9 +778,11 @@ suggested the nearer of its point's and its outline's distance — so `pota`'s
733
778
  still reaches an operator standing on it. Called with no
734
779
  `searchTerm` for nearby suggestions, or with one for name/code search — an
735
780
  extension may honor either, both, or neither (returning `[]`). `scoped` is
736
- true when THIS extension alone was asked — the operator tapped its "Activity
737
- Types" row or typed its key as a scope — which a bare scope otherwise cannot be
738
- told from the nearby list, since neither carries a `searchTerm`. An extension
781
+ true when THIS extension's own list was asked for — the operator tapped its
782
+ "Activity Types" row or typed one of its words, with or without the colon
783
+ (below) — which otherwise cannot be told from the nearby list, since neither
784
+ carries a `searchTerm`. Without the colon the other extensions are asked too,
785
+ for the word as text. An extension
739
786
  that keeps itself out of the nearby list (`cwt`, offered only around a session)
740
787
  answers a scoped call anyway: it is the one being asked. The core fans out to
741
788
  every `activity` hook via `invokeHookAll`; a scoped search asks only its own
@@ -768,19 +815,38 @@ The row is marked, and the reference is stored, logged and exported as typed —
768
815
  the same rule ADIF import follows for a malformed `*_REF` (below).
769
816
 
770
817
  The activity search box also treats a leading scope word as a scope — typing
771
- `pota:` (or tapping a row under "Activity Types") asks the extension serving
772
- that reference type alone, via a key-scoped `invokeHook` rather than the usual
773
- fan-out. The word is the **reference type's base name**, lowercased —
774
- `potaActivation` and `pota` both scope as `pota:` — never the extension key: a
775
- user-published `ki2d-pota` serving `potaActivation` is still scoped as
776
- `pota:`, since the operator is adding POTA parks whichever extension supplies
777
- them. Two enabled extensions serving one type share the word, and the scoped
778
- search asks both. For the hook call to reach the extension, its `activity`
779
- hook must be registered under its own **extension key**, and its control
780
- descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
781
- derives the hook key from the part before the slash. An extension that
782
- deviates simply never gets scoped: its rows still add references, but the
783
- scope would ask a hook that doesn't exist and come back empty.
818
+ `pota:` (or tapping a row under "Activity Types") asks only the extensions
819
+ serving that reference type, via a key-scoped `invokeHook` rather than the
820
+ usual fan-out. An extension answers to two kinds of word:
821
+
822
+ - **Each reference type its manifest declares**, as a plain `ref:<type>` in
823
+ `hooks`, by the type's base name lowercased — `ref:potaActivation` and
824
+ `ref:pota` are both `pota:`. A user-published `ki2d-pota` declaring
825
+ `ref:potaActivation` is scoped as `pota:` too, since the operator is adding
826
+ POTA parks whichever extension supplies them: two running extensions
827
+ declaring one type share the word, and the scoped search asks both. A
828
+ qualified claim (`ref:qp/ny`) makes no word.
829
+ - **Its own extension key** — `ham2k-pota:` asks that extension alone, which
830
+ is how an operator picks one supplier over another.
831
+
832
+ The same word without its colon gives precedence instead of scoping. `pota`
833
+ on its own asks the extensions serving the type for their own list (as a
834
+ scope, `scoped: true` with no `searchTerm`) and every other extension to
835
+ search for the text "pota", with the first answers leading. `pota bear mtn`
836
+ asks the extensions serving the type for "bear mtn" and every other one for
837
+ the whole text — a `suggest` matching codes and names against one string
838
+ finds nothing for the word it was named by.
839
+
840
+ The scoped call reaches the extension under the key it registered its
841
+ `activity` hook with, which has to be its manifest key. The search reads which
842
+ extension supplied a control from the fan-out that returned it, never from
843
+ the descriptor's `key`, so a catalog `ham2k-pota` keying its controls
844
+ `pota/activation` is reached as `ham2k-pota`. (A logging control's serial
845
+ counter is still namespaced by the `key`'s prefix: two extensions keying
846
+ controls under one prefix share a counter.) A
847
+ switched-off or not-yet-installed extension is offered from the same words:
848
+ typing a type it declares, or its key, puts its enable or install row under
849
+ the search.
784
850
 
785
851
  `editable` declares whether a reference of this type has anything to edit
786
852
  once it's on the operation. It defaults to **false**: a plain reference
@@ -1046,7 +1112,7 @@ interface CommandHook {
1046
1112
  // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
1047
1113
  // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
1048
1114
  // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
1049
- // setKeyerSpeed?, showKeyer?, setEnterSends?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
1115
+ // setKeyerSpeed?, showKeyer?, setEnterSends?, sendSpots?, autoRespot?, syncAll?, updateSettings?, updateSetting?, openSettings?,
1050
1116
  // updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
1051
1117
  // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
1052
1118
  // devTimeTravel?, toggleExperiment?}
@@ -1165,6 +1231,9 @@ action field — the hook parses "EXP <key-or-alias>" and resolves the token
1165
1231
  against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
1166
1232
  resolves it again when it flips the state and reports the outcome, since the
1167
1233
  stored per-experiment state lives app-side;
1234
+ LANG `<code>` switches the app's language — hidden locales (CW, QRP, QRO)
1235
+ included, "LANG SYSTEM" back to the device's — with a plain `updateSetting`
1236
+ on the `locale` field, which refuses a code the app has no strings for;
1168
1237
  DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
1169
1238
  "DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
1170
1239
  anything that turns on the date or on elapsed time can be tested without
@@ -1451,6 +1520,92 @@ detection, DXCC entities worked (via `@ham2k/lib-country-files`'
1451
1520
  `useBuiltinCountryFile`, no network needed), and bands/modes worked. US
1452
1521
  States and Canadian Provinces rules are deferred (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
1453
1522
 
1523
+ ## ✅ `chasing` — what a contact counts toward
1524
+
1525
+ A chasing program — DXCC, POTA hunting, WAS — says which lines of its
1526
+ checklist each contact fills. The app keeps the best claim per line and ranks
1527
+ spots against the result; [claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) is the design.
1528
+
1529
+ One registration per program, keyed by the program's key — the extension's own
1530
+ key, or one prefixed `<extension key>_`, so one extension may register
1531
+ several:
1532
+
1533
+ ```ts
1534
+ registerHook("chasing", { key: "dxcc", hook: {
1535
+ program: { label: "DXCC", icon: "earth", version: 1, editions: "lifetime" },
1536
+ claimsForQsos({ qsos }, ctx) { /* { [uuid]: QsoClaim[] } */ },
1537
+ holdersFor({ station }, ctx) { /* string[] */ },
1538
+ summarizeClaims({ holder, edition, slots }, ctx) { /* { [key]: ClaimsTally } */ },
1539
+ importClaims({ importer, file, station }, ctx) { /* { claims: ImportedClaim[], skipped? } */ }, // optional
1540
+ }})
1541
+ ```
1542
+
1543
+ - **`program`** is plain data, read across every extension in one call
1544
+ without invoking anything. Raise `version` whenever the same contacts would
1545
+ claim something different: the app keeps no claim from an older version.
1546
+ `refLabel` is what one reference is called where a notice names it
1547
+ ("New POTA Park"), the program's `label` when left out.
1548
+ `supersedes` names the scoring notices the program's verdicts stand in for
1549
+ (DXCC: `newDXCC`): wherever the program judges a call or a spot, the
1550
+ scorer's notice is dropped in favor of the verdict.
1551
+ - **`claimsForQsos`** answers every contact in the batch, by `uuid` — a
1552
+ caller asking about something that is not a stored contact (a spot, the
1553
+ call being typed) gives it a `uuid` of its own, and a contact without one
1554
+ is left out — with the
1555
+ slots it fills: `{holder, ref, band, mode, level, edition?, by?, label?,
1556
+ guess?}`. `band` and `mode` are explicit — `null` means "any", and a
1557
+ program usually claims several granularities per contact (DXCC: Mixed, the
1558
+ band, the mode). `mode` is the program's own grouping; the app never maps
1559
+ modes for it. `level` is `worked`, `confirmed` (a QSL the program accepts,
1560
+ from `qso.qsl`) or `verified` (sponsor credit, `qso.credits.<program>`).
1561
+ **It must be a pure function of each contact** and the extension's own
1562
+ data — never the log — because the app asks it about spots, and about
1563
+ contacts on a device that holds only part of the log. An empty list is an
1564
+ answer: that contact claims nothing. So is the list for an event marker
1565
+ (`band: 'event'`), whose placeholder calls are not stations. A contact's
1566
+ `our` carries the place it was made from — `grid`, `lat`/`lon` — its own,
1567
+ or its operation's when it states none, for a program that keeps a
1568
+ checklist per place. A spot candidate's `their` carries the lookup's
1569
+ `guess.state` when an offline lookup knows one.
1570
+ - **`holdersFor`** lists the holders a station claims as — the opaque
1571
+ strings the program puts on its claims — so the app knows whose checklist
1572
+ to show: the first, in the current edition. `station` carries its call
1573
+ and, when it has one, its place, as a contact's `our` does. An `annual`
1574
+ program names its editions by UTC year (`'2026'`), which is how the app
1575
+ tells the current one; an `event` program's checklist has no current
1576
+ edition to show.
1577
+ - **`summarizeClaims`** reads one holder's filled slots (`{ref, band, mode,
1578
+ level, label?}` each) into tallies, a `ScoreTally` without its `for`.
1579
+ - **`importClaims`**, for a program whose `program.imports` lists importers
1580
+ (`{key, label, extensions}`), reads one file — `{name, text}` — that the
1581
+ operator picked or dropped, for the station they chase as (`station`, its
1582
+ call and place as `holdersFor` gets them). It answers the
1583
+ claims the file makes, each a `QsoClaim` plus `at` (epoch millis, when the
1584
+ slot was earned) if the file says, and `skipped`, the records it could not
1585
+ read. Each claim is kept in the log as a claim record — an event row, so it
1586
+ syncs with the log ([claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) §9) — and
1587
+ `claimsForQsos` is never asked about one: the app reads the claim it holds.
1588
+ The file is the whole of its importer's records: importing the same kind of
1589
+ file again replaces every claim the last one gave, and a file answered with
1590
+ no claims replaces nothing.
1591
+
1592
+ The host isolates programs from each other
1593
+ (`app/lib/services/extensions/extension_chasing.dart`): a program that throws
1594
+ or times out answers nothing for that call, and a contact with any
1595
+ malformed claim is left out of the answer, keeping the claims it already
1596
+ holds, since dropping one claim would read as the contact no longer making
1597
+ it. Both are reported once per program and method. The category is
1598
+ extension API 9: a bundle that registers a program declares `"api": 9`, which
1599
+ the packer enforces, so an older app — which would install it and never ask
1600
+ it anything — is never offered it.
1601
+
1602
+ Implemented by: `dxcc` (core) — Mixed, per-band and per-mode slots for the
1603
+ station call worked from each entity; LoTW and paper QSLs confirm, LoTW credit
1604
+ verifies. `ham2k-pota` — one slot per park hunted, worked only; its
1605
+ hunted-parks export verifies. `ham2k-was` — Mixed, per-band and per-mode
1606
+ slots (plus FT8 and RTTY) for the station call worked from one 4-character
1607
+ grid; LoTW and paper QSLs confirm, `WAS` credit verifies.
1608
+
1454
1609
  ## ✅ `bench` — kernel diagnostics
1455
1610
 
1456
1611
  Registered by the kernel itself (`bench/kernel`): `echo` (bridge round-trip
@@ -1471,6 +1626,11 @@ permission (pending challenge code for an email, immediately-active for an
1471
1626
  already-permitted account, or a blank permission for QR-code linking) and
1472
1627
  returns server rejections as `{ error, error_status }` data.
1473
1628
 
1629
+ `sync`'s payload and reply carry `claims` beside `operations` and `qsos`
1630
+ (docs/design/claims.md §7) once the server's reply has said it takes them
1631
+ with `meta.flags.claims`. A transport passes the array through like the
1632
+ other two, both ways.
1633
+
1474
1634
  `linkClient` must NOT mail anything. The core shows the challenge code on
1475
1635
  screen and offers the email as a separate choice, so a transport that mails
1476
1636
  here sends a confirmation nobody asked for — and one more on every renewal,
@@ -1941,6 +2101,9 @@ rate. Extension API 7 adds to the answer:
1941
2101
  modes `setRadio` accepts for this radio, in the radio's order.
1942
2102
  - `tuner`: `inline`, `bypassed`, `tuning` or `failed`; null for a radio
1943
2103
  without one, or whose driver cannot read it.
2104
+ - `canStartTune` (extension API 9): the radio starts a tune apart from
2105
+ putting its tuner in line — `setRadio`'s `startTune`. False on a FlexRadio,
2106
+ whose in line already is the tune, and wherever `tuner` is null.
1944
2107
  - `passband`: `{lowHz, highHz}`, the receive filter's edges in Hz from the
1945
2108
  tuned frequency as the radio shows them — `100`…`2800` in USB,
1946
2109
  `-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot
@@ -1975,6 +2138,14 @@ extension API 7: a bundle using them declares `"api": 7`.
1975
2138
  - `tunerInline`: puts the antenna tuner in line or bypasses it. On a
1976
2139
  FlexRadio, whose tuner has no other way in, in line is a TUNE — a carrier
1977
2140
  on the air; other radios switch it without transmitting.
2141
+ - `startTune: true` (extension API 9): starts a tune — a carrier on the air
2142
+ until the tuner settles, read back through `tuner` as `tuning`. Only for an
2143
+ operator's explicit press: never send it on a hook's own initiative. It
2144
+ goes alone — in the same call as any other setting it is `invalid` — and is
2145
+ `unsupported` where `canStartTune` is false. It is the radio's own tune,
2146
+ not one of the keying calls below: it reaches whichever radio the panel
2147
+ shows, with keying on or off. A start while one is running is ignored, and
2148
+ `stopRadioTransmit` (or Escape) ends a tune the app started.
1978
2149
  - `passband`: `{lowHz, highHz}`, as `passband` reports it. A radio that sets
1979
2150
  only a width from a table takes the nearest, keeping its own shift — read
1980
2151
  the result back from `passband`. It applies to the mode the radio is in: a
@@ -2009,7 +2180,7 @@ rather than keying the logging radio behind the operator's back:
2009
2180
  `on: false` releases whatever key the app holds and is never refused; a
2010
2181
  radio that does not take the key answers `on: true` with `disconnected`.
2011
2182
  - `host.stopRadioTransmit({id, selection?})` stops every CW message, voice
2012
- memory and PTT the app is keying, as Escape does, whatever radio the
2183
+ memory, PTT and tune the app is keying, as Escape does, whatever radio the
2013
2184
  identity names: a stop is never the call that is refused.
2014
2185
 
2015
2186
  A radio that cannot do what is asked (no CW key, no voice memories, no PTT)
@@ -2143,7 +2314,8 @@ its first render.
2143
2314
 
2144
2315
  The whole log is deliberately absent from `render`'s args. A panel that
2145
2316
  needs it calls `ctx.getQsos(operation.uuid)` — right for a `'qsoLogged'`
2146
- render, wrong for a per-keystroke one. Spots are absent for the same reason:
2317
+ render, which comes for every change to the log: a contact logged, edited
2318
+ or deleted, or one a sync brought in. Spots are absent for the same reason:
2147
2319
  a panel showing them declares `'spots'` and calls `ctx.getSpots()`, which
2148
2320
  answers the operator's own filtered board, judged against this operation.
2149
2321
 
@@ -2239,7 +2411,6 @@ Consequences worth knowing:
2239
2411
 
2240
2412
  | Category | Role | Notes |
2241
2413
  |---|---|---|
2242
- | `chasing` | what each contact counts toward a program's checklist (claims) | [claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) |
2243
2414
  | `confirmation` | QSL/confirmation sources (spot-history cross-checking) | |
2244
2415
  | `opSetting` | per-operation settings contributions | |
2245
2416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
package/samples/README.md CHANGED
@@ -6,6 +6,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
6
6
 
7
7
  | | |
8
8
  | --- | --- |
9
+ | `k2hrc-hello-world` | The smallest extension there is: one panel, saying hello |
9
10
  | `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
10
11
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
11
12
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
@@ -0,0 +1,6 @@
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
+
4
+ await buildExtension(build, { dir: import.meta.dirname })
5
+
6
+ console.log("built build/index.js")
@@ -0,0 +1,22 @@
1
+ {
2
+ "key": "k2hrc-hello-world",
3
+ "name": "Hello World",
4
+ "shortName": "Hello",
5
+ "version": "1.0.0",
6
+ "description": "The smallest possible Ham2K extension — a panel that says hello",
7
+ "category": "dashboard",
8
+ "icon": "hand-wave-outline",
9
+ "api": 1,
10
+ "keywords": ["sample", "example", "hello"],
11
+ "hooks": ["panel"],
12
+ "sharedDependencies": {
13
+ "@ham2k/lib-callsigns": "^1.0.2",
14
+ "@ham2k/lib-country-files": "^1.0.202605301",
15
+ "@ham2k/lib-dxcc-data": "^1.0.1",
16
+ "@ham2k/lib-format-tools": "^1.1.3",
17
+ "@ham2k/lib-geo-tools": "^1.1.1",
18
+ "@ham2k/lib-operation-data": "^1.1.3",
19
+ "i18next": "^23.16.8",
20
+ "liquidjs": "^10.28.0"
21
+ }
22
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // The smallest extension that does something visible: one panel, saying hello.
5
+ //
6
+ // It exists to be COPIED. Everything here is the minimum — one hook, one
7
+ // descriptor, one render — so that what a real extension adds is obvious by
8
+ // comparison. See the catalog's `ham2k-custom-text` for a panel that earns its
9
+ // keep, and docs/extensions/hooks.md for every other hook there is.
10
+ //
11
+ // Its manifest still lists the host's shared libraries in `sharedDependencies`:
12
+ // the SDK reaches them, and declaring them makes the bundle use the host's
13
+ // copy rather than carry its own — see docs/extensions/distribution.md.
14
+
15
+ import { defineExtension } from "@ham2k/extension-sdk"
16
+ import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
17
+
18
+ import manifest from "../manifest.json"
19
+
20
+ const HelloPanel: PanelHook = {
21
+ /// What the operator sees when choosing a panel to place.
22
+ async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
23
+ return [
24
+ {
25
+ key: "hello",
26
+ title: "Hello World",
27
+ description: "A panel that says hello",
28
+ icon: "hand-wave-outline",
29
+ },
30
+ ]
31
+ },
32
+
33
+ /// What the pane shows. Markdown, rendered by the app — a panel returns
34
+ /// content, never widgets, which is what keeps extensions out of the UI
35
+ /// toolkit's way.
36
+ ///
37
+ /// The method is `render`, not `renderPanel`: the host routes to the hook by
38
+ /// its registered key and then calls this by name, so a near-miss is a panel
39
+ /// that installs, activates, and then renders nothing.
40
+ async render(_args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
41
+ return {
42
+ kind: "markdown",
43
+ content: ["# Hello, World!", "", "This panel came from an extension installed from a `.h2kext` file."].join("\n"),
44
+ }
45
+ },
46
+ }
47
+
48
+ defineExtension({
49
+ ...manifest,
50
+ onActivation({ registerHook }) {
51
+ registerHook("panel", { key: "k2hrc-hello-world", hook: HelloPanel })
52
+ },
53
+ })