@ham2k/extension-sdk 0.5.7 → 0.6.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 +1 -0
- package/dist/base64.js +38 -0
- package/dist/copyOnWrite.js +71 -0
- package/dist/index.d.ts +38 -2
- package/dist/index.js +85 -0
- package/dist/scoring.js +53 -14
- package/docs/distribution.md +8 -5
- package/docs/hooks.md +53 -22
- package/docs/templates.md +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -103,6 +103,7 @@ 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
|
|
106
107
|
await host.kvGet('key') // storage, namespaced to your extension
|
|
107
108
|
await host.kvSet('key', value)
|
|
108
109
|
await host.showForm(form) // ask the user something
|
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
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
1
|
+
// @ham2k/extension-sdk 0.6.0
|
|
2
2
|
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface SvgScene {
|
|
4
4
|
version: 1;
|
|
@@ -394,6 +394,7 @@ export interface LoggingControlDescriptor {
|
|
|
394
394
|
key: string;
|
|
395
395
|
label: string;
|
|
396
396
|
shortLabel?: string;
|
|
397
|
+
skipFocus?: boolean;
|
|
397
398
|
icon?: string;
|
|
398
399
|
color?: string;
|
|
399
400
|
order?: number;
|
|
@@ -634,12 +635,15 @@ export interface ScoreQsosRequest {
|
|
|
634
635
|
ref?: Record<string, JSONValue>;
|
|
635
636
|
resumeFrom?: JSONValue;
|
|
636
637
|
resumeDay?: number;
|
|
638
|
+
resumeKey?: string;
|
|
639
|
+
checkpointKey?: string;
|
|
637
640
|
}
|
|
638
641
|
export interface ScoreQsosResult {
|
|
639
642
|
qsoScores: Record<string, QsoScoreVerdict>;
|
|
640
643
|
daySections: DayScoreSummary[];
|
|
641
644
|
operationSummary: Record<string, ScoreTally>;
|
|
642
645
|
scoresheet?: JSONValue;
|
|
646
|
+
checkpointHeld?: true;
|
|
643
647
|
scoresheetDay?: number;
|
|
644
648
|
}
|
|
645
649
|
export interface ScoreQsoRequest {
|
|
@@ -651,11 +655,13 @@ export interface ScoreQsoRequest {
|
|
|
651
655
|
resumeFrom?: JSONValue;
|
|
652
656
|
resumeDay?: number;
|
|
653
657
|
qsos?: Record<string, JSONValue>[];
|
|
658
|
+
resumeKey?: string;
|
|
654
659
|
}
|
|
655
660
|
export interface QsoScoreNotices {
|
|
656
661
|
notices: string[];
|
|
657
662
|
alerts: string[];
|
|
658
663
|
dupe?: boolean;
|
|
664
|
+
checkpointHeld?: true;
|
|
659
665
|
}
|
|
660
666
|
export interface ScoreCandidatesRequest {
|
|
661
667
|
operation: Record<string, JSONValue>;
|
|
@@ -668,8 +674,11 @@ export interface ScoreCandidatesRequest {
|
|
|
668
674
|
resumeFrom?: JSONValue;
|
|
669
675
|
resumeDay?: number;
|
|
670
676
|
qsos?: Record<string, JSONValue>[];
|
|
677
|
+
resumeKey?: string;
|
|
671
678
|
}
|
|
672
|
-
export type ScoreCandidatesResult = Record<string, QsoScoreNotices
|
|
679
|
+
export type ScoreCandidatesResult = Record<string, QsoScoreNotices> & {
|
|
680
|
+
$checkpointHeld?: true;
|
|
681
|
+
};
|
|
673
682
|
export interface ScoringHook {
|
|
674
683
|
scoreQsos(args: ScoreQsosRequest, ctx: HookContext): Promise<ScoreQsosResult>;
|
|
675
684
|
scoreQso?(args: ScoreQsoRequest, ctx: HookContext): Promise<QsoScoreNotices>;
|
|
@@ -953,6 +962,7 @@ export interface ExtensionManifest {
|
|
|
953
962
|
enabledByDefault?: boolean;
|
|
954
963
|
hooks?: string[];
|
|
955
964
|
domains?: string[];
|
|
965
|
+
webSockets?: string[];
|
|
956
966
|
allowUserAgentOverride?: boolean;
|
|
957
967
|
requiresLocation?: boolean;
|
|
958
968
|
requiresRadioRead?: boolean;
|
|
@@ -997,6 +1007,29 @@ export interface FetchResponse {
|
|
|
997
1007
|
status: number;
|
|
998
1008
|
body: string;
|
|
999
1009
|
}
|
|
1010
|
+
export interface WebSocketOptions {
|
|
1011
|
+
protocols?: string[];
|
|
1012
|
+
}
|
|
1013
|
+
export interface WebSocketCloseEvent {
|
|
1014
|
+
code: number;
|
|
1015
|
+
reason: string;
|
|
1016
|
+
wasClean: boolean;
|
|
1017
|
+
}
|
|
1018
|
+
export interface ExtensionWebSocket {
|
|
1019
|
+
readonly url: string;
|
|
1020
|
+
readonly readyState: 0 | 1 | 2 | 3;
|
|
1021
|
+
readonly protocol: string;
|
|
1022
|
+
onopen: (() => void) | null;
|
|
1023
|
+
onmessage: ((event: {
|
|
1024
|
+
data: string | ArrayBuffer;
|
|
1025
|
+
}) => void) | null;
|
|
1026
|
+
onerror: ((event: {
|
|
1027
|
+
message: string;
|
|
1028
|
+
}) => void) | null;
|
|
1029
|
+
onclose: ((event: WebSocketCloseEvent) => void) | null;
|
|
1030
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
1031
|
+
close(code?: number, reason?: string): void;
|
|
1032
|
+
}
|
|
1000
1033
|
export interface DeviceLocation {
|
|
1001
1034
|
latitude: number;
|
|
1002
1035
|
longitude: number;
|
|
@@ -1461,6 +1494,8 @@ export declare function isUnexpectedSideband(mode: string | undefined | null, {
|
|
|
1461
1494
|
freqKHz?: number;
|
|
1462
1495
|
band?: string;
|
|
1463
1496
|
}): boolean;
|
|
1497
|
+
export declare const RESUME_MISSING = "resume-missing";
|
|
1498
|
+
export declare const CHECKPOINT_HELD_KEY = "$checkpointHeld";
|
|
1464
1499
|
export interface ContestScorerOptions {
|
|
1465
1500
|
scope?: ScoringScope;
|
|
1466
1501
|
}
|
|
@@ -1693,6 +1728,7 @@ export declare const host: {
|
|
|
1693
1728
|
}): Promise<PanelRadioTuneResult>;
|
|
1694
1729
|
tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
|
|
1695
1730
|
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
1731
|
+
webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
|
|
1696
1732
|
kvGet(key: string): Promise<JSONValue | null>;
|
|
1697
1733
|
getLocation(): Promise<DeviceLocation | null>;
|
|
1698
1734
|
getVersionInfo(): Promise<VersionInfo>;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
1
2
|
export * from "./types.js";
|
|
2
3
|
export * from "./svgScene.js";
|
|
3
4
|
export * from "./i18n.js";
|
|
@@ -38,6 +39,85 @@ function defineExtension(def) {
|
|
|
38
39
|
}
|
|
39
40
|
});
|
|
40
41
|
}
|
|
42
|
+
const MAX_FRAME_SIZE = 1024 * 1024;
|
|
43
|
+
function utf8Length(text) {
|
|
44
|
+
let length = 0;
|
|
45
|
+
for (const ch of text) {
|
|
46
|
+
const cp = ch.codePointAt(0);
|
|
47
|
+
length += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
|
|
48
|
+
}
|
|
49
|
+
return length;
|
|
50
|
+
}
|
|
51
|
+
function openWebSocket(call, url, options) {
|
|
52
|
+
let readyState = 0;
|
|
53
|
+
let protocol = "";
|
|
54
|
+
const failed = (what) => (e) => kernel().log(`webSocket ${what} on ${url} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
55
|
+
const socket = {
|
|
56
|
+
url,
|
|
57
|
+
get readyState() {
|
|
58
|
+
return readyState;
|
|
59
|
+
},
|
|
60
|
+
get protocol() {
|
|
61
|
+
return protocol;
|
|
62
|
+
},
|
|
63
|
+
onopen: null,
|
|
64
|
+
onmessage: null,
|
|
65
|
+
onerror: null,
|
|
66
|
+
onclose: null,
|
|
67
|
+
send(data) {
|
|
68
|
+
if (readyState !== 1) throw new Error("WebSocket is not open");
|
|
69
|
+
let frame;
|
|
70
|
+
if (typeof data === "string") {
|
|
71
|
+
if (data.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
|
|
72
|
+
frame = { data };
|
|
73
|
+
} else {
|
|
74
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
75
|
+
if (bytes.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
|
|
76
|
+
frame = { binary: bytesToBase64(bytes) };
|
|
77
|
+
}
|
|
78
|
+
call("webSocketSend", { socketId, ...frame }).catch(failed("send"));
|
|
79
|
+
},
|
|
80
|
+
close(code, reason) {
|
|
81
|
+
if (code !== void 0 && (!Number.isInteger(code) || code !== 1e3 && (code < 3e3 || code > 4999))) {
|
|
82
|
+
throw new Error("close code must be 1000 or 3000-4999");
|
|
83
|
+
}
|
|
84
|
+
if (reason !== void 0 && (typeof reason !== "string" || utf8Length(reason) > 123)) {
|
|
85
|
+
throw new Error("close reason must be a string of at most 123 UTF-8 bytes");
|
|
86
|
+
}
|
|
87
|
+
if (readyState >= 2) return;
|
|
88
|
+
const before = readyState;
|
|
89
|
+
readyState = 2;
|
|
90
|
+
call("webSocketClose", { socketId, ...code !== void 0 ? { code } : {}, ...reason !== void 0 ? { reason } : {} }).catch((e) => {
|
|
91
|
+
if (readyState === 2) readyState = before;
|
|
92
|
+
failed("close")(e);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (typeof kernel().registerSocket !== "function") throw new Error("host.webSocket needs a newer version of HaLo");
|
|
97
|
+
const socketId = kernel().registerSocket((event) => {
|
|
98
|
+
switch (event.type) {
|
|
99
|
+
case "open":
|
|
100
|
+
if (readyState !== 0) return;
|
|
101
|
+
readyState = 1;
|
|
102
|
+
protocol = String(event.protocol ?? "");
|
|
103
|
+
return socket.onopen?.();
|
|
104
|
+
case "message": {
|
|
105
|
+
const data = typeof event.binary === "string" ? base64ToBytes(event.binary).buffer : String(event.data);
|
|
106
|
+
return socket.onmessage?.({ data });
|
|
107
|
+
}
|
|
108
|
+
case "error":
|
|
109
|
+
return socket.onerror?.({ message: String(event.message ?? "") });
|
|
110
|
+
case "close":
|
|
111
|
+
readyState = 3;
|
|
112
|
+
return socket.onclose?.({ code: Number(event.code), reason: String(event.reason ?? ""), wasClean: event.wasClean === true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
call("webSocketOpen", { socketId, url, ...options?.protocols ? { protocols: options.protocols } : {} }).catch((e) => {
|
|
116
|
+
kernel().socketEvent(socketId, JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
117
|
+
kernel().socketEvent(socketId, JSON.stringify({ type: "close", code: 1006, reason: "", wasClean: false }));
|
|
118
|
+
});
|
|
119
|
+
return socket;
|
|
120
|
+
}
|
|
41
121
|
const hooks = {
|
|
42
122
|
/// EVERY hook in the category — including the CALLER's own. A hook that
|
|
43
123
|
/// delegates within its own category therefore re-enters itself; use
|
|
@@ -76,6 +156,11 @@ const host = {
|
|
|
76
156
|
async fetch(url, options) {
|
|
77
157
|
return await hostCallFn()("fetch", { url, ...options });
|
|
78
158
|
},
|
|
159
|
+
/// A WebSocket to a host the manifest's `webSockets` lists — see
|
|
160
|
+
/// `ExtensionWebSocket`. Returns at once, connecting, as a browser's does.
|
|
161
|
+
webSocket(url, options) {
|
|
162
|
+
return openWebSocket(hostCallFn(), url, options);
|
|
163
|
+
},
|
|
79
164
|
async kvGet(key) {
|
|
80
165
|
return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
|
|
81
166
|
},
|
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
|
|
6
|
-
return
|
|
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
|
-
|
|
70
|
-
|
|
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 =
|
|
85
|
-
const start = resumed
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
};
|
package/docs/distribution.md
CHANGED
|
@@ -37,7 +37,7 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
37
37
|
"version": "1.2.3",
|
|
38
38
|
"description": "Notes about the stations I work",
|
|
39
39
|
"category": "dashboard",
|
|
40
|
-
"api":
|
|
40
|
+
"api": 2,
|
|
41
41
|
"domains": ["notes.example.org"],
|
|
42
42
|
"icon": "note-text"
|
|
43
43
|
}
|
|
@@ -45,7 +45,10 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
45
45
|
|
|
46
46
|
`api` is the extension API the bundle was built against. The host refuses one
|
|
47
47
|
it does not speak, so an older app meeting a newer bundle says so instead of
|
|
48
|
-
failing somewhere deep in a hook
|
|
48
|
+
failing somewhere deep in a hook — and the catalog does not list it for that
|
|
49
|
+
app at all. Declare the lowest version that has what the bundle uses, so it
|
|
50
|
+
reaches every app that can run it: 2 adds `host.webSocket`, and the packer
|
|
51
|
+
refuses a manifest declaring `webSockets` under anything less.
|
|
49
52
|
|
|
50
53
|
Three fields are **refused** in a distributed bundle, and it is worth
|
|
51
54
|
understanding why:
|
|
@@ -254,7 +257,7 @@ It reports every problem at once rather than one per run:
|
|
|
254
257
|
h2kext-pack: ./build is not ready to package:
|
|
255
258
|
manifest.json: missing required field 'name'
|
|
256
259
|
manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
|
|
257
|
-
manifest.json: missing 'api' — declare the extension API this was built against (currently
|
|
260
|
+
manifest.json: missing 'api' — declare the extension API this was built against (currently 2)
|
|
258
261
|
```
|
|
259
262
|
|
|
260
263
|
`--force-name` waives the naming convention below. It exists for Ham2K's own
|
|
@@ -318,8 +321,8 @@ needs one degrades the same way it does in a build without that value
|
|
|
318
321
|
configured.
|
|
319
322
|
|
|
320
323
|
The sandbox is otherwise the same one the built-in extensions run in — no
|
|
321
|
-
filesystem, no network beyond the `domains`
|
|
322
|
-
approved, no timers.
|
|
324
|
+
filesystem, no network beyond the `domains` and `webSockets` the manifest
|
|
325
|
+
declares and the user approved, no timers.
|
|
323
326
|
|
|
324
327
|
## Installing one
|
|
325
328
|
|
package/docs/hooks.md
CHANGED
|
@@ -499,11 +499,13 @@ interface ActivityHook {
|
|
|
499
499
|
suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
|
|
500
500
|
processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
|
|
501
501
|
}
|
|
502
|
-
// LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?,
|
|
503
|
-
// optionType?, allowsMultiple?, editable?, input}
|
|
502
|
+
// LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
|
|
503
|
+
// order?, optionType?, allowsMultiple?, editable?, input}
|
|
504
504
|
// `shortLabel` is the same name in fewer characters, for a field the logging
|
|
505
505
|
// row has squeezed below its label's width. The core measures and picks; the
|
|
506
506
|
// extension only says what the short form is.
|
|
507
|
+
// `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
|
|
508
|
+
// the arrow keys still reach it. Every shipped contest sets it on Our Serial.
|
|
507
509
|
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
|
|
508
510
|
// ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
|
|
509
511
|
|
|
@@ -1161,8 +1163,10 @@ standing beside your credit, on the one row that can show only one.
|
|
|
1161
1163
|
A scorer's internal *rules* stay private to its scoresheet: contests don't
|
|
1162
1164
|
share a rule vocabulary, so only notices, alerts, points and summary tallies
|
|
1163
1165
|
cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
|
|
1164
|
-
return it; copying accumulated state per QSO is quadratic
|
|
1165
|
-
copies
|
|
1166
|
+
return it; copying accumulated state per QSO is quadratic. The batch pass
|
|
1167
|
+
copies the checkpoint it resumes from, once; the live paths hand the scorer a
|
|
1168
|
+
copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
|
|
1169
|
+
writes land in the view and the held sheet is never touched.
|
|
1166
1170
|
|
|
1167
1171
|
```ts
|
|
1168
1172
|
// The wire shape the harness produces, for reference:
|
|
@@ -1171,17 +1175,19 @@ interface ScoringHook {
|
|
|
1171
1175
|
scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
|
|
1172
1176
|
scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
|
|
1173
1177
|
}
|
|
1174
|
-
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
|
|
1178
|
+
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
|
|
1175
1179
|
// ScoreQsosResult: {
|
|
1176
1180
|
// qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
|
|
1177
1181
|
// daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
|
|
1178
1182
|
// operationSummary: Record<tallyKey, ScoreTally>,
|
|
1179
1183
|
// scoresheet, // checkpoint to resume from
|
|
1180
1184
|
// scoresheetDay?, // the day it ends on, handed back as resumeDay
|
|
1185
|
+
// checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
|
|
1181
1186
|
// }
|
|
1182
|
-
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1187
|
+
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1188
|
+
// QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
|
|
1189
|
+
// ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1190
|
+
// ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
|
|
1185
1191
|
```
|
|
1186
1192
|
|
|
1187
1193
|
All three arrive already implemented by `contestScorer()`; a scorer writes the
|
|
@@ -1192,23 +1198,48 @@ both live paths for free.
|
|
|
1192
1198
|
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1193
1199
|
automatic) reaches the scorer stamped with the current time on both live
|
|
1194
1200
|
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1195
|
-
candidate that states a time keeps it. On both live paths a
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1199
|
-
scores the candidate against the result. The host sends a
|
|
1200
|
-
it can prove the log is what the checkpoint folded plus
|
|
1201
|
-
otherwise the harness reads the log through `ctx.getQsos`
|
|
1202
|
-
The batched `scoreQsos` resumes the same way: after the
|
|
1203
|
-
session, an append arrives as
|
|
1204
|
-
their verdicts come back.
|
|
1201
|
+
candidate that states a time keeps it. On both live paths a checkpoint, with
|
|
1202
|
+
the `qsos` logged since it, stands in for the whole-log read: the harness
|
|
1203
|
+
views the checkpoint copy-on-write, replays that tail onto the view
|
|
1204
|
+
(continuing the day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1205
|
+
mid-day) and scores the candidate against the result. The host sends a
|
|
1206
|
+
checkpoint only when it can prove the log is what the checkpoint folded plus
|
|
1207
|
+
appended contacts; otherwise the harness reads the log through `ctx.getQsos`
|
|
1208
|
+
and folds it cold. The batched `scoreQsos` resumes the same way: after the
|
|
1209
|
+
first pass of a session, an append arrives as a checkpoint plus the appended
|
|
1210
|
+
contacts, and only their verdicts come back.
|
|
1211
|
+
|
|
1212
|
+
**A checkpoint is held in the runtime and named, not sent, once it is
|
|
1213
|
+
there.** A sheet the size of a long log's worked-call map crossing the bridge
|
|
1214
|
+
on every keystroke would be most of the keystroke's cost. So the host names
|
|
1215
|
+
each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
|
|
1216
|
+
the batch pass produces it, and the harness keeps the end sheet under that
|
|
1217
|
+
name — one per operation, the last few operations. A live call, or the next
|
|
1218
|
+
append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
|
|
1219
|
+
`resumeKey` is filed under it too. A name the harness does not hold — a
|
|
1220
|
+
runtime restarted since, or one operation more than it keeps — is an error
|
|
1221
|
+
(`resume-missing`), never a silent cold fold, and the host answers it by
|
|
1222
|
+
sending the sheet once more. The host names a checkpoint only to a harness
|
|
1223
|
+
that has said `checkpointHeld: true` for it; a scorer built on an SDK without
|
|
1224
|
+
the cache never says so and keeps receiving the sheet. A `scoreCandidates`
|
|
1225
|
+
answer says it under the reserved key `$checkpointHeld`, since its other
|
|
1226
|
+
entries are the caller's candidate keys — a candidate key never starts with
|
|
1227
|
+
`$`.
|
|
1228
|
+
|
|
1229
|
+
The view a scorer gets on the live paths takes every kind of write —
|
|
1230
|
+
assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
|
|
1231
|
+
that would reach the base sheet by other means: `Object.freeze` (or `seal`,
|
|
1232
|
+
`preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
|
|
1233
|
+
or carries accessors is outside the contract and throws at the first touch.
|
|
1234
|
+
|
|
1205
1235
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1206
1236
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1207
|
-
scorer and every candidate scored against
|
|
1208
|
-
can become another's duplicate
|
|
1209
|
-
|
|
1237
|
+
scorer and every candidate scored against its own copy-on-write VIEW of the
|
|
1238
|
+
result, so no candidate can become another's duplicate and no candidate pays
|
|
1239
|
+
for a copy of the log. Candidates carry no uuid — a spot is not a record — so
|
|
1240
|
+
results come back under caller-chosen keys.
|
|
1210
1241
|
|
|
1211
|
-
`
|
|
1242
|
+
`QsosRepository.forOperation` already filters deleted rows and orders by
|
|
1212
1243
|
`startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
|
|
1213
1244
|
`deleted`/`event` row bookkeeping on this side of the bridge.
|
|
1214
1245
|
`ScoringService` (`app/lib/services/scoring_service.dart`) writes
|
package/docs/templates.md
CHANGED
|
@@ -224,7 +224,7 @@ will jump rather than count. Show HH:MM.
|
|
|
224
224
|
| Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
|
|
225
225
|
| Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
|
|
226
226
|
| ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
|
|
227
|
-
| CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's
|
|
227
|
+
| CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the CW Keyer dialog (Settings → Radio, or the Station dialog's keyer row) |
|
|
228
228
|
|
|
229
229
|
NOTES and COMMENT default to QSO notes and are withheld when private data
|
|
230
230
|
is off. QSLMSG defaults to empty for program exports, and for the whole-log
|