@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 +6 -0
- package/dist/base64.js +38 -0
- package/dist/copyOnWrite.js +71 -0
- package/dist/hookCategories.js +25 -0
- package/dist/index.d.ts +92 -6
- package/dist/index.js +110 -0
- package/dist/scoring.js +53 -14
- package/dist/templateContext.js +43 -2
- package/dist/timerQueue.js +187 -0
- package/dist/timers.js +42 -0
- package/docs/distribution.md +27 -6
- package/docs/hooks.md +135 -32
- package/docs/templates.md +69 -2
- package/package.json +5 -1
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
};
|