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