@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/AGENTS.md +139 -0
- package/README.md +19 -4
- package/dist/index.d.ts +1 -1
- package/docs/distribution.md +279 -0
- package/docs/forms.md +271 -0
- package/docs/hooks.md +1282 -0
- package/docs/settings.md +523 -0
- package/docs/templates.md +204 -0
- package/package.json +12 -2
- package/samples/README.md +33 -0
- package/samples/k2hrc-cqww/build.mjs +6 -0
- package/samples/k2hrc-cqww/manifest.json +24 -0
- package/samples/k2hrc-cqww/src/index.ts +273 -0
- package/samples/k2hrc-hamqth/build.mjs +6 -0
- package/samples/k2hrc-hamqth/manifest.json +25 -0
- package/samples/k2hrc-hamqth/src/index.ts +202 -0
- package/samples/k2hrc-llota/build.mjs +6 -0
- package/samples/k2hrc-llota/manifest.json +25 -0
- package/samples/k2hrc-llota/src/index.ts +172 -0
- package/samples/k2hrc-radio/build.mjs +6 -0
- package/samples/k2hrc-radio/manifest.json +24 -0
- package/samples/k2hrc-radio/src/index.ts +145 -0
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.
|