@ham2k/extension-sdk 0.12.8 → 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.
@@ -128,8 +128,10 @@ function activityScorer(rules) {
128
128
  const isDupe = verdict === "duplicate";
129
129
  const freshRefOnly = verdict === "ref" && !freshRefRescuesActivation;
130
130
  const activationCredit = freshRefOnly ? 0 : activationValue;
131
+ const hasStake = activationValue + huntingValue > 0;
131
132
  const score = { value: isDupe ? 0 : activationCredit + huntingValue };
132
- if (isDupe) {
133
+ if (!hasStake) {
134
+ } else if (isDupe) {
133
135
  score.dupe = true;
134
136
  score.alerts = ["duplicate"];
135
137
  } else if (verdict) {
@@ -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
  }
@@ -5,6 +5,7 @@ const HOOK_CATEGORIES = [
5
5
  "adifImport",
6
6
  "bench",
7
7
  "callNotes",
8
+ "chasing",
8
9
  "command",
9
10
  "dataFile",
10
11
  "export",
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.8
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
  },
@@ -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). Each call is a bridge round-trip carrying a whole
64
- log — fine at command-execution or export scale, not on per-keystroke
65
- paths.
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 alone was asked — the operator tapped its "Activity
737
- Types" row or typed its key as a scope — which a bare scope otherwise cannot be
738
- told from the nearby list, since neither carries a `searchTerm`. An extension
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 extension serving
772
- that reference type alone, via a key-scoped `invokeHook` rather than the usual
773
- fan-out. The word is the **reference type's base name**, lowercased —
774
- `potaActivation` and `pota` both scope as `pota:` — never the extension key: a
775
- user-published `ki2d-pota` serving `potaActivation` is still scoped as
776
- `pota:`, since the operator is adding POTA parks whichever extension supplies
777
- them. Two enabled extensions serving one type share the word, and the scoped
778
- search asks both. For the hook call to reach the extension, its `activity`
779
- hook must be registered under its own **extension key**, and its control
780
- descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
781
- derives the hook key from the part before the slash. An extension that
782
- deviates simply never gets scoped: its rows still add references, but the
783
- scope would ask a hook that doesn't exist and come back empty.
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 PTT the app is keying, as Escape does, whatever radio the
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, wrong for a per-keystroke one. Spots are absent for the same reason:
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
 
package/docs/settings.md CHANGED
@@ -346,6 +346,12 @@ override) without it cluttering the panel for regular users:
346
346
  { type: "field", fieldType: "text", key: "apiEndpointOverride", label: "API Endpoint Override", devMode: true }
347
347
  ```
348
348
 
349
+ A panel with nothing to show at the current developer-mode setting is left
350
+ out of the Settings screen's list of categories rather than shown as an empty
351
+ heading, so a panel whose every field is `devMode` appears there only while
352
+ developer mode is on. Its sidebar entry is not: give such a panel
353
+ `sidebar: false`, or the entry opens on an empty page.
354
+
349
355
  ## `aliases`: short names for the SET command
350
356
 
351
357
  The `SET`, `SHOW` and `HIDE` commands search every declared setting by key,
@@ -435,6 +441,16 @@ one dialog.
435
441
  value: false, target: "logging" }
436
442
  ```
437
443
 
444
+ The Developer settings panel is another: its "Extension Settings" section
445
+ collects every field tagged `target: 'developer'` — the place for a switch
446
+ only someone working on the extension or its service needs, such as POTA's
447
+ "use the test servers". The section shows only once some field targets it.
448
+ Pair the target with `devMode: true` so the field's home panel hides it too
449
+ (see "`devMode` fields" above), and with `sidebar: false` when that is the
450
+ panel's only field. The section is extension API 8: a bundle relying on it
451
+ declares `"api": 8`, so an older app — which would list the panel as an empty
452
+ heading — is never offered it.
453
+
438
454
  `target` is a single target key, or an array when the same field should
439
455
  surface under more than one. A `'<target>-extended'` target (e.g.
440
456
  `'logging-extended'`) is the same idea, but starts hidden behind that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",