@ham2k/extension-sdk 0.12.9 → 0.12.10
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/dist/copyOnWrite.js +6 -2
- package/dist/hookCategories.js +1 -0
- package/dist/index.d.ts +96 -3
- package/dist/index.js +1 -1
- package/dist/logCopy.js +87 -0
- package/dist/scoring.js +6 -1
- package/docs/hooks.md +151 -22
- package/package.json +1 -1
package/dist/copyOnWrite.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
function copyOnWrite(base) {
|
|
2
|
-
return isContainer(base) ? wrap(base) : base;
|
|
2
|
+
return isContainer(base) ? wrap(wrappable(base)) : base;
|
|
3
|
+
}
|
|
4
|
+
function wrappable(value) {
|
|
5
|
+
if (!Object.isFrozen(value)) return value;
|
|
6
|
+
return Array.isArray(value) ? [...value] : { ...value };
|
|
3
7
|
}
|
|
4
8
|
function isContainer(value) {
|
|
5
9
|
return typeof value === "object" && value !== null;
|
|
@@ -13,7 +17,7 @@ function wrap(target) {
|
|
|
13
17
|
if (deleted.has(key)) return void 0;
|
|
14
18
|
const value = Reflect.get(t, key);
|
|
15
19
|
if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
|
|
16
|
-
const view = wrap(value);
|
|
20
|
+
const view = wrap(wrappable(value));
|
|
17
21
|
own.set(key, view);
|
|
18
22
|
return view;
|
|
19
23
|
}
|
package/dist/hookCategories.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.12.
|
|
1
|
+
// @ham2k/extension-sdk 0.12.10
|
|
2
2
|
/** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface PanelScene {
|
|
4
4
|
version: 1;
|
|
@@ -474,6 +474,7 @@ export declare const HOOK_CATEGORIES: readonly [
|
|
|
474
474
|
"adifImport",
|
|
475
475
|
"bench",
|
|
476
476
|
"callNotes",
|
|
477
|
+
"chasing",
|
|
477
478
|
"command",
|
|
478
479
|
"dataFile",
|
|
479
480
|
"export",
|
|
@@ -1119,6 +1120,82 @@ export interface ContestScorer<S extends Scoresheet = Scoresheet> {
|
|
|
1119
1120
|
scope: "day" | "operation";
|
|
1120
1121
|
}, ctx: HookContext): Record<string, ScoreTally>;
|
|
1121
1122
|
}
|
|
1123
|
+
export interface ChasingProgram {
|
|
1124
|
+
label: string;
|
|
1125
|
+
icon?: string;
|
|
1126
|
+
color?: string;
|
|
1127
|
+
version: number;
|
|
1128
|
+
editions: "lifetime" | "annual" | "event";
|
|
1129
|
+
supersedes?: string[];
|
|
1130
|
+
refLabel?: string;
|
|
1131
|
+
imports?: ChasingImporter[];
|
|
1132
|
+
}
|
|
1133
|
+
export interface ChasingImporter {
|
|
1134
|
+
key: string;
|
|
1135
|
+
label: string;
|
|
1136
|
+
extensions: string[];
|
|
1137
|
+
}
|
|
1138
|
+
export interface ImportedClaim extends QsoClaim {
|
|
1139
|
+
at?: number;
|
|
1140
|
+
match?: {
|
|
1141
|
+
theirCall?: string;
|
|
1142
|
+
startAtMillis?: number;
|
|
1143
|
+
band?: string;
|
|
1144
|
+
mode?: string;
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
export type ClaimLevel = "worked" | "confirmed" | "verified";
|
|
1148
|
+
export interface QsoClaim {
|
|
1149
|
+
holder: string;
|
|
1150
|
+
ref: string;
|
|
1151
|
+
band: string | null;
|
|
1152
|
+
mode: string | null;
|
|
1153
|
+
edition?: string;
|
|
1154
|
+
level: ClaimLevel;
|
|
1155
|
+
by?: string;
|
|
1156
|
+
label?: string;
|
|
1157
|
+
guess?: boolean;
|
|
1158
|
+
}
|
|
1159
|
+
export interface ClaimSlot {
|
|
1160
|
+
ref: string;
|
|
1161
|
+
band: string | null;
|
|
1162
|
+
mode: string | null;
|
|
1163
|
+
level: ClaimLevel;
|
|
1164
|
+
label?: string;
|
|
1165
|
+
}
|
|
1166
|
+
export type ClaimsTally = Omit<ScoreTally, "for">;
|
|
1167
|
+
export interface ChasingHook {
|
|
1168
|
+
program: ChasingProgram;
|
|
1169
|
+
claimsForQsos(args: {
|
|
1170
|
+
qsos: Record<string, JSONValue>[];
|
|
1171
|
+
}, ctx: HookContext): Promise<Record<string, QsoClaim[]>> | Record<string, QsoClaim[]>;
|
|
1172
|
+
holdersFor(args: {
|
|
1173
|
+
station: {
|
|
1174
|
+
call: string;
|
|
1175
|
+
} & Record<string, JSONValue>;
|
|
1176
|
+
}, ctx: HookContext): Promise<string[]> | string[];
|
|
1177
|
+
summarizeClaims(args: {
|
|
1178
|
+
holder: string;
|
|
1179
|
+
edition?: string;
|
|
1180
|
+
slots: ClaimSlot[];
|
|
1181
|
+
}, ctx: HookContext): Promise<Record<string, ClaimsTally>> | Record<string, ClaimsTally>;
|
|
1182
|
+
importClaims?(args: {
|
|
1183
|
+
importer: string;
|
|
1184
|
+
file: {
|
|
1185
|
+
name: string;
|
|
1186
|
+
text: string;
|
|
1187
|
+
};
|
|
1188
|
+
station: {
|
|
1189
|
+
call: string;
|
|
1190
|
+
} & Record<string, JSONValue>;
|
|
1191
|
+
}, ctx: HookContext): Promise<{
|
|
1192
|
+
claims: ImportedClaim[];
|
|
1193
|
+
skipped?: number;
|
|
1194
|
+
}> | {
|
|
1195
|
+
claims: ImportedClaim[];
|
|
1196
|
+
skipped?: number;
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1122
1199
|
export interface CommandAction {
|
|
1123
1200
|
setVfo?: {
|
|
1124
1201
|
freq?: number;
|
|
@@ -1203,6 +1280,7 @@ export interface CommandAction {
|
|
|
1203
1280
|
};
|
|
1204
1281
|
devLogCat?: Record<string, never>;
|
|
1205
1282
|
devMapInfo?: Record<string, never>;
|
|
1283
|
+
devIdle?: Record<string, never>;
|
|
1206
1284
|
toggleExperiment?: {
|
|
1207
1285
|
token: string;
|
|
1208
1286
|
};
|
|
@@ -1264,11 +1342,13 @@ export interface CommandHook {
|
|
|
1264
1342
|
export interface SyncPayload {
|
|
1265
1343
|
operations?: Record<string, JSONValue>[];
|
|
1266
1344
|
qsos?: Record<string, JSONValue>[];
|
|
1345
|
+
claims?: Record<string, JSONValue>[];
|
|
1267
1346
|
meta?: Record<string, JSONValue>;
|
|
1268
1347
|
}
|
|
1269
1348
|
export interface SyncResponse {
|
|
1270
1349
|
operations?: Record<string, JSONValue>[];
|
|
1271
1350
|
qsos?: Record<string, JSONValue>[];
|
|
1351
|
+
claims?: Record<string, JSONValue>[];
|
|
1272
1352
|
meta?: Record<string, JSONValue>;
|
|
1273
1353
|
account?: Record<string, JSONValue>;
|
|
1274
1354
|
success?: boolean;
|
|
@@ -1359,7 +1439,7 @@ export interface AccountHook {
|
|
|
1359
1439
|
synchronizable?: boolean;
|
|
1360
1440
|
testCredentials(credentials: Record<string, string>, ctx: HookContext): Promise<string>;
|
|
1361
1441
|
}
|
|
1362
|
-
export type Hook = LookupHook | LookupServiceHook | SpotsHook | ExportHook | ScoringHook | RefHandlerHook | ActivityHook | AdifFieldsHook | AdifImportHook | CommandHook | SyncTransportHook | AccountHook | DataFileDefinition | FormHook | SettingsPanelHook | PanelHook | Record<string, unknown>;
|
|
1442
|
+
export type Hook = LookupHook | LookupServiceHook | SpotsHook | ExportHook | ScoringHook | ChasingHook | RefHandlerHook | ActivityHook | AdifFieldsHook | AdifImportHook | CommandHook | SyncTransportHook | AccountHook | DataFileDefinition | FormHook | SettingsPanelHook | PanelHook | Record<string, unknown>;
|
|
1363
1443
|
export interface RegisterHookParams {
|
|
1364
1444
|
hook: Hook;
|
|
1365
1445
|
key?: string;
|
|
@@ -1805,6 +1885,12 @@ export interface PanelRadioState {
|
|
|
1805
1885
|
* cannot read it (extension API 7).
|
|
1806
1886
|
*/
|
|
1807
1887
|
tuner?: "inline" | "bypassed" | "tuning" | "failed" | null;
|
|
1888
|
+
/**
|
|
1889
|
+
* Whether the radio starts a tune apart from putting its tuner in line — `setRadio`'s `startTune`.
|
|
1890
|
+
* False on a FlexRadio, whose in line already is the tune, and wherever `tuner` is null
|
|
1891
|
+
* (extension API 9).
|
|
1892
|
+
*/
|
|
1893
|
+
canStartTune?: boolean;
|
|
1808
1894
|
/**
|
|
1809
1895
|
* The receive filter's edges in Hz from the tuned frequency, as the radio shows them — `100`…`2800`
|
|
1810
1896
|
* in USB, `-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot read it
|
|
@@ -1921,6 +2007,13 @@ export interface PanelRadioSet extends PanelRadioTarget {
|
|
|
1921
2007
|
* On a FlexRadio, in line is a TUNE — a carrier on the air — since its tuner has no other way in.
|
|
1922
2008
|
*/
|
|
1923
2009
|
tunerInline?: boolean;
|
|
2010
|
+
/**
|
|
2011
|
+
* Starts a tune — a carrier on the air until the tuner settles. Only for an operator's explicit
|
|
2012
|
+
* press. Goes alone: with any other setting in the same call it is `invalid`; `unsupported` where
|
|
2013
|
+
* `canStartTune` is false (extension API 9). Not a keying call: it reaches the radio the panel
|
|
2014
|
+
* shows, with keying on or off. `host.stopRadioTransmit()` ends a tune the app started.
|
|
2015
|
+
*/
|
|
2016
|
+
startTune?: true;
|
|
1924
2017
|
/**
|
|
1925
2018
|
* The receive filter, as `passband` reports it; refused as `unsupported` where that is null. A
|
|
1926
2019
|
* radio that sets only a width from a table takes the nearest, keeping its own shift — read the
|
|
@@ -2406,7 +2499,7 @@ export declare const host: {
|
|
|
2406
2499
|
setRadioPtt(args: PanelRadioTarget & {
|
|
2407
2500
|
on: boolean;
|
|
2408
2501
|
}): Promise<PanelRadioResult>;
|
|
2409
|
-
/** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
|
|
2502
|
+
/** Stops all CW, voice and PTT the app is keying, and a tune it started (extension API 9), as Escape does. Needs `requiresRadioWrite` and extension API 7. */
|
|
2410
2503
|
stopRadioTransmit(args: PanelRadioTarget): Promise<PanelRadioResult>;
|
|
2411
2504
|
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
2412
2505
|
webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
|
package/dist/index.js
CHANGED
|
@@ -193,7 +193,7 @@ const host = {
|
|
|
193
193
|
async setRadioPtt(args) {
|
|
194
194
|
return await hostCallFn()("setRadioPtt", { ...args });
|
|
195
195
|
},
|
|
196
|
-
/** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
|
|
196
|
+
/** Stops all CW, voice and PTT the app is keying, and a tune it started (extension API 9), as Escape does. Needs `requiresRadioWrite` and extension API 7. */
|
|
197
197
|
async stopRadioTransmit(args) {
|
|
198
198
|
return await hostCallFn()("stopRadioTransmit", { ...args });
|
|
199
199
|
},
|
package/dist/logCopy.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const FROZEN_QSOS_API = 9;
|
|
2
|
+
const UNSUPPORTED = "Host call method getQsoChanges not implemented";
|
|
3
|
+
const KEEP = 3;
|
|
4
|
+
const startOf = (qso) => typeof qso.startAtMillis === "number" ? qso.startAtMillis : -Infinity;
|
|
5
|
+
function deepFreeze(value) {
|
|
6
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
7
|
+
Object.freeze(value);
|
|
8
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function createLogCopies(source, keep = KEEP) {
|
|
13
|
+
const copies = /* @__PURE__ */ new Map();
|
|
14
|
+
let unsupported = false;
|
|
15
|
+
function copyFor(operationUuid) {
|
|
16
|
+
let copy = copies.get(operationUuid);
|
|
17
|
+
if (copy) copies.delete(operationUuid);
|
|
18
|
+
else copy = { rows: /* @__PURE__ */ new Map(), list: [], queue: Promise.resolve() };
|
|
19
|
+
copies.set(operationUuid, copy);
|
|
20
|
+
for (const stale of copies.keys()) {
|
|
21
|
+
if (copies.size <= keep) break;
|
|
22
|
+
copies.delete(stale);
|
|
23
|
+
}
|
|
24
|
+
return copy;
|
|
25
|
+
}
|
|
26
|
+
async function sync(operationUuid, copy) {
|
|
27
|
+
let changes = await source.changes(operationUuid, copy.cursor);
|
|
28
|
+
if (changes === null) return false;
|
|
29
|
+
const held = apply(copy, changes);
|
|
30
|
+
if (!changes.reset && typeof changes.count === "number" && held !== changes.count) {
|
|
31
|
+
changes = await source.changes(operationUuid, void 0);
|
|
32
|
+
if (changes === null) return false;
|
|
33
|
+
apply(copy, changes);
|
|
34
|
+
}
|
|
35
|
+
if (changes.reset || changes.qsos.length > 0 || changes.removed.length > 0) {
|
|
36
|
+
copy.list = [...copy.rows.values()].sort((a, b) => startOf(a) - startOf(b));
|
|
37
|
+
copy.json = void 0;
|
|
38
|
+
}
|
|
39
|
+
copy.cursor = changes.cursor;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
function apply(copy, changes) {
|
|
43
|
+
if (changes.reset) copy.rows.clear();
|
|
44
|
+
for (const qso of changes.qsos) copy.rows.set(String(qso.uuid), deepFreeze(qso));
|
|
45
|
+
for (const uuid of changes.removed) copy.rows.delete(uuid);
|
|
46
|
+
return copy.rows.size;
|
|
47
|
+
}
|
|
48
|
+
async function synced(operationUuid) {
|
|
49
|
+
if (unsupported) return UNSUPPORTED;
|
|
50
|
+
const copy = copyFor(operationUuid);
|
|
51
|
+
const run = copy.queue.then(() => sync(operationUuid, copy));
|
|
52
|
+
copy.queue = run.catch(() => void 0);
|
|
53
|
+
let known;
|
|
54
|
+
try {
|
|
55
|
+
known = await run;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!String(error?.message ?? error).includes(UNSUPPORTED)) throw error;
|
|
58
|
+
unsupported = true;
|
|
59
|
+
copies.clear();
|
|
60
|
+
return UNSUPPORTED;
|
|
61
|
+
}
|
|
62
|
+
if (known) return copy;
|
|
63
|
+
copies.delete(operationUuid);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
async read(operationUuid, { frozen }) {
|
|
68
|
+
const copy = await synced(operationUuid);
|
|
69
|
+
if (copy === null || copy === UNSUPPORTED) return copy === null ? null : source.whole(operationUuid);
|
|
70
|
+
if (frozen) return copy.list.slice();
|
|
71
|
+
copy.json ?? (copy.json = JSON.stringify(copy.list));
|
|
72
|
+
return JSON.parse(copy.json);
|
|
73
|
+
},
|
|
74
|
+
async pick(operationUuid, uuids, { frozen }) {
|
|
75
|
+
const copy = await synced(operationUuid);
|
|
76
|
+
if (copy === null) return null;
|
|
77
|
+
const rows = copy === UNSUPPORTED ? new Map((await source.whole(operationUuid))?.map((qso) => [String(qso.uuid), qso]) ?? []) : copy.rows;
|
|
78
|
+
const picked = uuids.flatMap((uuid) => rows.has(uuid) ? [rows.get(uuid)] : []);
|
|
79
|
+
return frozen || copy === UNSUPPORTED ? picked : JSON.parse(JSON.stringify(picked));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
FROZEN_QSOS_API,
|
|
85
|
+
createLogCopies,
|
|
86
|
+
deepFreeze
|
|
87
|
+
};
|
package/dist/scoring.js
CHANGED
|
@@ -19,6 +19,11 @@ function chronological(qsos) {
|
|
|
19
19
|
function isEvent(qso) {
|
|
20
20
|
return qso.band === "event";
|
|
21
21
|
}
|
|
22
|
+
function hasIncompleteCall(qso) {
|
|
23
|
+
const their = qso.their;
|
|
24
|
+
const call = their && typeof their === "object" && !Array.isArray(their) ? their.call : void 0;
|
|
25
|
+
return typeof call === "string" && call.includes("?");
|
|
26
|
+
}
|
|
22
27
|
function startAtMillisOf(qso) {
|
|
23
28
|
const at = qso.startAtMillis;
|
|
24
29
|
return typeof at === "number" ? at : 0;
|
|
@@ -61,7 +66,7 @@ function contestScorer(scorer, options = {}) {
|
|
|
61
66
|
const timeline = segments && segments.length > 1 ? segments : void 0;
|
|
62
67
|
let segmentIndex = 0;
|
|
63
68
|
for (const qso of qsos) {
|
|
64
|
-
if (isEvent(qso)) continue;
|
|
69
|
+
if (isEvent(qso) || hasIncompleteCall(qso)) continue;
|
|
65
70
|
let operation = args.operation;
|
|
66
71
|
let ref = args.ref;
|
|
67
72
|
if (timeline) {
|
package/docs/hooks.md
CHANGED
|
@@ -60,9 +60,12 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
|
|
|
60
60
|
raw QSON, oldest first, excluding soft-deleted records but *including*
|
|
61
61
|
event markers (`band: 'event'`) — or null when the operation is unknown or
|
|
62
62
|
the host has no log access, distinct from `[]` (the operation exists and
|
|
63
|
-
simply has no QSOs yet).
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
simply has no QSOs yet). Every extension reads one copy of the log the
|
|
64
|
+
runtime keeps, which asks the host only what changed since the last read:
|
|
65
|
+
a call is never staler than itself, and the whole log crosses the bridge
|
|
66
|
+
only the first time. Each call gets a new array. From extension API 9 its
|
|
67
|
+
QSOs are that shared copy, frozen — a write throws, so copy what you mean
|
|
68
|
+
to change; under an older `api` each call gets QSOs of its own.
|
|
66
69
|
* `getSpotsForCall?(call, maxAgeMinutes = 30)` — the spots the host has
|
|
67
70
|
already fetched for `call`, no older than `maxAgeMinutes` (measured on the
|
|
68
71
|
spot's own `spot.timeInMillis`, not on when it was fetched), newest first.
|
|
@@ -733,9 +736,11 @@ suggested the nearer of its point's and its outline's distance — so `pota`'s
|
|
|
733
736
|
still reaches an operator standing on it. Called with no
|
|
734
737
|
`searchTerm` for nearby suggestions, or with one for name/code search — an
|
|
735
738
|
extension may honor either, both, or neither (returning `[]`). `scoped` is
|
|
736
|
-
true when THIS extension
|
|
737
|
-
Types" row or typed
|
|
738
|
-
told from the nearby list, since neither
|
|
739
|
+
true when THIS extension's own list was asked for — the operator tapped its
|
|
740
|
+
"Activity Types" row or typed one of its words, with or without the colon
|
|
741
|
+
(below) — which otherwise cannot be told from the nearby list, since neither
|
|
742
|
+
carries a `searchTerm`. Without the colon the other extensions are asked too,
|
|
743
|
+
for the word as text. An extension
|
|
739
744
|
that keeps itself out of the nearby list (`cwt`, offered only around a session)
|
|
740
745
|
answers a scoped call anyway: it is the one being asked. The core fans out to
|
|
741
746
|
every `activity` hook via `invokeHookAll`; a scoped search asks only its own
|
|
@@ -768,19 +773,38 @@ The row is marked, and the reference is stored, logged and exported as typed —
|
|
|
768
773
|
the same rule ADIF import follows for a malformed `*_REF` (below).
|
|
769
774
|
|
|
770
775
|
The activity search box also treats a leading scope word as a scope — typing
|
|
771
|
-
`pota:` (or tapping a row under "Activity Types") asks the
|
|
772
|
-
that reference type
|
|
773
|
-
fan-out.
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
`
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
776
|
+
`pota:` (or tapping a row under "Activity Types") asks only the extensions
|
|
777
|
+
serving that reference type, via a key-scoped `invokeHook` rather than the
|
|
778
|
+
usual fan-out. An extension answers to two kinds of word:
|
|
779
|
+
|
|
780
|
+
- **Each reference type its manifest declares**, as a plain `ref:<type>` in
|
|
781
|
+
`hooks`, by the type's base name lowercased — `ref:potaActivation` and
|
|
782
|
+
`ref:pota` are both `pota:`. A user-published `ki2d-pota` declaring
|
|
783
|
+
`ref:potaActivation` is scoped as `pota:` too, since the operator is adding
|
|
784
|
+
POTA parks whichever extension supplies them: two running extensions
|
|
785
|
+
declaring one type share the word, and the scoped search asks both. A
|
|
786
|
+
qualified claim (`ref:qp/ny`) makes no word.
|
|
787
|
+
- **Its own extension key** — `ham2k-pota:` asks that extension alone, which
|
|
788
|
+
is how an operator picks one supplier over another.
|
|
789
|
+
|
|
790
|
+
The same word without its colon gives precedence instead of scoping. `pota`
|
|
791
|
+
on its own asks the extensions serving the type for their own list (as a
|
|
792
|
+
scope, `scoped: true` with no `searchTerm`) and every other extension to
|
|
793
|
+
search for the text "pota", with the first answers leading. `pota bear mtn`
|
|
794
|
+
asks the extensions serving the type for "bear mtn" and every other one for
|
|
795
|
+
the whole text — a `suggest` matching codes and names against one string
|
|
796
|
+
finds nothing for the word it was named by.
|
|
797
|
+
|
|
798
|
+
The scoped call reaches the extension under the key it registered its
|
|
799
|
+
`activity` hook with, which has to be its manifest key. The search reads which
|
|
800
|
+
extension supplied a control from the fan-out that returned it, never from
|
|
801
|
+
the descriptor's `key`, so a catalog `ham2k-pota` keying its controls
|
|
802
|
+
`pota/activation` is reached as `ham2k-pota`. (A logging control's serial
|
|
803
|
+
counter is still namespaced by the `key`'s prefix: two extensions keying
|
|
804
|
+
controls under one prefix share a counter.) A
|
|
805
|
+
switched-off or not-yet-installed extension is offered from the same words:
|
|
806
|
+
typing a type it declares, or its key, puts its enable or install row under
|
|
807
|
+
the search.
|
|
784
808
|
|
|
785
809
|
`editable` declares whether a reference of this type has anything to edit
|
|
786
810
|
once it's on the operation. It defaults to **false**: a plain reference
|
|
@@ -1165,6 +1189,9 @@ action field — the hook parses "EXP <key-or-alias>" and resolves the token
|
|
|
1165
1189
|
against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
|
|
1166
1190
|
resolves it again when it flips the state and reports the outcome, since the
|
|
1167
1191
|
stored per-experiment state lives app-side;
|
|
1192
|
+
LANG `<code>` switches the app's language — hidden locales (CW, QRP, QRO)
|
|
1193
|
+
included, "LANG SYSTEM" back to the device's — with a plain `updateSetting`
|
|
1194
|
+
on the `locale` field, which refuses a code the app has no strings for;
|
|
1168
1195
|
DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
|
|
1169
1196
|
"DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
|
|
1170
1197
|
anything that turns on the date or on elapsed time can be tested without
|
|
@@ -1451,6 +1478,92 @@ detection, DXCC entities worked (via `@ham2k/lib-country-files`'
|
|
|
1451
1478
|
`useBuiltinCountryFile`, no network needed), and bands/modes worked. US
|
|
1452
1479
|
States and Canadian Provinces rules are deferred (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
|
|
1453
1480
|
|
|
1481
|
+
## ✅ `chasing` — what a contact counts toward
|
|
1482
|
+
|
|
1483
|
+
A chasing program — DXCC, POTA hunting, WAS — says which lines of its
|
|
1484
|
+
checklist each contact fills. The app keeps the best claim per line and ranks
|
|
1485
|
+
spots against the result; [claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) is the design.
|
|
1486
|
+
|
|
1487
|
+
One registration per program, keyed by the program's key — the extension's own
|
|
1488
|
+
key, or one prefixed `<extension key>_`, so one extension may register
|
|
1489
|
+
several:
|
|
1490
|
+
|
|
1491
|
+
```ts
|
|
1492
|
+
registerHook("chasing", { key: "dxcc", hook: {
|
|
1493
|
+
program: { label: "DXCC", icon: "earth", version: 1, editions: "lifetime" },
|
|
1494
|
+
claimsForQsos({ qsos }, ctx) { /* { [uuid]: QsoClaim[] } */ },
|
|
1495
|
+
holdersFor({ station }, ctx) { /* string[] */ },
|
|
1496
|
+
summarizeClaims({ holder, edition, slots }, ctx) { /* { [key]: ClaimsTally } */ },
|
|
1497
|
+
importClaims({ importer, file, station }, ctx) { /* { claims: ImportedClaim[], skipped? } */ }, // optional
|
|
1498
|
+
}})
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
- **`program`** is plain data, read across every extension in one call
|
|
1502
|
+
without invoking anything. Raise `version` whenever the same contacts would
|
|
1503
|
+
claim something different: the app keeps no claim from an older version.
|
|
1504
|
+
`refLabel` is what one reference is called where a notice names it
|
|
1505
|
+
("New POTA Park"), the program's `label` when left out.
|
|
1506
|
+
`supersedes` names the scoring notices the program's verdicts stand in for
|
|
1507
|
+
(DXCC: `newDXCC`): wherever the program judges a call or a spot, the
|
|
1508
|
+
scorer's notice is dropped in favor of the verdict.
|
|
1509
|
+
- **`claimsForQsos`** answers every contact in the batch, by `uuid` — a
|
|
1510
|
+
caller asking about something that is not a stored contact (a spot, the
|
|
1511
|
+
call being typed) gives it a `uuid` of its own, and a contact without one
|
|
1512
|
+
is left out — with the
|
|
1513
|
+
slots it fills: `{holder, ref, band, mode, level, edition?, by?, label?,
|
|
1514
|
+
guess?}`. `band` and `mode` are explicit — `null` means "any", and a
|
|
1515
|
+
program usually claims several granularities per contact (DXCC: Mixed, the
|
|
1516
|
+
band, the mode). `mode` is the program's own grouping; the app never maps
|
|
1517
|
+
modes for it. `level` is `worked`, `confirmed` (a QSL the program accepts,
|
|
1518
|
+
from `qso.qsl`) or `verified` (sponsor credit, `qso.credits.<program>`).
|
|
1519
|
+
**It must be a pure function of each contact** and the extension's own
|
|
1520
|
+
data — never the log — because the app asks it about spots, and about
|
|
1521
|
+
contacts on a device that holds only part of the log. An empty list is an
|
|
1522
|
+
answer: that contact claims nothing. So is the list for an event marker
|
|
1523
|
+
(`band: 'event'`), whose placeholder calls are not stations. A contact's
|
|
1524
|
+
`our` carries the place it was made from — `grid`, `lat`/`lon` — its own,
|
|
1525
|
+
or its operation's when it states none, for a program that keeps a
|
|
1526
|
+
checklist per place. A spot candidate's `their` carries the lookup's
|
|
1527
|
+
`guess.state` when an offline lookup knows one.
|
|
1528
|
+
- **`holdersFor`** lists the holders a station claims as — the opaque
|
|
1529
|
+
strings the program puts on its claims — so the app knows whose checklist
|
|
1530
|
+
to show: the first, in the current edition. `station` carries its call
|
|
1531
|
+
and, when it has one, its place, as a contact's `our` does. An `annual`
|
|
1532
|
+
program names its editions by UTC year (`'2026'`), which is how the app
|
|
1533
|
+
tells the current one; an `event` program's checklist has no current
|
|
1534
|
+
edition to show.
|
|
1535
|
+
- **`summarizeClaims`** reads one holder's filled slots (`{ref, band, mode,
|
|
1536
|
+
level, label?}` each) into tallies, a `ScoreTally` without its `for`.
|
|
1537
|
+
- **`importClaims`**, for a program whose `program.imports` lists importers
|
|
1538
|
+
(`{key, label, extensions}`), reads one file — `{name, text}` — that the
|
|
1539
|
+
operator picked or dropped, for the station they chase as (`station`, its
|
|
1540
|
+
call and place as `holdersFor` gets them). It answers the
|
|
1541
|
+
claims the file makes, each a `QsoClaim` plus `at` (epoch millis, when the
|
|
1542
|
+
slot was earned) if the file says, and `skipped`, the records it could not
|
|
1543
|
+
read. Each claim is kept in the log as a claim record — an event row, so it
|
|
1544
|
+
syncs with the log ([claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) §9) — and
|
|
1545
|
+
`claimsForQsos` is never asked about one: the app reads the claim it holds.
|
|
1546
|
+
The file is the whole of its importer's records: importing the same kind of
|
|
1547
|
+
file again replaces every claim the last one gave, and a file answered with
|
|
1548
|
+
no claims replaces nothing.
|
|
1549
|
+
|
|
1550
|
+
The host isolates programs from each other
|
|
1551
|
+
(`app/lib/services/extensions/extension_chasing.dart`): a program that throws
|
|
1552
|
+
or times out answers nothing for that call, and a contact with any
|
|
1553
|
+
malformed claim is left out of the answer, keeping the claims it already
|
|
1554
|
+
holds, since dropping one claim would read as the contact no longer making
|
|
1555
|
+
it. Both are reported once per program and method. The category is
|
|
1556
|
+
extension API 9: a bundle that registers a program declares `"api": 9`, which
|
|
1557
|
+
the packer enforces, so an older app — which would install it and never ask
|
|
1558
|
+
it anything — is never offered it.
|
|
1559
|
+
|
|
1560
|
+
Implemented by: `dxcc` (core) — Mixed, per-band and per-mode slots for the
|
|
1561
|
+
station call worked from each entity; LoTW and paper QSLs confirm, LoTW credit
|
|
1562
|
+
verifies. `ham2k-pota` — one slot per park hunted, worked only; its
|
|
1563
|
+
hunted-parks export verifies. `ham2k-was` — Mixed, per-band and per-mode
|
|
1564
|
+
slots (plus FT8 and RTTY) for the station call worked from one 4-character
|
|
1565
|
+
grid; LoTW and paper QSLs confirm, `WAS` credit verifies.
|
|
1566
|
+
|
|
1454
1567
|
## ✅ `bench` — kernel diagnostics
|
|
1455
1568
|
|
|
1456
1569
|
Registered by the kernel itself (`bench/kernel`): `echo` (bridge round-trip
|
|
@@ -1471,6 +1584,11 @@ permission (pending challenge code for an email, immediately-active for an
|
|
|
1471
1584
|
already-permitted account, or a blank permission for QR-code linking) and
|
|
1472
1585
|
returns server rejections as `{ error, error_status }` data.
|
|
1473
1586
|
|
|
1587
|
+
`sync`'s payload and reply carry `claims` beside `operations` and `qsos`
|
|
1588
|
+
(docs/design/claims.md §7) once the server's reply has said it takes them
|
|
1589
|
+
with `meta.flags.claims`. A transport passes the array through like the
|
|
1590
|
+
other two, both ways.
|
|
1591
|
+
|
|
1474
1592
|
`linkClient` must NOT mail anything. The core shows the challenge code on
|
|
1475
1593
|
screen and offers the email as a separate choice, so a transport that mails
|
|
1476
1594
|
here sends a confirmation nobody asked for — and one more on every renewal,
|
|
@@ -1941,6 +2059,9 @@ rate. Extension API 7 adds to the answer:
|
|
|
1941
2059
|
modes `setRadio` accepts for this radio, in the radio's order.
|
|
1942
2060
|
- `tuner`: `inline`, `bypassed`, `tuning` or `failed`; null for a radio
|
|
1943
2061
|
without one, or whose driver cannot read it.
|
|
2062
|
+
- `canStartTune` (extension API 9): the radio starts a tune apart from
|
|
2063
|
+
putting its tuner in line — `setRadio`'s `startTune`. False on a FlexRadio,
|
|
2064
|
+
whose in line already is the tune, and wherever `tuner` is null.
|
|
1944
2065
|
- `passband`: `{lowHz, highHz}`, the receive filter's edges in Hz from the
|
|
1945
2066
|
tuned frequency as the radio shows them — `100`…`2800` in USB,
|
|
1946
2067
|
`-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot
|
|
@@ -1975,6 +2096,14 @@ extension API 7: a bundle using them declares `"api": 7`.
|
|
|
1975
2096
|
- `tunerInline`: puts the antenna tuner in line or bypasses it. On a
|
|
1976
2097
|
FlexRadio, whose tuner has no other way in, in line is a TUNE — a carrier
|
|
1977
2098
|
on the air; other radios switch it without transmitting.
|
|
2099
|
+
- `startTune: true` (extension API 9): starts a tune — a carrier on the air
|
|
2100
|
+
until the tuner settles, read back through `tuner` as `tuning`. Only for an
|
|
2101
|
+
operator's explicit press: never send it on a hook's own initiative. It
|
|
2102
|
+
goes alone — in the same call as any other setting it is `invalid` — and is
|
|
2103
|
+
`unsupported` where `canStartTune` is false. It is the radio's own tune,
|
|
2104
|
+
not one of the keying calls below: it reaches whichever radio the panel
|
|
2105
|
+
shows, with keying on or off. A start while one is running is ignored, and
|
|
2106
|
+
`stopRadioTransmit` (or Escape) ends a tune the app started.
|
|
1978
2107
|
- `passband`: `{lowHz, highHz}`, as `passband` reports it. A radio that sets
|
|
1979
2108
|
only a width from a table takes the nearest, keeping its own shift — read
|
|
1980
2109
|
the result back from `passband`. It applies to the mode the radio is in: a
|
|
@@ -2009,7 +2138,7 @@ rather than keying the logging radio behind the operator's back:
|
|
|
2009
2138
|
`on: false` releases whatever key the app holds and is never refused; a
|
|
2010
2139
|
radio that does not take the key answers `on: true` with `disconnected`.
|
|
2011
2140
|
- `host.stopRadioTransmit({id, selection?})` stops every CW message, voice
|
|
2012
|
-
memory and
|
|
2141
|
+
memory, PTT and tune the app is keying, as Escape does, whatever radio the
|
|
2013
2142
|
identity names: a stop is never the call that is refused.
|
|
2014
2143
|
|
|
2015
2144
|
A radio that cannot do what is asked (no CW key, no voice memories, no PTT)
|
|
@@ -2143,7 +2272,8 @@ its first render.
|
|
|
2143
2272
|
|
|
2144
2273
|
The whole log is deliberately absent from `render`'s args. A panel that
|
|
2145
2274
|
needs it calls `ctx.getQsos(operation.uuid)` — right for a `'qsoLogged'`
|
|
2146
|
-
render,
|
|
2275
|
+
render, which comes for every change to the log: a contact logged, edited
|
|
2276
|
+
or deleted, or one a sync brought in. Spots are absent for the same reason:
|
|
2147
2277
|
a panel showing them declares `'spots'` and calls `ctx.getSpots()`, which
|
|
2148
2278
|
answers the operator's own filtered board, judged against this operation.
|
|
2149
2279
|
|
|
@@ -2239,7 +2369,6 @@ Consequences worth knowing:
|
|
|
2239
2369
|
|
|
2240
2370
|
| Category | Role | Notes |
|
|
2241
2371
|
|---|---|---|
|
|
2242
|
-
| `chasing` | what each contact counts toward a program's checklist (claims) | [claims.md](https://github.com/ham2k/halo/blob/main/docs/design/claims.md) |
|
|
2243
2372
|
| `confirmation` | QSL/confirmation sources (spot-history cross-checking) | |
|
|
2244
2373
|
| `opSetting` | per-operation settings contributions | |
|
|
2245
2374
|
|