@ham2k/extension-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/hooks.md ADDED
@@ -0,0 +1,1282 @@
1
+ # Hook reference
2
+
3
+ Hooks are registered under a **category** with an optional **key** (defaults
4
+ to the extension key) and **priority** (higher runs first; default 0). The
5
+ host invokes them two ways:
6
+
7
+ - `invokeHook(category, key?, method, args)` — best (highest-priority) hook.
8
+ - `invokeHookAll(category, method, args)` — every hook in the category, one
9
+ bridge round-trip, per-source error isolation.
10
+
11
+ All argument and result types below are defined in
12
+ [`extensions/sdk/src/types.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/types.ts). Every
13
+ hook method receives `(args, ctx)` where `ctx: HookContext` is:
14
+ * `online: boolean` — True if the device has an active network connection.
15
+ * `developerMode: boolean` — True if developer options are enabled in settings.
16
+ * `locale: string` — The active locale code of the application (e.g. `'en'`, `'es'`).
17
+ * `appName?: string` — Platform-appropriate app name ("Ham2K Logger" on
18
+ desktop, "Ham2K Portable Logger" on mobile) — use this instead of a
19
+ hardcoded name when identifying the app to an external service or file
20
+ (e.g. a spot's `source` field, an ADIF `PROGRAMID` header).
21
+ * `edition?: string` — The running build's edition: `'dev' | 'next' | 'prod'`
22
+ (see `packages/halo_build_tools/lib/src/edition.dart`). For a hook whose
23
+ static config must vary per-edition — e.g. SOTA's OAuth redirect URL, one
24
+ per edition and each registered separately with the provider.
25
+ * `enabledExperiments?: string[]` — Keys of every experiment
26
+ (`app/experiments.yaml`) currently on for this user. For a hook whose own
27
+ effect must stay invisible until an experiment gating the surface it
28
+ belongs to has shipped ungated — checked per-call, since gating at the
29
+ whole-extension `manifest.experiments` grain would also hide any other
30
+ hook the same extension registers (e.g. `radio-commands`' POWER command
31
+ checks `gato` here; its BAND/MODE/frequency siblings don't).
32
+ * `getOperation?(uuid)` / `getQsos?(operationUuid)` — log-data reads, for
33
+ hooks that only receive part of the picture as arguments (e.g. a `command`
34
+ hook handed the operation it's typed into, needing the existing QSOs —
35
+ dev-commands' SEED). `getOperation` resolves the operation's QSON with
36
+ `uuid` folded in (null for an unknown uuid); `getQsos` resolves every QSO's
37
+ raw QSON, oldest first, excluding soft-deleted records but *including*
38
+ event markers (`band: 'event'`) — or null when the operation is unknown or
39
+ the host has no log access, distinct from `[]` (the operation exists and
40
+ simply has no QSOs yet). Each call is a bridge round-trip carrying a whole
41
+ log — fine at command-execution or export scale, not on per-keystroke
42
+ paths.
43
+ * `getSpotsForCall?(call, maxAgeMinutes = 30)` — the spots the host has
44
+ already fetched for `call`, no older than `maxAgeMinutes` (measured on the
45
+ spot's own `spot.timeInMillis`, not on when it was fetched), newest first.
46
+ Reads the host's cache and **never triggers a fetch**, so unlike the log
47
+ reads above it is cheap enough for the lookup path; it resolves `[]`
48
+ whenever the cache is cold or the host keeps no spots. Used by
49
+ `spot-history` to pull a hunted park or summit into the QSO being logged.
50
+ * `getHistoryForCall?(call)` — every non-deleted QSO ever logged with `call`,
51
+ across every operation, most recent first, as raw QSON — the one log read
52
+ that is cross-operation rather than scoped to a single one, since it
53
+ answers "who is this station" from the whole log rather than "what's in
54
+ this operation". Resolves `[]` for no match or no log access; unlike
55
+ `getQsos` there is no "unknown call" to distinguish from "no history".
56
+ Used by `call-history` to fold the most recent past QSO's fields into a
57
+ lookup result.
58
+
59
+ Status legend: ✅ implemented · 🔜 planned (design in DESIGN.md §4).
60
+
61
+ ---
62
+
63
+ ## ✅ `lookup` — callsign lookups
64
+
65
+ Data sources for callsign/QSO enrichment (QRZ, HamDB, POTA, SOTA…). Never
66
+ called directly from Dart — the `lookupService/core` hook below fans out to
67
+ every registered `lookup` hook **sequentially, in priority order**, folding
68
+ each result into the next hook's input, and applies the locationScope-aware
69
+ merge rules (see below). Fired debounced from the logging panel, never
70
+ per-keystroke.
71
+
72
+ ```ts
73
+ interface LookupHook {
74
+ lookupCall(args: { callInfo: AnnotatedCallInfo; qso; operation }, ctx): Promise<LookupResult>
75
+ }
76
+ // AnnotatedCallInfo: CallInfo & {guess?: CallInfo, lookups?: CallInfoLookup[]}
77
+ // LookupResult: (CallInfoLookup & {locAccuracy?, refs?: RefInfo[], online?: boolean})[] —
78
+ // zero or more records; [] to skip
79
+ // CallInfoLookup: CallInfo & {source?, scope?: 'general'|'operation'|'qso', notes?, history?}
80
+ ```
81
+
82
+ Set `online: true` on a result ONLY when answering it required an actual
83
+ network round-trip — never on one your source could have produced with
84
+ `ctx.online` false. `LookupQueueService._hasOnlineLookup` reads this flag
85
+ generically to decide whether a saved QSO already has a real online answer
86
+ worth not re-fetching; omitting it (the default) is correct for anything
87
+ from local/offline data — a log query, a cached reference file, notes — even
88
+ if that source usually needs the network for OTHER calls (POTA/SOTA's
89
+ `fetchPark`/`fetchSummit` try a local reference-data cache before falling
90
+ back to a live fetch, so they leave it unset rather than risk mistagging a
91
+ cache hit). The one-time offline annotation pre-step never sets it either.
92
+
93
+ A result's `refs` **proposes** activity references for the QSO being logged.
94
+ The host applies only those whose `type` the QSO does not already carry — a
95
+ lookup never overrides a reference the operator entered — and re-runs the
96
+ lookup once afterwards so QSO-scoped hooks (POTA/SOTA) see the new ref and
97
+ can resolve its grid. Because an already-applied type is filtered out on the
98
+ next pass, that re-run settles instead of cycling.
99
+
100
+ Return `[]` for "can't answer" (not configured, not found, or `!ctx.online`
101
+ and this source has no offline data) — never throw for expected conditions.
102
+ `callInfo`/`qso`/`operation` are the full QSON-shaped objects (all from
103
+ `@ham2k/lib-qson-tools`'s `CallInfo`/`QSON` types), so e.g. a POTA lookup can
104
+ inspect `qso.refs` for a hunted park reference. Implemented by: `qrz`,
105
+ `ham2k-lookup` (`scope: 'general'`), `pota`, `sota` (`scope: 'qso'`, grid of
106
+ any hunted ref on the current QSO), `call-notes` (`scope: 'general'`,
107
+ offline — combined `note`/`emoji` extra fields from the Hams of Note file,
108
+ issue #17), and `call-history` (`scope: 'general'`, offline — the most
109
+ recent past QSO's own identity fields, from `ctx.getHistoryForCall`).
110
+ Registers at **priority -1** — below every other lookup source, and below
111
+ the 0 an undeclared hook defaults to: the log is a fallback, filling only
112
+ what nothing else answered, and that holds even for a field the operator
113
+ TYPED on the past QSO — a live source describes the station today, the log
114
+ describes it whenever it was last worked. It still labels a carried-forward
115
+ field by where it came from (`Call History` for a typed one, `Call History
116
+ (guess)` for a guessed one), and a station's whole location travels in one
117
+ of the two so the merge's grid/city reconciliation cannot split it — see
118
+ `extensions/lookups/call-history/src/index.ts`.
119
+
120
+ ## ✅ `recentContextLookup` — live-context lookups
121
+
122
+ Same `LookupHook` interface, run by the same orchestrator immediately after
123
+ the `lookup` fan-out and folded through the same merge — but **only when the
124
+ request sets `recentContext`**, which the logging panel does solely while
125
+ composing a new contact.
126
+
127
+ The split is about what the answer depends on. A `lookup` hook answers *who
128
+ is this callsign*, which is true whenever asked. A `recentContextLookup` hook
129
+ answers *what is this station doing right now* — from spots, from the last
130
+ minutes of the log, from whatever else is live — and that is a question about
131
+ a contact in progress. Asked about a QSO logged an hour ago, or about every
132
+ callsign on the spots board, the answer is not just useless but wrong.
133
+
134
+ Registering here rather than under `lookup` is therefore correctness first
135
+ and economy second, though the economy is real: the spots board runs a lookup
136
+ per distinct spotted call, and the background queue one per saved QSO, so an
137
+ ordinary `lookup` hook is asked hundreds of times for answers nobody reads.
138
+
139
+ Implemented by: `spot-history` (offline — `refs` from the host's recent
140
+ spots, see `ctx.getSpotsForCall`).
141
+
142
+ ## ✅ `lookupService` — the lookup orchestrator
143
+
144
+ One hook call resolves an entire callsign decoration pass: the hardcoded
145
+ callsign-annotation pre-step (via `@ham2k/lib-country-files`, always first,
146
+ regardless of any other hook's priority) plus every registered `lookup` hook
147
+ above — and, when `recentContext` is set, the `recentContextLookup` hooks
148
+ after them — merged according to the `locationScope` rules, all inside the
149
+ runtime, mirroring `scoring`'s "one coarse call in, everything back out"
150
+ shape.
151
+
152
+ ```ts
153
+ interface LookupServiceHook {
154
+ lookupCallInfo(
155
+ args: { callInfo: CallInfo; qso; operation; recentContext?: boolean },
156
+ ctx,
157
+ ): Promise<{ lookups: CallInfoLookup[]; guess: CallInfo }>
158
+ }
159
+ ```
160
+
161
+ **locationScope merge rules** (`CallInfo.locationScope`, ranked
162
+ `qth < prefixed < portable`, most general to most specific):
163
+ - A result whose `locationScope` is strictly more specific than the current
164
+ guess's clears all location fields (`locSource, lat, lon, grid, city,
165
+ state, county, country`) before merging the result in, and the guess
166
+ adopts the more specific scope.
167
+ - Otherwise, priority order applies as usual: a field already present in the
168
+ guess is never overwritten by a later (lower-priority) result. A
169
+ location field additionally requires the result's own scope to be at
170
+ least as specific as the guess's current scope — a more general-scoped
171
+ result can never populate a slot a more specific one already claimed,
172
+ even if that slot is currently empty.
173
+ - Grid/lat-lon reconciliation, scoped to fields the result actually set:
174
+ grid-only → lat/lon computed from the grid; lat/lon-only → a 6-digit grid
175
+ computed from them; both given → taken verbatim. Setting grid/lat/lon
176
+ clears city/state/country/county unless the *same* result also supplies
177
+ them; conversely, setting city/state/country (without county, and without
178
+ also setting grid/lat/lon in the same result) clears grid/lat/lon.
179
+ - `notes`/`history` are per-`CallInfoLookup` fields, not part of the merged
180
+ `guess` — they simply accumulate, unconditionally, in the returned
181
+ `lookups[]` array.
182
+ - **`locSource: 'prefix'` is reserved**, and a `lat`/`lon` merged under it is
183
+ never read as a coordinate (`locationFromGuess`, halo_core). It marks
184
+ lib-country-files' DXCC entity centroid, whose longitude follows cty.dat's
185
+ west-positive convention — the opposite of every other coordinate here. A
186
+ source reporting a real position must leave `locSource` unset or name
187
+ itself; tagging one `'prefix'` gets its coordinate discarded.
188
+
189
+ Implemented by: `lookups` (key `core`). The merge algorithm itself
190
+ (`mergeLookupIntoGuess`/`runLookupPipeline` in
191
+ `extensions/core/lookups/src/mergeLookup.ts`) is a pure, host-independent
192
+ function with its own unit test suite
193
+ (`extensions/core/lookups/src/mergeLookup.test.ts`, run via `npm test` in
194
+ `extensions/`) — this is the highest-defect-risk logic in the feature, so it
195
+ is tested in isolation from the hook/bridge plumbing.
196
+
197
+ ## ✅ `spots` — spot sources and spotting (ham2k/halo-dist#9, #10)
198
+
199
+ ```ts
200
+ interface SpotsHook {
201
+ sourceName: string
202
+ fetchSpots(args: {}, ctx): Promise<Spot[]>
203
+ isSelfSpotEnabled?(args: { operation }, ctx): Promise<SpotEligibility>
204
+ isOtherSpotEnabled?(args: { qso }, ctx): Promise<SpotEligibility>
205
+ postSelfSpot?(args: PostSelfSpotRequest, ctx): Promise<PostResult>
206
+ postOtherSpot?(args: PostOtherSpotRequest, ctx): Promise<PostResult>
207
+ }
208
+ // Spot: {their: {call}, freq?, band?, mode?, refs?: Ref[],
209
+ // spot: {timeInMillis, source, label?, sourceInfo?}}
210
+ // SpotEligibility: {enabled, icon?}
211
+ // PostResult: {ok, message?}
212
+ // PostSelfSpotRequest: {operation, freq, mode?, comment?}
213
+ // PostOtherSpotRequest: {qso, comment?, spotterCall?}
214
+ ```
215
+
216
+ Map upstream spots into QSON-shaped spot objects; filter out QRT/stale
217
+ entries at the source. `Spot` may also carry an `icon` (MDI/FontAwesome
218
+ name, same convention as `activity` controls). Implemented by: `pota`,
219
+ `sota` (SOTAWatch) — fetch only.
220
+
221
+ The four posting methods are all optional; a hook that omits them just
222
+ never appears in the Spotting control's icon row (same "silently absent"
223
+ convention as `export`'s `suggestExportOptions`). `isSelfSpotEnabled`/
224
+ `isOtherSpotEnabled` are checked once to decide whether to show a source's
225
+ icon at all (e.g. only when the operation/QSO has a matching activation
226
+ ref); `postSelfSpot`/`postOtherSpot` are only called for sources that
227
+ reported `enabled: true`. Implemented by: `pota` (self + other, posts
228
+ directly to the POTA API, no auth required). `sota`'s spot API requires an
229
+ authenticated account app-halo doesn't have yet, so `sota` stays
230
+ fetch-only for now.
231
+
232
+ ## ✅ `export` — log file generation
233
+
234
+ ```ts
235
+ interface ExportHook {
236
+ suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
237
+ generateExport(args: ExportRequest, ctx): Promise<ExportResult>
238
+ }
239
+ // ExportOptionsRequest: {operation, qsos, compactFilenames?}
240
+ // ExportOption: {exportType, format, label, filename?, icon?, priority?, selectedByDefault?}
241
+ // ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
242
+ // ExportResult: {filename, mimeType, content}
243
+ ```
244
+
245
+ Two-step flow: the Exports Panel calls `suggestExportOptions` on every
246
+ `export` hook to build its selectable list (a hook that omits it never
247
+ appears in the panel), then calls `generateExport` — passing back the
248
+ chosen option's `exportType` — only for the options the user selected. The
249
+ core owns file I/O and save/share; the hook only produces content.
250
+ Exporters should compose per-QSO program fields from `adifFields` hooks (see
251
+ below) rather than knowing about specific activities. Implemented by: `adif`
252
+ (key `adif`, one option); Cabrillo will register here too.
253
+
254
+ **Filenames.** Build them with the SDK's `exportFilename` rather than
255
+ spelling one out, passing the request's `compactFilenames` straight through —
256
+ that is what makes the user's "Use compact file names" setting apply to your
257
+ export, and what keeps every extension's names consistent:
258
+
259
+ ```ts
260
+ filename: exportFilename({
261
+ stationCall: operation.stationCall,
262
+ ref: 'US-1234', // or `activity: 'CQ-WW-CW'`, or neither
263
+ startAtMillis: startMillisOf(operation, qsos),
264
+ extension: 'adi',
265
+ compact: args.compactFilenames,
266
+ })
267
+ // normal → "2026-07-27 N0CALL at US-1234.adi"
268
+ // compact → "N0CALL@US-1234-20260727.adi"
269
+ ```
270
+
271
+ Set `filename` on the OPTION, not just on the result: the panel shows the
272
+ option's name, and the core writes the file under it. A name computed again
273
+ in `generateExport` sees a different input — that call is handed only the
274
+ QSOs the option's `refType` covers — so the two would otherwise disagree in
275
+ front of the user.
276
+
277
+ ## ✅ `ref:<type>` — reference handlers
278
+
279
+ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
280
+ following app-polo's QSON conventions. Handles validation and enrichment of
281
+ activity references on QSOs and operations.
282
+
283
+ ```ts
284
+ interface RefHandlerHook {
285
+ validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
286
+ decorateRef?(args: { ref: Ref }, ctx): Promise<Ref> // may hit the network
287
+ suggestOperationTitle?(args: { ref: Ref; operation }, ctx): Promise<TitleSuggestion | null>
288
+ linkForRef?(args: { ref: Ref }, ctx): Promise<RefLink | null> // {url, label?}
289
+ geojsonUrlForRef?(args: { ref: Ref }, ctx): Promise<RefGeojson | null>
290
+ }
291
+ // Ref: {type, ref?, name?, label?, shortLabel?, grid?, location?, program?, ...}
292
+ // TitleSuggestion: {title?, for?, at?, subtitle?, priority?} — a fragment of
293
+ // the operation's title/subtitle sentence (ham2k/halo-dist#174). Only
294
+ // return one for the ref type that means "this operation is happening AT/FOR
295
+ // this reference" (e.g. an activation, not a hunted ref) — a hunting ref
296
+ // contributes nothing here. `priority` breaks ties when multiple refs supply
297
+ // the same fragment kind (higher wins); default 0.
298
+ ```
299
+
300
+ `decorateRef` fills names/locations/labels (park names via the POTA API,
301
+ etc.); results update ref chips asynchronously in the UI. Implemented by:
302
+ `pota` (both types).
303
+
304
+ `linkForRef` says where a reference can be read about on the web — the
305
+ program's own page for it (POTA's park page, SOTA's summit page), or, for a
306
+ contest, the rules the event is run under. The Operation Setup sheet asks for
307
+ one link per ref every time the operation's refs change and renders a web
308
+ button on the row; **nothing is stored on the ref**, so a program that moves
309
+ its URLs takes effect with the extension rather than needing saved operations
310
+ rewritten. Build the URL from the reference and return: this hook must not hit
311
+ the network. Return `null` for a reference the program would not accept (a
312
+ half-typed code links to a page that cannot exist) and implement nothing at all
313
+ for a program whose list is a download with no page per reference — a link into
314
+ a search form is worse than no link, and the row simply shows no button.
315
+ `label` names the destination for assistive technology, wrapped in the app's
316
+ own localized phrasing — set it only where the destination is NOT the row's own
317
+ subject, as a contest's rules are; leave it off for a reference's own page and
318
+ the app names the row instead. A handler registered for another program's ref
319
+ type (WCA answers for `ecaActivation`) must leave it off: a label built from
320
+ its own program name reads the wrong program aloud. Programs built on the SDK's `referenceActivity` factory get this
321
+ from one `linkUrl` config line.
322
+
323
+ `geojsonUrlForRef` says where the reference's own footprint can be fetched as
324
+ GeoJSON — a SOTA summit's activation zone, and whatever equivalent another
325
+ program publishes. Same contract as `linkForRef`: build the URL from the
326
+ reference, **never hit the network**, return `null` for a reference the program
327
+ has no shape for. The core does the fetching and the caching (`GeoshapeService`),
328
+ so an extension never decides how long a device keeps a file or how large one may
329
+ be; it only names the two ages the core should use:
330
+
331
+ ```ts
332
+ // RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?}
333
+ ```
334
+
335
+ `maxAgeInDays` (default 30) is how long a fetched shape stays usable offline —
336
+ boundaries move on the scale of surveys. `missMaxAgeInDays` (default 1) is how
337
+ long the core remembers that a reference has NO shape, deliberately shorter so a
338
+ newly surveyed zone appears within a day rather than a month. Both are ignored
339
+ unless positive: a zero would re-fetch on every rebuild of the map.
340
+
341
+ The URL must be `https`, and the core does not follow redirects to reach it.
342
+ Unlike a link, this one is fetched rather than handed to the platform, and a
343
+ plaintext one — named outright, or arrived at through a `Location:` header —
344
+ would downgrade every activator's connection on a program's say-so.
345
+
346
+ Shapes are asked for sparingly: the Map tab asks about the operation's own
347
+ references, and the Activities pane's results map about those plus the one
348
+ suggestion whose card is open. Neither fetches a shape per search hit — a search
349
+ answers with dozens of references, and at the zoom that shows dozens of them a
350
+ footprint is a few pixels across. Implemented by: `sota` (both types, via
351
+ SOTLAS's `az.sotl.as`, used with the operators' permission).
352
+
353
+ `suggestOperationTitle` lets an activity build a natural-language operation
354
+ title/subtitle from its refs — "at Yosemite NP" (POTA), "for Field Day"
355
+ (contest programs, when ported) — mirroring app-polo's
356
+ `buildOperationTitle`. The core (`OperationTitleService`) calls this once per
357
+ ref, groups the non-null results by fragment kind (`title`/`for`/`at`,
358
+ highest `priority` first within a kind), and joins them with a **localized**
359
+ "for"/"at" — unlike app-polo, which hardcodes those joiner words in English;
360
+ HaLo threads them through `strings.g.dart` instead, so extensions only ever
361
+ return proper nouns/names (locale-agnostic or self-translated via their own
362
+ `tFor`), never the connective prose. Implemented by: `pota`, `sota`
363
+ (activation ref types only).
364
+
365
+ ## ✅ `activity` — declarative UI contributions
366
+
367
+ The UI-catalog seam (DESIGN.md §5): activities describe controls as data;
368
+ the core renders them with native widgets and calls back into ref handlers
369
+ for behavior.
370
+
371
+ ```ts
372
+ interface ActivityHook {
373
+ // `qso` is the in-progress QSO, including `their.guess` from the callsign
374
+ // lookup — re-supplied when a lookup resolves, so a control can key a
375
+ // dynamic label/placeholder/suggestedValue off who's being worked.
376
+ loggingControls?(args: { operation, qso? }, ctx): Promise<LoggingControlDescriptor[]>
377
+ operationControls?(args: { operation, qso? }, ctx): Promise<LoggingControlDescriptor[]>
378
+ suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
379
+ processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
380
+ }
381
+ // LoggingControlDescriptor: {key, label, icon?, color?, order?, optionType?,
382
+ // allowsMultiple?, editable?, input}
383
+ // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
384
+ // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
385
+
386
+ // The input catalog. A new kind is earned by a different WIDGET, never by
387
+ // different validation — so a pattern-constrained field is `text` with a
388
+ // `pattern`, not a kind of its own (docs/design/contests.md §5.3).
389
+ // {kind: 'refList', refType, placeholder?, pattern?, transforms?}
390
+ // {kind: 'text', refType, field, placeholder?, pattern?, transforms?,
391
+ // numeric?, uppercase? /* default true */, maxLength?,
392
+ // suggestedValue?}
393
+ // {kind: 'options', refType, field, options: [{code, name?}], matchOn?,
394
+ // allowFreeform?, preferredCodes?, minCharsForSuggestions?,
395
+ // placeholder?, pattern?, transforms?, numeric?, uppercase?,
396
+ // maxLength?, suggestedValue?}
397
+ // {kind: 'serial', refType, field, sequence: {key, scope?, start?, padTo?},
398
+ // editable?}
399
+ // {kind: 'form', refType, form} // operationControls only
400
+ //
401
+ // `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
402
+ // kind; a malformed regex disables validation rather than throwing. A
403
+ // mismatch tints the field but still accepts the value.
404
+ //
405
+ // `text`, `options` and `serial` values round-trip onto a ref of `refType` under
406
+ // `field`, through the same core-owned path refList writes refs — several may
407
+ // share one refType and are merged into a single ref. `suggestedValue` fills the
408
+ // field only while the operator hasn't typed into it. A `serial`'s number is
409
+ // allocated by the core (docs/design/contests.md §5.8), and all three kinds
410
+ // render as PRIMARY fields, shown only when the operation is running that
411
+ // activity.
412
+ //
413
+ // An `options` field searches a KNOWN SET: it shows a ranked suggestion line and
414
+ // Space accepts the top hit before advancing, and a value outside the set tints
415
+ // the field unless `allowFreeform`. Space accepts only a suggestion the typed
416
+ // text is a PREFIX of, and never from an empty field — a substring hit is worth
417
+ // showing, but swapping one in rewrites the operator's own text into something it
418
+ // isn't the start of. The suggestion line is added to the info area, above
419
+ // whatever it was already showing, so a DUPE stays visible while an exchange
420
+ // field has focus. The set is inline — §5.3's registered `optionSet` is deferred
421
+ // (nothing can write rows into the lookups table today).
422
+ // `text`, `serial` and `options` are logging-panel kinds: operation settings
423
+ // render `refList` and `form` and silently ignore the rest.
424
+ //
425
+ // `optionType` is NOT yet honored by the core.
426
+ ```
427
+
428
+ `icon` is a Material Design Icons name (e.g. `'pine-tree'`), or a FontAwesome6
429
+ solid-style name prefixed with `'fa-'` (e.g. `'fa-tree'`) — same convention
430
+ app-polo itself uses. Any name in either set works with no core-repo change;
431
+ an unrecognized name falls back to a placeholder glyph rather than erroring.
432
+
433
+ `color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
434
+ may use sparingly to highlight this control's icon — a tinted circle behind
435
+ it in the Activities picker, or the icon itself once a reference is entered
436
+ in the logging panel's secondary pills. Missing or unrecognized values just
437
+ mean no accent.
438
+
439
+ `processQsoBeforeSave` is the last chance to shape a QSO before it is written,
440
+ for projecting data the extension owns into the generic QSON fields the rest of
441
+ the app reads. A contest keeps its exchange on its own ref (`theirZone`,
442
+ `theirSerial`, …), which nothing generic knows how to read — mirroring it into
443
+ `their.exchange` is what fills the QSO row's exchange column and a plain ADIF
444
+ export. Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
445
+ `their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
446
+ field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
447
+ refs are shared with the core and with other activities. Return null to do
448
+ nothing. Called on every save path (single QSO, call list, call stack, edit),
449
+ and skipped entirely when the operation has no refs, so ordinary logging pays
450
+ no bridge crossing. A serial the CORE allocates at commit is NOT visible here
451
+ for the second and later calls of a call list (docs/design/contests.md §5.8).
452
+
453
+ `loggingControls` appear on the logging panel (per-QSO refs);
454
+ `operationControls` appear in the operation's Activities dialog (e.g. "which
455
+ park am I activating"). Catalog components are versioned core code —
456
+ proposing a new `input.kind` is a core PR, deliberately (DESIGN.md §5's
457
+ "catalog creep" risk). Implemented by: `pota`.
458
+
459
+ `suggest` powers the Activities view's "add activity" search: called with a
460
+ `location`/`callsign` the core resolves from the operation if set, else the
461
+ station defaults (extensions never need to know which). Called with no
462
+ `searchTerm` for nearby suggestions, or with one for name/code search — an
463
+ extension may honor either, both, or neither (returning `[]`). `scoped` is
464
+ true when THIS extension alone was asked — the operator tapped its "Activity
465
+ Types" row or typed its key as a scope — which a bare scope otherwise cannot be
466
+ told from the nearby list, since neither carries a `searchTerm`. An extension
467
+ that keeps itself out of the nearby list (`cwt`, offered only around a session)
468
+ answers a scoped call anyway: it is the one being asked. The core fans
469
+ out to every `activity` hook via `invokeHookAll`, merges results, and ranks them
470
+ in TWO BANDS (`app/lib/tools/activity_suggestions.dart`): everything carrying a
471
+ `distance` first, by `distance / relevance` ascending — a more relevant hit
472
+ counts as nearer — then everything without one, by DESCENDING `relevance`, with
473
+ ties broken on the order the extension emitted them. So `relevance` alone orders
474
+ a set of placeless suggestions (dated contest events, say) among themselves, but
475
+ never lifts one above a located park. Implemented by: `pota`, `sota`,
476
+ `stateparks`, `cwt`.
477
+
478
+ The activity search box also treats a leading `key:` as a scope — typing
479
+ `pota:` (or tapping a row under "Activity Types") asks that extension alone,
480
+ via a key-scoped `invokeHook` rather than the usual fan-out. For that to
481
+ work, an extension's `activity` hook must be registered under its own
482
+ **extension key**, and its control descriptors keyed `<extensionKey>/<name>`
483
+ (`pota/activation`) — the core derives the scope from the part before the
484
+ slash. An extension that deviates simply never gets scoped: its rows still
485
+ add references, but `key:` would ask a hook that doesn't exist and come back
486
+ empty.
487
+
488
+ `editable` declares whether a reference of this type has anything to edit
489
+ once it's on the operation. It defaults to **false**: a plain reference
490
+ activity carries only the code its own `suggest` already resolved, so the
491
+ Current Activities row stays inert — the user removes it and picks again
492
+ rather than retyping a code no lookup would re-check. `pota` and `sota` leave
493
+ it off. Set it true for a type whose references carry more than the code.
494
+
495
+ A **`kind: 'form'` control prompts on add.** Tapping one of its suggestions
496
+ saves the reference and then opens the setup form over it, because a contest
497
+ that has not been set up cannot score and nothing on its row says so. The
498
+ save comes first, so cancelling the form leaves the activity added and
499
+ unconfigured rather than undoing the tap. Two consequences for the hook:
500
+
501
+ - The suggestion still has to carry its own `label`/`shortLabel`/`program`
502
+ (§5.4 of contests.md) — the cancel path never reaches `decorateRef`, so a
503
+ suggestion that relies on being decorated reads as a blank row.
504
+ - `decorateRef` runs on whatever the form submits, immediately after. It is
505
+ handed the suggestion's own fields merged with the form's, so it must
506
+ tolerate being called with a reference it has already decorated once.
507
+
508
+ A `refList` control does **not** prompt: its suggestion already carries the
509
+ code that identifies it, so a form could only ask the user to retype what
510
+ they just picked.
511
+
512
+ `allowsMultiple` declares whether an operation may carry more than one
513
+ reference of that type at a time. It defaults to **false**: picking one of
514
+ the type's references **replaces** the operation's existing reference of
515
+ that type rather than adding alongside it. A type that genuinely stacks
516
+ declares `allowsMultiple: true` — `pota` does, for n-fers. Declare it on the
517
+ suggestion (for the search/nearby list) *and* on the type's
518
+ `LoggingControlDescriptor` (for references the user types in by hand); the
519
+ core reads the suggestion's value first and falls back to the control's.
520
+ Selecting a reference the operation already has is still a duplicate, not a
521
+ replacement — the core flashes the existing row instead of adding it twice.
522
+
523
+ ## ✅ `adifFields` — per-QSO export field contributions
524
+
525
+ How activities inject program fields into ADIF exports without the exporter
526
+ knowing them. Called by the `adif` extension **inside the runtime** via
527
+ `hooks.invokeAll` — no bridge crossing.
528
+
529
+ ```ts
530
+ interface AdifFieldsHook {
531
+ fieldsForOneQSO(args: { qso, operation }, ctx): Promise<{name, value}[]>
532
+ // One field set PER ADIF RECORD this contact should produce.
533
+ fieldCombinationsForOneQSO?(args: { qso, operation }, ctx): Promise<{name, value}[][]>
534
+ }
535
+ ```
536
+
537
+ POTA contributes `SIG/SIG_INFO/POTA_REF` (hunted refs on the QSO) and
538
+ `MY_SIG/MY_SIG_INFO/MY_POTA_REF` (activation refs on the operation).
539
+
540
+ `fieldCombinationsForOneQSO` exists because **one contact can owe several ADIF
541
+ records**: work a station standing in two parks and each park is submitted
542
+ separately, which is also why it counts twice toward an activation
543
+ (docs/design/activities.md §4). Return one field set per record; the exporter
544
+ writes that many, shifting each successive record's `QSO_DATE`/`TIME_ON` by a
545
+ second so otherwise-identical records aren't read as duplicates (app-polo's
546
+ `qsonToADIF.js:144` does the same).
547
+
548
+ Record count is the largest any hook asks for. A hook returning a single set
549
+ is describing the *contact*, so its fields go on every record; only a hook
550
+ returning several is describing individual records. Declaring both methods is
551
+ fine and expected — a hook that does is asked only for its combinations, so
552
+ its plain fields aren't written twice. Implemented by: `pota`.
553
+
554
+ **Only the export's OWN hook is asked.** A file submitted to one program is
555
+ that program's log, so an export that delegates the ADIF build names itself as
556
+ `mainHandler` in the `ExportRequest` — app-polo's "main handler"
557
+ (`qsonToADIF`'s `handler`) — and the `adif` extension invokes that hook alone,
558
+ not the whole category. Activate a park that is also a lighthouse and each
559
+ file claims its own reference; ask every hook and both programs'
560
+ `MY_SIG`/`MY_SIG_INFO` pairs land in every record, leaving the receiving
561
+ program to guess which is its. The delegate cannot work out who called it, so
562
+ naming yourself is not optional.
563
+
564
+ The full ADIF export names no `mainHandler` and every hook contributes —
565
+ polo's `includeOtherRefs`, which only its full export sets. That is the export
566
+ claiming no program: the complete copy the operator keeps.
567
+
568
+ An export can also name a **list** of other hooks it accepts,
569
+ `includeFieldsFrom` — polo's all-or-nothing flag narrowed to what the program
570
+ itself says it reads. It is for what DESCRIBES a contact rather than claiming
571
+ it: POTA's exports list `satellites`, because a park worked through a bird is
572
+ still a park activation and POTA's uploader reads `PROP_MODE`/`SAT_NAME`. A
573
+ listed key that answers nothing is fine — the operator may have that extension
574
+ off — where a `mainHandler` that answers nothing is an error, since that file
575
+ would claim no reference at all.
576
+
577
+ **A field name is written once per record, and the first to carry a value
578
+ wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
579
+ the same name — the full export asks everyone, and a park and a summit both
580
+ answer `MY_SIG` — the later one is dropped, and the contact's own fields
581
+ outrank all of them. Hooks are asked main handler first, then each
582
+ `includeFieldsFrom` key in the order given, so that order decides — whichever
583
+ of the two methods each hook answered. app-polo resolves a collision the same
584
+ way ("keep the first one defined"). The **full export**, which asks everyone,
585
+ has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
586
+ group and hooks answering `fieldsForOneQSO` as another, so which of two programs
587
+ keeps a shared `MY_SIG` there is not something either of them chose.
588
+
589
+ Because the winner is picked per field name, **answer a pair whole or not at
590
+ all**: a hook offering `MY_SIG` without `MY_SIG_INFO` takes the name and leaves
591
+ the next hook's reference under it, which claims another program's park for
592
+ yours. `custom` is the one activity whose two halves are free text, and it
593
+ drops both when either is empty.
594
+
595
+ ## ✅ `adifImport` — per-QSO reference recovery on import
596
+
597
+ The inverse of `adifFields`. The core's ADIF parser
598
+ (`packages/halo_core/lib/src/adif/`) maps **universal ADIF only** and produces
599
+ **no references at all** — `SIG`/`SIG_INFO`, `SOTA`, `IOTA`, `CONTEST_ID` and
600
+ every `*_REF` field mean nothing to it. This hook is the entire ref vocabulary
601
+ on import, not a refinement of a core guess. An import with no `adifImport`
602
+ hook registered yields ref-less QSOs.
603
+
604
+ ```ts
605
+ interface AdifImportHook {
606
+ refsForRecords(
607
+ args: { records: { fields: Record<string, string> }[] },
608
+ ctx: HookContext,
609
+ ): Promise<({ refs?: Ref[] } | null)[]>
610
+ }
611
+ ```
612
+
613
+ `fields` is the record's raw ADIF, names lowercased and values entity-decoded.
614
+
615
+ **Batched, unlike every other per-QSO hook.** `adifFields` is called by the
616
+ `adif` extension *inside the runtime*, so a call per QSO costs nothing there.
617
+ This one is driven from Dart (`app/lib/services/adif_import_service.dart`), so
618
+ each call is a bridge crossing — a ten-thousand-contact import is twenty
619
+ crossings, not ten thousand. Batch size is 500.
620
+
621
+ Return **one entry per record, positionally**; `null` for a record the hook has
622
+ nothing to say about. Never filter the empties out: the core matches results
623
+ back to records by index, and a shortened array would land one QSO's references
624
+ on another. A reply whose length doesn't match the batch is discarded whole for
625
+ that reason. Refs from different hooks accumulate (a park-to-park contact made
626
+ during a summit activation legitimately carries both), deduped by `type`+`ref`.
627
+
628
+ A returned ref may carry **`for: 'operation'`** to say it belongs on the
629
+ operation rather than the QSO — an activation. ADIF has no operation, so it
630
+ writes the activation onto every record, and a hook only ever sees one record;
631
+ tagging is how it says where the reference actually goes. The importer moves it
632
+ and **consumes the tag**, since once the reference sits on the operation its
633
+ position is the answer. Untagged means the QSO. Without this the only test
634
+ available to the importer is "every record carries it", which cannot tell an
635
+ activation from a short log where everyone hunted the same reference.
636
+ `activityAdifImport` tags the activation side for you.
637
+
638
+ Returning an object rather than a bare `Ref[]` leaves room for the other things
639
+ an importer may eventually owe a QSO — a contest serial out of `STX`/`SRX`,
640
+ an exchange — as an additive change.
641
+
642
+ Most reference programs write the same `SIG`/`SIG_INFO`/`MY_SIG`/`MY_SIG_INFO`
643
+ pair plus their own comma-separated `*_REF` list, so the SDK's
644
+ `activityAdifImport({sig, huntingType, activationType, refField, normalize})`
645
+ builds the whole hook.
646
+
647
+ **A reference the program's own pattern rejects is imported anyway**, as
648
+ written. By the time `normalize` runs, the file has already said whose the
649
+ reference is — the `*_REF` list field is program-specific, and `SIG_INFO` is
650
+ gated on `SIG` — so a pattern failure means *malformed*, not *foreign*.
651
+ `decorateRef` labels it invalid, which puts it in front of the operator to fix;
652
+ discarding it would lose the only text a correction could work from, and lose it
653
+ without a word. `normalize` still repairs what it can (POTA turns a dashless
654
+ `us1234` into `US-1234`); only its failures fall through as raw text. The same
655
+ rule governs `satellites`, which imports a bird it cannot resolve to a
656
+ transponder rather than dropping the QSO's satellite.
657
+
658
+ It reads **both** sources and merges them, deduped on the normalized value.
659
+ Neither subsumes the other: `*_REF` lists every reference but not every program
660
+ writes it (SOTA's own exports carry `SOTA_REF` and no `SIG` at all), while
661
+ other software writes the same reference only as `SIG`/`SIG_INFO`. The union is
662
+ safe *because* it dedupes — taking `SIG_INFO` in preference to the list would
663
+ import an n-fer as a one-fer, since `SIG_INFO` names only the primary
664
+ reference; taking both cannot, because the primary is already in the list. The
665
+ `SIG` value is still checked before `SIG_INFO` is claimed, or every program
666
+ would import every other program's references.
667
+
668
+ `referenceActivity()` returns a ready-built `adifImportHook` from the config it
669
+ already has, so an activity using that factory opts in with one `registerHook`
670
+ line. Implemented by every activity except two:
671
+
672
+ - **`satellites`** writes `SAT_NAME`/`BAND_RX` and no `SIG` at all, so it
673
+ hand-writes its own. Its reference is a *transponder* while ADIF names only
674
+ the bird, so the hook recovers the transponder where it can — narrowing by
675
+ `BAND_RX` (the downlink) and the record's own `BAND`/`FREQ` (the uplink) —
676
+ and otherwise imports the bare satellite the file named. It validates
677
+ nothing: an unknown bird, a contradictory `BAND_RX` and a record with no band
678
+ all import. The reference degrades cleanly, since `decorateRef` falls back to
679
+ the whole string and the exporter writes `SAT_NAME` from the same segment.
680
+ - **`custom`** deliberately registers none. Its `SIG` is a program name the
681
+ *operator* typed, so there is no constant to match, and a hook claiming every
682
+ `SIG` would add a spurious reference to every other program's records.
683
+ "Claim the `SIG`s nobody else claimed" is the rule that works and it is not
684
+ expressible in a hook — these run independently and additively, so none can
685
+ see what the others took. It belongs to the importer.
686
+
687
+ ## ✅ `command` — call-field text shortcuts
688
+
689
+ Space in the logging panel's call field tries the typed text as a command
690
+ before falling back to "advance focus." The core does no matching itself —
691
+ it fans out to **every** registered `command` hook via `invokeHookAll` and
692
+ applies the first non-null result, in priority order.
693
+
694
+ ```ts
695
+ interface CommandHook {
696
+ interpret(
697
+ args: { input: string; operation?; qso?; execute? },
698
+ ctx,
699
+ ): Promise<CommandInterpretation | null>
700
+ catalog?(ctx): Promise<CommandCatalogEntry[]>
701
+ }
702
+ // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
703
+ // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
704
+ // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
705
+ // sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
706
+ // updateOperation?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
707
+ // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
708
+ // devTimeTravel?, toggleExperiment?}
709
+ ```
710
+
711
+ Each hook does its own matching internally and returns `null` for "not mine"
712
+ — the host never compiles extension-supplied regex source itself. A match
713
+ returns an interpretation the core previews (`describe`) and, on confirm,
714
+ applies: state changes go only through the closed `CommandAction` field set
715
+ (DESIGN.md §4's "whitelisted field-change operations"), never arbitrary
716
+ state. `expectsParams: true` is the "recognized prefix, keep typing" signal
717
+ described below.
718
+
719
+ A command that IS recognized but can't run in the current context returns
720
+ `{ error: '…' }` instead of matching silently or pretending to succeed —
721
+ e.g. the operation-scoped commands (SEED, NOTE, SPOT/SPOTLAST) typed into
722
+ the home screen's quick search, where no operation is open. The UI presents
723
+ the error message, never executes the interpretation, and leaves the typed
724
+ input in place for editing. Commands that work anywhere (DEVMODE, RELOAD,
725
+ band/frequency/mode…) never need it.
726
+
727
+ `interpret` is called on **every** keystroke pause (the call field's 150ms
728
+ typing-debounce, for the live `describe` preview), not just once when the
729
+ command actually runs — so a hook whose `commands` are expensive to build
730
+ must not compute them unconditionally. `execute: true` is the signal that
731
+ this particular call IS the one about to run the command (the host makes a
732
+ final `interpret` call with it set, right before dispatching `commands`,
733
+ discarding whatever a stale preview may have returned); it's false/undefined
734
+ on every describe-preview call. A hook with cheap `commands` can ignore this
735
+ entirely. `SEED` (below) is the one hook that needs it: skips its `ctx.getQsos`
736
+ read and per-generated-QSO lookups — real bridge/network-ish work — unless
737
+ `execute` is set, returning a cheap describe-only result otherwise.
738
+
739
+ A hook whose complete result needs a network fetch doesn't have to block
740
+ `interpret`'s own returned Promise on it — even on a describe-preview call.
741
+ Return quickly with a placeholder `describe` ("Fetching…"), kick the fetch
742
+ off in the background, and once it resolves call:
743
+
744
+ ```ts
745
+ host.updateInterpretation(input: string, interpretation: CommandInterpretation): void
746
+ ```
747
+
748
+ One-way, like `host.log` — no response ever comes back, nothing to await.
749
+ `input` must be the same `input` the triggering `interpret` call received;
750
+ the host discards a push whose `input` no longer matches the live call field
751
+ (the user typed past it), the same way it already discards a stale
752
+ describe-preview result. This is how `annotation-commands`' SOLAR/WEATHER
753
+ (below) show their fetched reading in the UI before the user ever presses
754
+ Enter, without either blocking every keystroke-debounce call on a live
755
+ fetch or requiring a second command invocation.
756
+
757
+ Note that only the logging panel consumes those pushes today — the ⌘K palette
758
+ and the Home search bar do not — so a hook that needs its describe on every
759
+ surface must resolve it inside `interpret` instead. `settings-commands` awaits
760
+ its host call for that reason.
761
+
762
+ A command that needs to reach the app's SETTINGS asks rather than carrying its
763
+ own list of them:
764
+
765
+ ```ts
766
+ host.searchSettings({ args: string, kind?: 'set' | 'show' | 'hide' }): Promise<SettingsSearchResult>
767
+ ```
768
+
769
+ `args` is everything the operator typed after the command word, whole: where
770
+ the setting's name ends and its value begins is decided app-side, because only
771
+ the settings catalog can tell `SET AVOID ZERO BEAT N` (a four-word name plus a
772
+ value) from `SET THEME DARK` (a one-word name plus a value). Matching is by
773
+ subsequence over each setting's key, aliases and label, and the catalog is
774
+ derived from the app's declarative settings registry — so it reaches every
775
+ setting the settings screen would currently show, and only those (`devMode`,
776
+ `environment` and `visibleWhen` all apply). The result carries the setting's
777
+ label, section, current value and the coerced new value, each already
778
+ localized; a `secret` field's value is never included. Write the result back
779
+ with the `updateSetting` action, or open the settings form with `openSettings`.
780
+
781
+ This call re-enters the extension runtime while the calling hook is still
782
+ awaiting it (reading every `settingsPanel` is part of building the catalog).
783
+ That is safe by construction — hook calls are keyed by id with independent
784
+ completers and no lock — but it is the only host call that does so, and any
785
+ new one that re-enters should say so here.
786
+
787
+ Implemented by `radio-commands` (band/frequency/mode shortcuts — "20m ",
788
+ "14285 ", "14.285 ", "cw "), ported from app-polo's `RadioCommands`
789
+ extension. Notably, this replaced an earlier hand-rolled Dart regex for
790
+ frequency parsing that rejected valid MHz-style input ("14.285") because it
791
+ required 3+ digits before any decimal point; the extension delegates to
792
+ `@ham2k/lib-format-tools`' published `parseFreq` instead, which already
793
+ handles this correctly. Also implemented by `dev-commands` (DEVMODE toggles
794
+ developer mode; DEVNOTICE/DEVSTATUS/DEVPROGRESS post sample items to the
795
+ status bar via the `devNotice`/`devStatus`/`devProgress` action fields, for
796
+ exercising that UI without a real extension driving it; DEVHAMMER
797
+ (`devHammer`) churns the footer the way a data-file sync does — items
798
+ appearing, ticking and clearing — which is what provokes the web engine into
799
+ blurring a focused text field, and which the real thing only does on a cold
800
+ profile; DEVSPLASH re-shows
801
+ the startup release-notes overlay (Issue #42) via the `devSplash` action
802
+ field, bypassing its normal once-per-version gate — for previewing that
803
+ overlay on demand instead of only once per release; RELOAD forces a
804
+ `dataFile` re-sync via the `reloadDataFiles` action field — "RELOAD POTA"/
805
+ "RELOAD SOTA" target one activity's data file(s) by category, "RELOAD ALL",
806
+ "RELOADALL", or bare "RELOAD!" target every data file, and it bypasses ETag caching
807
+ since a manual reload is usually about our own `csvToLookupEntry` mapping
808
+ changing, not the source data; SEED fills the current log with generated
809
+ QSOs via `addQso` actions — "SEED" adds 10, "SEED 20" adds 20, "SEED 15 3H"
810
+ spreads 15 over the 3 hours up to now, or over the 3 hours *ending at the
811
+ earliest existing QSO* when the log's own time span is under a third of
812
+ that spread. Calls come from the Callsign Notes data — call-notes exposes
813
+ its parsed index to other extensions through an in-runtime `callNotes`/
814
+ `allCalls` hook — and are mutated 80% of the time by swapping the digit or
815
+ a suffix letter; RSTs lean heavily toward 55/57/59; each generated QSO gets
816
+ the lookup service's offline `their.guess` decoration. The placement rule
817
+ needs the existing log: SEED reads it through `ctx.getQsos`, using the
818
+ `uuid` the logging panel folds into the `operation` map it passes to
819
+ `interpret`; EXP toggles an experimental feature via the `toggleExperiment`
820
+ action field — the hook only parses "EXP <key-or-alias>", and the app
821
+ resolves the token against `app/experiments.yaml` and reports the outcome,
822
+ since the catalog and the stored per-experiment state both live app-side;
823
+ DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
824
+ "DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
825
+ anything that turns on the date or on elapsed time can be tested without
826
+ waiting for it. The hook only parses, and only while developer mode is on; the
827
+ app applies the offset, refuses it against a real sync account, and reports the
828
+ resulting time. Deliberately the one command here with NO `catalog`, so it can
829
+ never surface in the palette (docs/design/time.md)),
830
+ `annotation-commands` (ham2k/halo-dist#64, ported from
831
+ app-polo's AnnotationCommands: NOTE/TODO `<text>`, SOLAR, and WEATHER each
832
+ add an annotation row to the log — an event pseudo-QSO, `band: 'event'`,
833
+ via `addQso`, rendered as a yellow row by the QSO list. SOLAR fetches
834
+ hamqsl.com's solar indices and WEATHER fetches Open-Meteo conditions for
835
+ the operation's grid square — as soon as the command is recognized
836
+ (ham2k/halo-dist#189), not only at `execute`: `interpret` kicks the
837
+ fetch off in the background and returns a placeholder `describe`
838
+ immediately, then pushes the resolved reading via `host.updateInterpretation`
839
+ once it lands, so it shows in the UI before the user presses Enter. One
840
+ in-flight fetch is shared by every `interpret` call for the same command
841
+ until an `execute` call consumes it (reusing the same reading rather than
842
+ fetching twice) and clears it, so the next SOLAR/WEATHER starts fresh. The
843
+ raw readings land in the logged event's `data` and the emoji summary line
844
+ in its `description`. This superseded the original `note-command`
845
+ extension, whose NOTE wrote to the in-progress QSO's notes field instead of
846
+ the timeline), and `call-notes` (`//KEY`
847
+ expands a Callsign Notes entry whose note — once a leading heading OR emoji
848
+ marker (never both) is stripped — is a plain comma list of calls into the
849
+ call field via `setCallField` — issue #17; the call field explicitly excludes a field
850
+ STARTING with "//" from call stacking, or a note key that's itself a valid
851
+ callsign would also read as a stacked call, so the two syntaxes coexist. A key
852
+ stacked ALONGSIDE other calls reaches this hook too, but never as a whole-field
853
+ command: the call field asks again with just the segment under the cursor
854
+ ("//1AB") and splices the answer back into that segment, so the hook itself
855
+ needs to know nothing about stacks. See
856
+ [logging-fields.md](https://github.com/ham2k/halo/blob/main/docs/design/logging-fields.md) § "More than one call in the
857
+ field" for the order Enter resolves the two syntaxes in).
858
+ Future command hooks (spotting, operator change…) register in the same
859
+ category and compete on priority with these.
860
+
861
+ ### `catalog` — advertising commands to the ⌘K dialog
862
+
863
+ `interpret` answers *"what does this exact text mean?"* — one answer, for
864
+ text the user has already typed. The command dialog (⌘K, issue #55) needs the
865
+ other direction: *"what commands exist?"*, so it can list them before the
866
+ user knows the syntax. That's the optional `catalog(ctx)`, returning one
867
+ entry per command:
868
+
869
+ ```ts
870
+ { command: 'NOTE', params: '<text>', describe: 'Add a note to the log',
871
+ category: 'Log annotations', needsOperation: true, expectsParams: true }
872
+ ```
873
+
874
+ `command` is both what the palette matches against and what it types into the
875
+ field when the entry is chosen, so it has to be something `interpret`
876
+ recognizes. `params` is display-only. `describe` may use inline markdown
877
+ (`**bold**`, `*italic*`, `` `code` ``), the same as `interpret`'s own
878
+ `describe`/`confirm` — the dialog renders it as one ellipsized line rather
879
+ than as blocks, so keep it to a single sentence. `needsOperation` drops the row entirely when
880
+ no operation is open, instead of letting the user pick it and get
881
+ `interpret`'s `error` back. `expectsParams` leaves the field open for typing
882
+ rather than running the command immediately — the catalog counterpart of
883
+ `CommandInterpretation.expectsParams`, and the reason picking NOTE doesn't
884
+ log an empty note. `suggest` opts the entry into the empty-field "Try"
885
+ samples shown while the dialog is new; it's opt-in on purpose — the samples
886
+ go to users who don't yet know what a command does, so anything destructive,
887
+ heavyweight or developer-only must stay out. Absent means listed and
888
+ searchable, never volunteered.
889
+
890
+ Three things differ from `interpret`, all deliberate:
891
+
892
+ - **Called once and cached**, not per keystroke — dropped when the runtime is
893
+ replaced (an extension enabled/disabled) or the hook context is
894
+ invalidated (locale change, since `describe` is localized by the hook).
895
+ - **Matching is host-side**, plain case-insensitive substring against
896
+ `command` and `describe`. Same rule `interpret`'s own matching follows: the
897
+ host never compiles extension-supplied patterns.
898
+ - **List commands, not variants.** `radio-commands` advertises one BAND
899
+ entry, not fifteen; `call-notes` advertises `//`, not every key the user
900
+ has defined.
901
+
902
+ A hook without `catalog` contributes nothing to the palette's suggestions and
903
+ still works fine when its commands are typed in full — that's why it's
904
+ optional. `dev-commands` uses `ctx.developerMode` to keep DEVMODE, SEED and
905
+ friends out of the list unless developer mode is on (RELOAD, being genuinely
906
+ user-facing, is always listed); the commands themselves still run either way.
907
+
908
+ A command that recognizes its own prefix but needs more typed input before
909
+ it can run (e.g. bare "RELOAD", before its target word) returns
910
+ `{ expectsParams: true }` instead of `null` or a full result. That switches
911
+ the call field to free-text entry — spaces allowed, no callsign character
912
+ restriction — and space no longer advances focus to the next field while
913
+ it's set; the interpretation is never executed even if Enter is pressed
914
+ while it still just says `expectsParams`. The field stays uppercase-only
915
+ (all logging input is uppercase unless explicitly mixed-case) unless the
916
+ interpretation also says `mixedCase: true` — for commands whose parameter
917
+ is free prose (NOTE's text), which lifts the uppercase rule. Return
918
+ `mixedCase` on both the bare-prefix result and the complete result, so
919
+ continued typing after the command is already complete ("NOTE Hello…")
920
+ isn't snapped back to uppercase. `mixedCase` is also what keeps Space a
921
+ literal character once a command is *already complete* — `expectsParams`
922
+ can't, since it also blocks execution — which is why SEED returns it on
923
+ every result despite its parameters not being prose: bare "SEED" must be
924
+ executable (default count) yet still extendable to "SEED 15 3H".
925
+
926
+ ## ✅ `scoring` — per-operation score batches
927
+
928
+ One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
929
+ "score 500 QSOs in one call, not 500 calls"). Day-sectioning (24h UTC
930
+ buckets, following app-polo/web-lofi's convention) and accumulation both
931
+ happen inside the runtime — the host just supplies the operation and its
932
+ QSOs and gets back everything it needs to cache.
933
+
934
+ Authors don't implement this directly. Write a **`ContestScorer`** — three
935
+ pure functions — and wrap it with `contestScorer()`, which supplies the loop,
936
+ day sectioning, checkpointing and the live single-QSO path
937
+ (docs/design/contests.md §5.2):
938
+
939
+ ```ts
940
+ interface ContestScorer<S> {
941
+ startScoresheet({operation, ref?}, ctx): S
942
+ scoreQso({scoresheet, qso, operation, ref?, isNewDay}, ctx): {scoresheet: S, score}
943
+ summarizeScore({scoresheet, operation, ref?, scope}, ctx): Record<string, ScoreTally>
944
+ }
945
+
946
+ registerHook('scoring', {
947
+ hook: contestScorer(MyScorer, { scope: { refTypes: ['cqww'] } }),
948
+ key: 'cqww',
949
+ })
950
+ ```
951
+
952
+ `scope` decides which operations the scorer applies to, and the host reads it
953
+ *before* invoking anything: `'always'` (core dupe/DXCC/bands) or a `refTypes`
954
+ list, so an operation that doesn't carry the ref never crosses the bridge.
955
+ Several scorers run on one operation — contests alongside core — and their
956
+ results are namespaced per scorer. An `'always'` scorer is the **lowest priority
957
+ on the worked-before axis**: for a QSO some other scorer has judged on that axis
958
+ — a verdict carrying `dupe`, a `duplicate` alert, or a
959
+ `newBand`/`newMode`/`newDay`/`newRef` notice — the host drops the `always`
960
+ scorer's own `dupe`, `duplicate` and those notices, so core's call+band+mode
961
+ rule never contradicts an activity's. What core alone reports — `newDXCC`,
962
+ `newState`, `newProvince` — always survives, and a verdict that says nothing
963
+ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
964
+ displaces nothing: core's dupe warning is then the only one there is.
965
+
966
+ So emit `dupe` whenever your dupe test actually ran, `dupe: false` included —
967
+ that is what tells the host you have ruled on the question. And when your
968
+ verdict means "worked before, and this one counts anyway", say it in one of
969
+ those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
970
+ inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
971
+ standing beside your credit, on the one row that can show only one.
972
+
973
+ A scorer's internal *rules* stay private to its scoresheet: contests don't
974
+ share a rule vocabulary, so only notices, alerts, points and summary tallies
975
+ cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
976
+ return it; copying accumulated state per QSO is quadratic, and the harness
977
+ copies any checkpoint at its boundary.
978
+
979
+ ```ts
980
+ // The wire shape the harness produces, for reference:
981
+ interface ScoringHook {
982
+ scoreQsos(args: ScoreQsosRequest, ctx): Promise<ScoreQsosResult>
983
+ scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
984
+ scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
985
+ }
986
+ // ScoreQsosRequest: {operation, qsos, ref?, resumeFrom?}
987
+ // ScoreQsosResult: {
988
+ // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
989
+ // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
990
+ // operationSummary: Record<tallyKey, ScoreTally>,
991
+ // scoresheet, // checkpoint to resume from
992
+ // }
993
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?}
994
+ // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
995
+ ```
996
+
997
+ All three arrive already implemented by `contestScorer()`; a scorer writes the
998
+ three pure `ContestScorer` functions and gets the loop, the checkpointing and
999
+ both live paths for free.
1000
+
1001
+ `scoreQso` answers for ONE not-yet-logged QSO — the callsign being typed.
1002
+ `scoreCandidates` answers the same question for many at once, which is what the
1003
+ Spots Panel asks to mark the spots already worked: the log is folded once per
1004
+ scorer and every candidate scored against a COPY of the result, so no candidate
1005
+ can become another's duplicate. Candidates carry no uuid — a spot is not a
1006
+ record — so results come back under caller-chosen keys.
1007
+
1008
+ `Qsos.watchForOperation` already filters deleted rows and orders by
1009
+ `startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
1010
+ `deleted`/`event` row bookkeeping on this side of the bridge.
1011
+ `ScoringService` (`app/lib/services/scoring_service.dart`) writes
1012
+ `qsoScores` into each QSO's `score_cache` column (`QsosRepository.updateScoreCache`,
1013
+ one batched transaction) and `daySections`/`operationSummary` into the
1014
+ operation's `local_data` (`OperationsRepository.updateLocalData`) — never
1015
+ synced content, so list scroll and score displays both read from SQLite and
1016
+ never wait on the JS runtime. Triggered on operation open; per-edit debounced
1017
+ rescoring is not wired up yet (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
1018
+
1019
+ Implemented by: `scoring` (key `core`) — three rules so far: duplicate-contact
1020
+ detection, DXCC entities worked (via `@ham2k/lib-country-files`'
1021
+ `useBuiltinCountryFile`, no network needed), and bands/modes worked. US
1022
+ States and Canadian Provinces rules are deferred (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
1023
+
1024
+ ## ✅ `bench` — kernel diagnostics
1025
+
1026
+ Registered by the kernel itself (`bench/kernel`): `echo` (bridge round-trip
1027
+ measurement) and `spin` (JS compute). Used by the spike screen; not for
1028
+ extensions.
1029
+
1030
+ ---
1031
+
1032
+ ## ✅ `syncTransport` — sync backends
1033
+
1034
+ The transport seam for LoFi (and future) sync. The Dart core owns the
1035
+ loop, batching, and dirty flags (`packages/halo_core`'s `SyncEngine`); the
1036
+ hook only moves an already-selected payload over the network, so sync keeps
1037
+ working even if the JS runtime is wedged (DESIGN.md §4). Registered as a
1038
+ single object with `sync`/`getAccountData`/`linkClient`/
1039
+ `linkClientWithEmail` methods — `linkClient` (optional) requests a link
1040
+ permission (pending challenge code for an email, immediately-active for an
1041
+ already-permitted account, or a blank permission for QR-code linking) and
1042
+ returns server rejections as `{ error, error_status }` data.
1043
+
1044
+ The account-management methods are all optional and all follow that same
1045
+ "a rejection is data, not a throw" convention, because every one of them
1046
+ reports to an operator in the middle of an action: `setAccountEmail` /
1047
+ `resendAccountEmail` change the address that identifies the account (it
1048
+ stays pending until the operator opens a mailed confirmation link, so a
1049
+ typo can never hand the account to a stranger), and `findLinkRequest` /
1050
+ `findLinkRequestByClient` / `approveLinkRequest` are the CONFIRMING side of
1051
+ linking — where `linkClient` asks to join some other account, these let
1052
+ this device admit another one into its own, from a typed challenge code or
1053
+ from the `com.ham2k:///link_client?id=…&token=…` deep link a requesting
1054
+ device's QR code carries. `approveLinkRequest` must be given the
1055
+ `challenge_token` for a blank/QR permission: that token is what binds a
1056
+ permission created with no account behind it to this one.
1057
+
1058
+ `submitReport`
1059
+ (optional) files a report the host composed — the equipment editor's radio
1060
+ report — as a real CaBo card; the host writes the title, body and kind, the
1061
+ transport adds who/where/what-build and owns the account gate.
1062
+ Implemented by: `ham2k-lofi`.
1063
+
1064
+ ## ✅ `panel` — panes an extension contributes to a view
1065
+
1066
+ A panel is a PANE the operator can put in a customizable view's layout
1067
+ (docs/design/layouts.md), listed in Layout Setup alongside the app's own
1068
+ QSOs / Info / Spots / Map. One extension may declare several, each with its
1069
+ own identity, config form and refresh budget.
1070
+
1071
+ ```ts
1072
+ interface PanelHook {
1073
+ getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
1074
+ render(args: PanelRenderArgs, ctx): Promise<PanelContent>
1075
+ }
1076
+ // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1077
+ // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
1078
+ // PanelContent: {kind: 'markdown' | 'svg' | 'html', content, title?, triggers?}
1079
+ ```
1080
+
1081
+ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1082
+ inside saved layouts — renaming a `key` drops the panel out of every
1083
+ arrangement holding it.
1084
+
1085
+ **Content is a document, not widgets** — the same UI-catalog seam every
1086
+ other declarative hook sits behind. Prefer `markdown`: it renders with the
1087
+ app's own typography, theme, font scale and density, and costs no web view.
1088
+ `svg` is drawn as a vector image. `html` is the escape hatch and runs
1089
+ sandboxed: **no JavaScript, no network for subresources (inline what you
1090
+ need as a `data:` URI), and no navigation** — a link the operator taps opens
1091
+ in the OS browser; one the document triggers itself is blocked. The
1092
+ no-network half is enforced by content blockers on Apple platforms and
1093
+ `blockNetworkLoads` on Android; a platform whose web view honours neither
1094
+ still gets no scripts and no navigation, so treat "no network" as a rule
1095
+ your panel must follow rather than one it is held to everywhere. HTML
1096
+ panels do not render at all where there is no web view (Linux, and Windows
1097
+ without the WebView2 runtime) — the pane says so. Nor on the web build, where
1098
+ one exists but is an iframe whose sandbox grants top-level navigation and
1099
+ popups: the no-navigation rule above is the one part a panel cannot be trusted
1100
+ to keep on its own, so the pane refuses rather than render under it.
1101
+ `markdown` has none of these caveats, which is the other reason to prefer it.
1102
+
1103
+ **`on`** is a refresh budget, not a subscription: `'operation'`,
1104
+ `'qsoLogged'`, `'spots'`, `'lookup'`, `'qso'`, or `` `tick:<seconds>` ``
1105
+ (one second is the fastest; anything below it is not a recognized trigger and
1106
+ is dropped with a line in the console, so the panel gets no tick at all). The host
1107
+ keeps at least 750ms of idle between one render FINISHING and the next
1108
+ starting — a gap, not a period, so a slow panel simply runs less often
1109
+ instead of monopolising a single-threaded runtime. A tick that arrives while
1110
+ a render is already coming is DROPPED rather than queued: another is aimed at
1111
+ the next boundary, and the render that is coming reads fresh context anyway.
1112
+ Everything else waits instead of being dropped. Ticks are aimed at wall-clock
1113
+ boundaries, so panels sharing a period render in the same frame. The host
1114
+ renders nothing while the panel is behind another dock tab, or while the whole
1115
+ app is hidden — an unselected tab stays mounted, so without that a four-panel
1116
+ layout would pay for the three nobody is looking at. A trigger that arrives
1117
+ while the panel is away is remembered, and the panel renders once when it
1118
+ comes back. Do not treat the first render as something that happens at mount:
1119
+ a panel placed on a tab nobody has selected is not rendered until that tab is,
1120
+ so one-time setup belongs in the hook's own initialization, not in a render. A trigger name outside this list is dropped when
1121
+ the panel is read, with a line in the console: it would otherwise sit in the
1122
+ descriptor looking live and never fire. A descriptor with no `on` renders once when it
1123
+ appears. Omitting it is the right default: a trigger costs a bridge call
1124
+ every time it fires.
1125
+
1126
+ `'qso'` is the expensive one. `args.qso` is the whole QSO being composed —
1127
+ every field, in QSON, as it would be logged — and it changes as fast as the
1128
+ operator types. The host debounces it to one wake per pause and stops
1129
+ producing it entirely when no panel in the arrangement asked, but a panel
1130
+ that wants only the resolved callsign should declare `'lookup'` instead: it
1131
+ fires when a lookup is ANSWERED, not while a call is being typed, and the
1132
+ guess is inside `args.qso` either way — declaring `'lookup'` is enough to be
1133
+ sent the draft, so what it saves over `'qso'` is renders, not the QSO.
1134
+
1135
+ `'lookup'` fires when the panel is shown a callsign that HAS an answer —
1136
+ which includes opening a QSO whose stored record already carries one, not
1137
+ only a fresh lookup resolving. It does not fire when a lookup starts, fails,
1138
+ or times out. A panel keyed to it alone cannot tell "still looking"
1139
+ from "no answer coming", and will sit on whatever it last drew. A panel that
1140
+ needs to show either state wants `'qso'` too, and should read
1141
+ `qso.their.guess` to decide which it is looking at.
1142
+
1143
+ **`form`** declares the panel's own configuration in the same field schema
1144
+ a Tier 1 `settingsPanel` uses ([settings.md](./settings.md)), rendered by
1145
+ the core's `FormRenderer` and reached from the gear beside the panel in
1146
+ Layout Setup. Values are host-persisted per panel and handed back as
1147
+ `args.config`; there is no per-keystroke bridge call and nothing for the
1148
+ extension to store.
1149
+
1150
+ **`multiple`** lets an operator place more than one of your panel. Off by
1151
+ default, because most panels are one thing — two maps or two spot lists would
1152
+ be a mistake to offer. Set it where two is the point: a panel whose content
1153
+ is the operator's OWN, like `custom-text`'s notes.
1154
+
1155
+ Each placement gets its own id and therefore its own config, so `args.config`
1156
+ differs per pane with nothing to do on your side. What you DO owe an
1157
+ instanceable panel is a `title` on the render result — without one every pane
1158
+ reads the same descriptor title and the operator cannot tell them apart.
1159
+
1160
+ **`preview`** is a still image for that same list — an SVG document or a
1161
+ `data:` URI, never a URL: the list renders offline, and a remote preview
1162
+ would make opening it a fetch (and a beacon).
1163
+
1164
+ A render result may carry a **`title`**, which replaces the tab label for
1165
+ that pane. Blank is ignored rather than producing a nameless tab, and a
1166
+ result with no title keeps the descriptor's.
1167
+
1168
+ A render result may also carry **`triggers`** — the same names as `on`,
1169
+ added to the descriptor's for THAT PLACEMENT only. `on` is per panel and so
1170
+ is paid by every pane of it; this is per pane, and exists for panels whose
1171
+ content is written by the operator: `custom-text` derives its triggers from
1172
+ the template in each pane (`triggersForTemplate`), so a pane showing a band
1173
+ plan renders once while the pane beside it, naming the callsign being typed,
1174
+ gets woken per keystroke pause. Unknown names are dropped with a line in the
1175
+ console, exactly as in a descriptor.
1176
+
1177
+ The declaration necessarily lands AFTER the render that carried it, so a
1178
+ panel asking for `'qso'` this way is answered without one the first time and
1179
+ woken by the next change to the draft. Declare in `on` instead when the
1180
+ panel always needs the signal — a descriptor's triggers are in force before
1181
+ its first render.
1182
+
1183
+ The whole log is deliberately absent from `render`'s args. A panel that
1184
+ needs it calls `ctx.getQsos(operation.uuid)` — right for a `'qsoLogged'`
1185
+ render, wrong for a per-keystroke one.
1186
+
1187
+ ## ✅ `settingsPanel` — extension settings forms
1188
+
1189
+ Declarative settings panels rendered by the core's `FormRenderer` — full
1190
+ schema, kernel dispatch, `ExtensionService`, and `SettingsView` wiring; see
1191
+ [settings.md](./settings.md). Complementary to `account`, not a replacement.
1192
+ Implemented by: `settings` (the core General panel).
1193
+
1194
+ ## ✅ `account` — account connections
1195
+
1196
+ Account connections (QRZ, LoFi, SOTA): credential fields, `testCredentials`,
1197
+ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
1198
+ `ExtensionService.getAccounts`/`testCredentials`. Credentials live in the
1199
+ platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
1200
+ (username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
1201
+ host runs the whole PKCE flow itself, see `_doOAuth` in
1202
+ `app/lib/views/settings_view.dart`).
1203
+
1204
+ **`label` / `description` / `fields`** — each either a plain value, or a
1205
+ function `(args, ctx) => value` called the same way any other hook method is,
1206
+ so extensions can localize them from `ctx.locale` (via the SDK's
1207
+ `createTranslator`). The same `Localizable<T>` convention applies to a
1208
+ `dataFile` registration's `name`/`description`. QRZ localizes its account
1209
+ description and field labels this way.
1210
+
1211
+ **`oauth2`** (optional) — either a plain `OAuth2Config`
1212
+ (`{issuer, clientId, redirectUrl, scopes}`), or, for a client that varies by
1213
+ build edition, a function `(args, ctx) => OAuth2Config` called the same way
1214
+ any other hook method is (`ctx.edition` is `'dev' | 'next' | 'prod'` — see
1215
+ `HookContext.edition` below). SOTA is the one such hook: every edition
1216
+ authenticates as the SAME Keycloak client, and what varies is the redirect
1217
+ URL — `com.ham2k.logger.<edition>.auth://sota`, one per edition, each
1218
+ registered against that one client.
1219
+
1220
+ **`synchronizable`** (optional, default `false`) — on Apple platforms (macOS
1221
+ and iOS), when `true` the account's stored credentials and session sync across
1222
+ the user's devices via iCloud Keychain (`kSecAttrSynchronizable`); when `false`
1223
+ they stay device-local. No effect on other platforms. Set it `true` only for
1224
+ account-level logins that are genuinely the same on every device — QRZ.com sets
1225
+ it, since one QRZ login is shared across a user's installs. **Never** set it for
1226
+ per-device identifiers or bearer tokens: the LoFi sync **device key** is a
1227
+ per-install identifier and deliberately stays device-local (it isn't even an
1228
+ `account` hook — it lives in `SettingsService`, non-synchronizable, and must
1229
+ stay that way, or two devices would present as the same sync client).
1230
+
1231
+ The flag must be consistent for a `kvKey` across its whole lifetime: a
1232
+ synchronizable and a non-synchronizable keychain item are *distinct entries*,
1233
+ so the host resolves each account's flag once at runtime start
1234
+ (`ExtensionService._accountSynchronizable`) and applies it to every
1235
+ read/write/delete. Flipping the flag on an account that already has stored
1236
+ credentials makes the old entry invisible — the credentials must be re-entered
1237
+ once (there is no automatic migration between the two entries).
1238
+
1239
+ ## ✅ `dataFile` — offline reference datasets
1240
+
1241
+ Large offline datasets (park/summit lists) the core downloads, caches, and
1242
+ refreshes on a schedule; the extension supplies the URL and a
1243
+ row/entry→`LookupRow` transform. See `DataFileManager`
1244
+ (`app/lib/services/data_file_manager.dart`). Implemented by: `pota`
1245
+ (`pota-all-parks`), `sota` (`sota-all-summits`), and `call-notes`
1246
+ (`fetchType: 'raw'` — the parsed Hams of Note index is cached as JSON and
1247
+ replayed through `onLoadRawData` at startup, issue #17).
1248
+
1249
+ ### How CSV rows reach `csvToLookupEntry`
1250
+
1251
+ An extension writes a **per-row** mapper; the kernel synthesizes the batch
1252
+ method around it. What crosses the boundary is **not** what the mapper sees:
1253
+ the host sends rows **positionally** plus the header row **once per chunk**,
1254
+ and `registerHook` zips them back into the `Record<string, string>` the mapper
1255
+ is declared to take. Building those maps host-side instead meant every column
1256
+ name was repeated for every row — 61% of the bytes on SOTA's 181k-row file
1257
+ (APP-16).
1258
+
1259
+ Consequences worth knowing:
1260
+
1261
+ - A file with `hasHeaders: false` sends no headers, and the mapper receives the
1262
+ positional `string[]` — the other half of the declared
1263
+ `string[] | Record<string, string>` signature.
1264
+ - A short row leaves its trailing keys **absent**, not `undefined`-valued, so
1265
+ `!row.SummitCode` still works as the emptiness test.
1266
+ - The zipped row has a **null prototype**: column names come from a
1267
+ third-party file and must never reach `Object.prototype`.
1268
+ - An extension that defines `mapCsvBatch` **itself** bypasses all of the above
1269
+ and receives the raw `{ rows, headers? }` — it has to zip on its own. Nothing
1270
+ does this today, and `DataFileDefinition` doesn't declare the method, so the
1271
+ type checker will not remind you.
1272
+
1273
+ ---
1274
+
1275
+ ## 🔜 Planned categories
1276
+
1277
+ | Category | Role | Notes |
1278
+ |---|---|---|
1279
+ | `confirmation` | QSL/confirmation sources (spot-history cross-checking) | |
1280
+ | `opSetting` | per-operation settings contributions | |
1281
+
1282
+ When one of these lands, move it above the line with its real signature.