@ham2k/extension-sdk 0.5.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -103,6 +103,8 @@ evaluating one script. Reach the outside world through `host`:
103
103
  import { host } from "@ham2k/extension-sdk"
104
104
 
105
105
  await host.fetch(url) // https only, and only domains your manifest lists
106
+ host.webSocket(url) // wss:// to hosts your manifest's webSockets lists
107
+ host.setTimeout(fn, ms) // timers — plain setTimeout reaches these too
106
108
  await host.kvGet('key') // storage, namespaced to your extension
107
109
  await host.kvSet('key', value)
108
110
  await host.showForm(form) // ask the user something
@@ -131,6 +133,10 @@ host.log('…') // the app's console
131
133
  reserved.
132
134
  - **`host.secret()` returns null** for anything not built into the app. Degrade;
133
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`.
134
140
  - **Hook method names are routed by string.** The host finds your hook by its
135
141
  registered key and then calls the method by name, so `renderPanel` where
136
142
  `render` was meant is an extension that installs, activates, and does nothing.
package/dist/base64.js ADDED
@@ -0,0 +1,38 @@
1
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+ const VALUES = new Map([...ALPHABET].map((c, i) => [c, i]));
3
+ function bytesToBase64(bytes) {
4
+ let out = "";
5
+ for (let i = 0; i < bytes.length; i += 3) {
6
+ const a = bytes[i];
7
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
8
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
9
+ const n = a << 16 | b << 8 | c;
10
+ out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
11
+ out += i + 1 < bytes.length ? ALPHABET[n >> 6 & 63] : "=";
12
+ out += i + 2 < bytes.length ? ALPHABET[n & 63] : "=";
13
+ }
14
+ return out;
15
+ }
16
+ function base64ToBytes(text) {
17
+ if (text.length % 4 !== 0) throw new Error("invalid base64");
18
+ const padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
19
+ const bytes = new Uint8Array(text.length / 4 * 3 - padding);
20
+ let o = 0;
21
+ for (let i = 0; i < text.length; i += 4) {
22
+ let n = 0;
23
+ for (let j = 0; j < 4; j++) {
24
+ const ch = text[i + j];
25
+ const value = ch === "=" && i + 4 === text.length && j >= 4 - padding ? 0 : VALUES.get(ch);
26
+ if (value === void 0) throw new Error("invalid base64");
27
+ n = n << 6 | value;
28
+ }
29
+ if (o < bytes.length) bytes[o++] = n >> 16 & 255;
30
+ if (o < bytes.length) bytes[o++] = n >> 8 & 255;
31
+ if (o < bytes.length) bytes[o++] = n & 255;
32
+ }
33
+ return bytes;
34
+ }
35
+ export {
36
+ base64ToBytes,
37
+ bytesToBase64
38
+ };
@@ -0,0 +1,71 @@
1
+ function copyOnWrite(base) {
2
+ return isContainer(base) ? wrap(base) : base;
3
+ }
4
+ function isContainer(value) {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+ function wrap(target) {
8
+ const own = /* @__PURE__ */ new Map();
9
+ const deleted = /* @__PURE__ */ new Set();
10
+ return new Proxy(target, {
11
+ get(t, key) {
12
+ if (own.has(key)) return own.get(key);
13
+ if (deleted.has(key)) return void 0;
14
+ const value = Reflect.get(t, key);
15
+ if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
16
+ const view = wrap(value);
17
+ own.set(key, view);
18
+ return view;
19
+ }
20
+ return value;
21
+ },
22
+ set(_t, key, value) {
23
+ own.set(key, value);
24
+ deleted.delete(key);
25
+ return true;
26
+ },
27
+ has(t, key) {
28
+ if (deleted.has(key)) return false;
29
+ return own.has(key) || key in t;
30
+ },
31
+ deleteProperty(t, key) {
32
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
33
+ if (descriptor && !descriptor.configurable) return false;
34
+ own.delete(key);
35
+ if (descriptor) deleted.add(key);
36
+ return true;
37
+ },
38
+ ownKeys(t) {
39
+ const keys = Reflect.ownKeys(t).filter((key) => !deleted.has(key));
40
+ for (const key of own.keys()) {
41
+ if (!keys.includes(key)) keys.push(key);
42
+ }
43
+ return keys;
44
+ },
45
+ getOwnPropertyDescriptor(t, key) {
46
+ if (deleted.has(key)) return void 0;
47
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
48
+ if (!own.has(key)) return descriptor;
49
+ return descriptor ? { ...descriptor, value: own.get(key) } : { value: own.get(key), writable: true, enumerable: true, configurable: true };
50
+ },
51
+ // The other ways to write. Without these traps `Object.defineProperty`
52
+ // and `Object.freeze` fall through to the base — the held checkpoint —
53
+ // and one such call would enter a candidate into every later verdict, or
54
+ // lock the sheet for the rest of the session.
55
+ defineProperty(_t, key, descriptor) {
56
+ if ("get" in descriptor || "set" in descriptor) return false;
57
+ own.set(key, descriptor.value);
58
+ deleted.delete(key);
59
+ return true;
60
+ },
61
+ preventExtensions() {
62
+ return false;
63
+ },
64
+ setPrototypeOf() {
65
+ return false;
66
+ }
67
+ });
68
+ }
69
+ export {
70
+ copyOnWrite
71
+ };
@@ -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.5.7
1
+ // @ham2k/extension-sdk 0.7.0
2
2
  /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface SvgScene {
4
4
  version: 1;
@@ -116,6 +116,30 @@ export interface PanelSceneEvent {
116
116
  export interface PanelSceneEventResult {
117
117
  values: Record<string, number>;
118
118
  }
119
+ export declare const HOOK_CATEGORIES: readonly [
120
+ "account",
121
+ "activity",
122
+ "adifFields",
123
+ "adifImport",
124
+ "bench",
125
+ "callNotes",
126
+ "command",
127
+ "dataFile",
128
+ "export",
129
+ "form",
130
+ "lookup",
131
+ "lookupService",
132
+ "panel",
133
+ "recentContextLookup",
134
+ "runtime",
135
+ "scoring",
136
+ "settingsPanel",
137
+ "spots",
138
+ "syncTransport",
139
+ "template"
140
+ ];
141
+ export type HookCategory = (typeof HOOK_CATEGORIES)[number];
142
+ export type HookCategoryName = HookCategory | `ref:${string}`;
119
143
  export type CallInfo = {
120
144
  call: string;
121
145
  baseCall?: string;
@@ -394,6 +418,7 @@ export interface LoggingControlDescriptor {
394
418
  key: string;
395
419
  label: string;
396
420
  shortLabel?: string;
421
+ skipFocus?: boolean;
397
422
  icon?: string;
398
423
  color?: string;
399
424
  order?: number;
@@ -634,12 +659,15 @@ export interface ScoreQsosRequest {
634
659
  ref?: Record<string, JSONValue>;
635
660
  resumeFrom?: JSONValue;
636
661
  resumeDay?: number;
662
+ resumeKey?: string;
663
+ checkpointKey?: string;
637
664
  }
638
665
  export interface ScoreQsosResult {
639
666
  qsoScores: Record<string, QsoScoreVerdict>;
640
667
  daySections: DayScoreSummary[];
641
668
  operationSummary: Record<string, ScoreTally>;
642
669
  scoresheet?: JSONValue;
670
+ checkpointHeld?: true;
643
671
  scoresheetDay?: number;
644
672
  }
645
673
  export interface ScoreQsoRequest {
@@ -651,11 +679,13 @@ export interface ScoreQsoRequest {
651
679
  resumeFrom?: JSONValue;
652
680
  resumeDay?: number;
653
681
  qsos?: Record<string, JSONValue>[];
682
+ resumeKey?: string;
654
683
  }
655
684
  export interface QsoScoreNotices {
656
685
  notices: string[];
657
686
  alerts: string[];
658
687
  dupe?: boolean;
688
+ checkpointHeld?: true;
659
689
  }
660
690
  export interface ScoreCandidatesRequest {
661
691
  operation: Record<string, JSONValue>;
@@ -668,8 +698,11 @@ export interface ScoreCandidatesRequest {
668
698
  resumeFrom?: JSONValue;
669
699
  resumeDay?: number;
670
700
  qsos?: Record<string, JSONValue>[];
701
+ resumeKey?: string;
671
702
  }
672
- export type ScoreCandidatesResult = Record<string, QsoScoreNotices>;
703
+ export type ScoreCandidatesResult = Record<string, QsoScoreNotices> & {
704
+ $checkpointHeld?: true;
705
+ };
673
706
  export interface ScoringHook {
674
707
  scoreQsos(args: ScoreQsosRequest, ctx: HookContext): Promise<ScoreQsosResult>;
675
708
  scoreQso?(args: ScoreQsoRequest, ctx: HookContext): Promise<QsoScoreNotices>;
@@ -784,6 +817,7 @@ export interface CommandAction {
784
817
  devBench?: {
785
818
  qsoCount?: number;
786
819
  };
820
+ devLogCat?: Record<string, never>;
787
821
  toggleExperiment?: {
788
822
  token: string;
789
823
  };
@@ -929,8 +963,12 @@ export interface RegisterHookParams {
929
963
  priority?: number;
930
964
  }
931
965
  export interface ActivationApi {
932
- registerHook(category: string, params: RegisterHookParams): void;
966
+ registerHook(category: HookCategoryName, params: RegisterHookParams): void;
933
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
+ };
934
972
  }
935
973
  export interface ExtensionRelevance {
936
974
  entities?: string[];
@@ -952,7 +990,9 @@ export interface ExtensionManifest {
952
990
  category?: string;
953
991
  enabledByDefault?: boolean;
954
992
  hooks?: string[];
993
+ adifFieldsScope?: "always";
955
994
  domains?: string[];
995
+ webSockets?: string[];
956
996
  allowUserAgentOverride?: boolean;
957
997
  requiresLocation?: boolean;
958
998
  requiresRadioRead?: boolean;
@@ -967,6 +1007,8 @@ export interface ExtensionManifest {
967
1007
  }
968
1008
  export interface ExtensionDefinition extends ExtensionManifest {
969
1009
  onActivation(api: ActivationApi): void;
1010
+ onShow?(): void | Promise<void>;
1011
+ onHide?(): void | Promise<void>;
970
1012
  }
971
1013
  export interface FetchOptions {
972
1014
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -997,6 +1039,29 @@ export interface FetchResponse {
997
1039
  status: number;
998
1040
  body: string;
999
1041
  }
1042
+ export interface WebSocketOptions {
1043
+ protocols?: string[];
1044
+ }
1045
+ export interface WebSocketCloseEvent {
1046
+ code: number;
1047
+ reason: string;
1048
+ wasClean: boolean;
1049
+ }
1050
+ export interface ExtensionWebSocket {
1051
+ readonly url: string;
1052
+ readonly readyState: 0 | 1 | 2 | 3;
1053
+ readonly protocol: string;
1054
+ onopen: (() => void) | null;
1055
+ onmessage: ((event: {
1056
+ data: string | ArrayBuffer;
1057
+ }) => void) | null;
1058
+ onerror: ((event: {
1059
+ message: string;
1060
+ }) => void) | null;
1061
+ onclose: ((event: WebSocketCloseEvent) => void) | null;
1062
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
1063
+ close(code?: number, reason?: string): void;
1064
+ }
1000
1065
  export interface DeviceLocation {
1001
1066
  latitude: number;
1002
1067
  longitude: number;
@@ -1376,6 +1441,10 @@ export interface PanelHook {
1376
1441
  event: PanelSceneEvent;
1377
1442
  }, ctx: HookContext): Promise<PanelSceneEventResult>;
1378
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;
1379
1448
  /**
1380
1449
  * The standard per-extension translator: takes the extension's i18next
1381
1450
  * resources once and returns a `tFor(ctx)` that hooks call with their
@@ -1461,6 +1530,8 @@ export declare function isUnexpectedSideband(mode: string | undefined | null, {
1461
1530
  freqKHz?: number;
1462
1531
  band?: string;
1463
1532
  }): boolean;
1533
+ export declare const RESUME_MISSING = "resume-missing";
1534
+ export declare const CHECKPOINT_HELD_KEY = "$checkpointHeld";
1464
1535
  export interface ContestScorerOptions {
1465
1536
  scope?: ScoringScope;
1466
1537
  }
@@ -1658,25 +1729,35 @@ export interface TemplateContextArgs {
1658
1729
  log?: LogValues;
1659
1730
  appName?: string;
1660
1731
  atMillis?: number;
1732
+ keyer?: {
1733
+ messages: string[];
1734
+ current?: number;
1735
+ };
1661
1736
  nowMillis?: number;
1662
1737
  }
1663
1738
  export declare function templateContext(args: TemplateContextArgs): Record<string, unknown>;
1664
1739
  export declare function isTestOperation(stationCall: JSONValue | undefined): boolean;
1665
1740
  export declare function defineExtension(def: ExtensionDefinition): void;
1666
1741
  export declare const hooks: {
1667
- invokeAll(category: string, method: string, args: unknown, online?: boolean): Promise<{
1742
+ invokeAll(category: HookCategoryName, method: string, args: unknown, online?: boolean): Promise<{
1743
+ key: string;
1744
+ ok: boolean;
1745
+ value?: unknown;
1746
+ error?: string;
1747
+ }[]>;
1748
+ invokeOne(category: HookCategoryName, key: string, method: string, args: unknown, online?: boolean): Promise<{
1668
1749
  key: string;
1669
1750
  ok: boolean;
1670
1751
  value?: unknown;
1671
1752
  error?: string;
1672
1753
  }[]>;
1673
- invokeOne(category: string, key: string, method: string, args: unknown, online?: boolean): Promise<{
1754
+ invokeForRefs(category: HookCategoryName, method: string, args: unknown, refs: unknown[], online?: boolean): Promise<{
1674
1755
  key: string;
1675
1756
  ok: boolean;
1676
1757
  value?: unknown;
1677
1758
  error?: string;
1678
1759
  }[]>;
1679
- invokeAllSequential(category: string, method: string, initialArgs: unknown, fold: (args: unknown, hookResult: unknown, hookKey: string) => unknown, online?: boolean): Promise<{
1760
+ invokeAllSequential(category: HookCategoryName, method: string, initialArgs: unknown, fold: (args: unknown, hookResult: unknown, hookKey: string) => unknown, online?: boolean): Promise<{
1680
1761
  key: string;
1681
1762
  ok: boolean;
1682
1763
  value?: unknown;
@@ -1693,6 +1774,7 @@ export declare const host: {
1693
1774
  }): Promise<PanelRadioTuneResult>;
1694
1775
  tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
1695
1776
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
1777
+ webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
1696
1778
  kvGet(key: string): Promise<JSONValue | null>;
1697
1779
  getLocation(): Promise<DeviceLocation | null>;
1698
1780
  getVersionInfo(): Promise<VersionInfo>;
@@ -1717,6 +1799,10 @@ export declare const host: {
1717
1799
  dismissNotice(key: string): Promise<void>;
1718
1800
  };
1719
1801
  log(message: string): void;
1802
+ setTimeout: typeof setTimeout$1;
1803
+ setInterval: typeof setInterval$1;
1804
+ clearTimeout: typeof clearTimeout$1;
1805
+ clearInterval: typeof clearInterval$1;
1720
1806
  updateInterpretation(input: string, interpretation: Record<string, unknown>): void;
1721
1807
  };
1722
1808
 
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { base64ToBytes, bytesToBase64 } from "./base64.js";
2
+ import { adoptExtensionTimers, clearInterval, clearTimeout, setInterval, setTimeout } from "./timers.js";
1
3
  export * from "./types.js";
4
+ export * from "./hookCategories.js";
2
5
  export * from "./svgScene.js";
3
6
  export * from "./i18n.js";
4
7
  export * from "./dxcc.js";
@@ -32,12 +35,97 @@ function defineExtension(def) {
32
35
  currentExtensionKey = def.key;
33
36
  kernel().defineExtension({
34
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()),
35
43
  onActivation(api) {
36
44
  if (typeof api.hostCall === "function") privilegedHostCalls[def.key] = api.hostCall;
45
+ adoptExtensionTimers(def.key, currentExtensionKey, api.timers);
37
46
  return def.onActivation(api);
38
47
  }
39
48
  });
40
49
  }
50
+ const MAX_FRAME_SIZE = 1024 * 1024;
51
+ function utf8Length(text) {
52
+ let length = 0;
53
+ for (const ch of text) {
54
+ const cp = ch.codePointAt(0);
55
+ length += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
56
+ }
57
+ return length;
58
+ }
59
+ function openWebSocket(call, url, options) {
60
+ let readyState = 0;
61
+ let protocol = "";
62
+ const failed = (what) => (e) => kernel().log(`webSocket ${what} on ${url} failed: ${e instanceof Error ? e.message : String(e)}`);
63
+ const socket = {
64
+ url,
65
+ get readyState() {
66
+ return readyState;
67
+ },
68
+ get protocol() {
69
+ return protocol;
70
+ },
71
+ onopen: null,
72
+ onmessage: null,
73
+ onerror: null,
74
+ onclose: null,
75
+ send(data) {
76
+ if (readyState !== 1) throw new Error("WebSocket is not open");
77
+ let frame;
78
+ if (typeof data === "string") {
79
+ if (data.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
80
+ frame = { data };
81
+ } else {
82
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
83
+ if (bytes.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
84
+ frame = { binary: bytesToBase64(bytes) };
85
+ }
86
+ call("webSocketSend", { socketId, ...frame }).catch(failed("send"));
87
+ },
88
+ close(code, reason) {
89
+ if (code !== void 0 && (!Number.isInteger(code) || code !== 1e3 && (code < 3e3 || code > 4999))) {
90
+ throw new Error("close code must be 1000 or 3000-4999");
91
+ }
92
+ if (reason !== void 0 && (typeof reason !== "string" || utf8Length(reason) > 123)) {
93
+ throw new Error("close reason must be a string of at most 123 UTF-8 bytes");
94
+ }
95
+ if (readyState >= 2) return;
96
+ const before = readyState;
97
+ readyState = 2;
98
+ call("webSocketClose", { socketId, ...code !== void 0 ? { code } : {}, ...reason !== void 0 ? { reason } : {} }).catch((e) => {
99
+ if (readyState === 2) readyState = before;
100
+ failed("close")(e);
101
+ });
102
+ }
103
+ };
104
+ if (typeof kernel().registerSocket !== "function") throw new Error("host.webSocket needs a newer version of HaLo");
105
+ const socketId = kernel().registerSocket((event) => {
106
+ switch (event.type) {
107
+ case "open":
108
+ if (readyState !== 0) return;
109
+ readyState = 1;
110
+ protocol = String(event.protocol ?? "");
111
+ return socket.onopen?.();
112
+ case "message": {
113
+ const data = typeof event.binary === "string" ? base64ToBytes(event.binary).buffer : String(event.data);
114
+ return socket.onmessage?.({ data });
115
+ }
116
+ case "error":
117
+ return socket.onerror?.({ message: String(event.message ?? "") });
118
+ case "close":
119
+ readyState = 3;
120
+ return socket.onclose?.({ code: Number(event.code), reason: String(event.reason ?? ""), wasClean: event.wasClean === true });
121
+ }
122
+ });
123
+ call("webSocketOpen", { socketId, url, ...options?.protocols ? { protocols: options.protocols } : {} }).catch((e) => {
124
+ kernel().socketEvent(socketId, JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) }));
125
+ kernel().socketEvent(socketId, JSON.stringify({ type: "close", code: 1006, reason: "", wasClean: false }));
126
+ });
127
+ return socket;
128
+ }
41
129
  const hooks = {
42
130
  /// EVERY hook in the category — including the CALLER's own. A hook that
43
131
  /// delegates within its own category therefore re-enters itself; use
@@ -53,6 +141,15 @@ const hooks = {
53
141
  invokeOne(category, key, method, args, online) {
54
142
  return kernel().invokeLocal(category, method, args, online, key);
55
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
+ },
56
153
  /// Like `invokeAll`, but calls hooks one at a time in priority order,
57
154
  /// threading each result into the next call's args via `fold` — needed
58
155
  /// when later hooks must see earlier hooks' contributions.
@@ -76,6 +173,11 @@ const host = {
76
173
  async fetch(url, options) {
77
174
  return await hostCallFn()("fetch", { url, ...options });
78
175
  },
176
+ /// A WebSocket to a host the manifest's `webSockets` lists — see
177
+ /// `ExtensionWebSocket`. Returns at once, connecting, as a browser's does.
178
+ webSocket(url, options) {
179
+ return openWebSocket(hostCallFn(), url, options);
180
+ },
79
181
  async kvGet(key) {
80
182
  return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
81
183
  },
@@ -233,6 +335,14 @@ const host = {
233
335
  log(message) {
234
336
  kernel().log(`[${currentExtensionKey}] ${message}`);
235
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,
236
346
  /// Pushes a later CommandInterpretation for the same `input` a `command`
237
347
  /// hook's `interpret()` was called with — see CommandHook's doc. One-way,
238
348
  /// like `log`: call it, don't await anything back.
package/dist/scoring.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { copyOnWrite } from "./copyOnWrite.js";
1
2
  const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
3
+ const HELD_CHECKPOINTS = 4;
4
+ const RESUME_MISSING = "resume-missing";
5
+ const CHECKPOINT_HELD_KEY = "$checkpointHeld";
2
6
  function copySheet(sheet) {
3
7
  return JSON.parse(JSON.stringify(sheet));
4
8
  }
5
- function openCheckpoint(resumeFrom, resumeDay) {
6
- return { sheet: copySheet(resumeFrom), day: typeof resumeDay === "number" ? resumeDay : void 0 };
9
+ function operationUuidOf(operation) {
10
+ return typeof operation.uuid === "string" ? operation.uuid : "";
7
11
  }
8
12
  function dayOf(qso) {
9
13
  const millis = Number(qso.startAtMillis ?? 0);
@@ -25,6 +29,26 @@ function candidateAsOfNow(qso) {
25
29
  function contestScorer(scorer, options = {}) {
26
30
  const scope = options.scope ?? "always";
27
31
  const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
32
+ const held = /* @__PURE__ */ new Map();
33
+ function hold(operationUuid, key, sheet, day) {
34
+ held.delete(operationUuid);
35
+ held.set(operationUuid, { key, sheet, day });
36
+ while (held.size > HELD_CHECKPOINTS) held.delete(held.keys().next().value);
37
+ }
38
+ function resolveCheckpoint(args) {
39
+ const operationUuid = operationUuidOf(args.operation);
40
+ const key = typeof args.resumeKey === "string" ? args.resumeKey : void 0;
41
+ if (args.resumeFrom !== void 0) {
42
+ const day = typeof args.resumeDay === "number" ? args.resumeDay : void 0;
43
+ const sheet = args.resumeFrom;
44
+ if (key !== void 0) hold(operationUuid, key, sheet, day);
45
+ return { key: key ?? "", sheet, day };
46
+ }
47
+ if (key === void 0) return void 0;
48
+ const entry = held.get(operationUuid);
49
+ if (entry === void 0 || entry.key !== key) throw new Error(`${RESUME_MISSING}: ${key}`);
50
+ return entry;
51
+ }
28
52
  function refOfScopedType(operation) {
29
53
  if (!scopedRefTypes) return void 0;
30
54
  const refs = operation.refs ?? [];
@@ -66,11 +90,12 @@ function contestScorer(scorer, options = {}) {
66
90
  return { sheet, day: currentDay };
67
91
  }
68
92
  async function liveScoresheet(args, ctx, excludeUuid) {
69
- if (args.resumeFrom !== void 0) {
70
- const { sheet, day } = openCheckpoint(args.resumeFrom, args.resumeDay);
93
+ const resumed = resolveCheckpoint(args);
94
+ if (resumed !== void 0) {
95
+ const sheet = copyOnWrite(resumed.sheet);
71
96
  const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
72
- if (tail.length === 0) return { sheet, day };
73
- return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
97
+ if (tail.length === 0) return { sheet, day: resumed.day };
98
+ return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, resumed.day);
74
99
  }
75
100
  const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
76
101
  const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
@@ -81,8 +106,8 @@ function contestScorer(scorer, options = {}) {
81
106
  scope,
82
107
  async scoreQsos(args, ctx) {
83
108
  const qsos = chronological(args.qsos);
84
- const resumed = args.resumeFrom !== void 0 ? openCheckpoint(args.resumeFrom, args.resumeDay) : void 0;
85
- const start = resumed?.sheet ?? scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
109
+ const resumed = resolveCheckpoint(args);
110
+ const start = resumed !== void 0 ? copySheet(resumed.sheet) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
86
111
  const qsoScores = {};
87
112
  const daySections = [];
88
113
  const { sheet: end, day: endDay } = fold(
@@ -103,12 +128,15 @@ function contestScorer(scorer, options = {}) {
103
128
  args.segments,
104
129
  resumed?.day
105
130
  );
131
+ const checkpointHeld = typeof args.checkpointKey === "string";
132
+ if (checkpointHeld) hold(operationUuidOf(args.operation), args.checkpointKey, end, endDay);
106
133
  return {
107
134
  qsoScores,
108
135
  daySections,
109
136
  operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
110
137
  scoresheet: end,
111
- ...endDay !== void 0 && { scoresheetDay: endDay }
138
+ ...endDay !== void 0 && { scoresheetDay: endDay },
139
+ ...checkpointHeld && { checkpointHeld: true }
112
140
  };
113
141
  },
114
142
  /// Live path: score the candidate against the log so far and report only
@@ -117,26 +145,35 @@ function contestScorer(scorer, options = {}) {
117
145
  async scoreQso(args, ctx) {
118
146
  const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
119
147
  const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
120
- return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
148
+ const checkpointHeld = typeof args.resumeKey === "string";
149
+ return {
150
+ notices: score.notices ?? [],
151
+ alerts: score.alerts ?? [],
152
+ ...score.dupe !== void 0 && { dupe: score.dupe },
153
+ ...checkpointHeld && { checkpointHeld: true }
154
+ };
121
155
  },
122
156
  /// The same live judgement as `scoreQso`, for many candidates at once —
123
157
  /// the Spots Panel annotating every spot on screen. The log is folded ONCE
124
- /// and each candidate scored against its own copy of the result, so a
158
+ /// and each candidate scored against its own view of the result, so a
125
159
  /// candidate can neither see nor become a duplicate of another (both spots
126
160
  /// for the same unworked call read as unworked, which is what an operator
127
161
  /// scanning a band expects).
128
162
  ///
129
- /// The copy is per candidate rather than per call because
163
+ /// The view is per candidate rather than per call because
130
164
  /// `ContestScorer.scoreQso` may mutate the scoresheet it is handed — the
131
165
  /// contract that makes the batch fold linear. Without it the first
132
- /// candidate's call would count as worked for every candidate after it.
166
+ /// candidate's call would count as worked for every candidate after it. A
167
+ /// view, not a copy: copying a long log's worked-call map forty times over
168
+ /// was the cost of the whole pass.
133
169
  async scoreCandidates(args, ctx) {
134
170
  const { sheet: base } = await liveScoresheet(args, ctx);
135
171
  const result = {};
172
+ if (typeof args.resumeKey === "string") result[CHECKPOINT_HELD_KEY] = true;
136
173
  for (const candidate of args.candidates) {
137
174
  const { score } = scorer.scoreQso(
138
175
  {
139
- scoresheet: copySheet(base),
176
+ scoresheet: copyOnWrite(base),
140
177
  qso: candidateAsOfNow(candidate.qso),
141
178
  operation: args.operation,
142
179
  ref: args.ref,
@@ -158,6 +195,8 @@ function tally(key, forScope, total, extra = {}) {
158
195
  return { key, for: forScope, total, ...extra };
159
196
  }
160
197
  export {
198
+ CHECKPOINT_HELD_KEY,
199
+ RESUME_MISSING,
161
200
  contestScorer,
162
201
  tally
163
202
  };