@ham2k/extension-sdk 0.2.0 → 0.4.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/LICENSE +21 -0
- package/README.md +20 -4
- package/dist/activityExports.js +48 -11
- package/dist/activityScoring.js +2 -0
- package/dist/dxcc.js +15 -2
- package/dist/exportSettings.js +133 -0
- package/dist/index.d.ts +133 -5
- package/dist/index.js +17 -14
- package/dist/modes.js +20 -0
- package/dist/refTransforms.js +34 -0
- package/dist/referenceActivity.js +89 -18
- package/dist/scoring.js +11 -4
- package/dist/segments.js +17 -0
- package/dist/templateContext.js +4 -0
- package/docs/distribution.md +168 -2
- package/docs/forms.md +36 -0
- package/docs/hooks.md +193 -28
- package/docs/settings.md +11 -9
- package/docs/templates.md +27 -25
- package/package.json +11 -2
- package/samples/k2hrc-cqww/manifest.json +0 -1
- package/samples/k2hrc-cqww/src/index.ts +1 -1
- package/samples/k2hrc-hamqth/manifest.json +0 -1
- package/samples/k2hrc-hamqth/src/index.ts +1 -1
- package/samples/k2hrc-llota/manifest.json +1 -1
- package/samples/k2hrc-llota/src/index.ts +1 -1
- package/samples/k2hrc-radio/manifest.json +0 -1
- package/samples/k2hrc-radio/src/index.ts +1 -1
package/docs/distribution.md
CHANGED
|
@@ -47,7 +47,7 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
47
47
|
it does not speak, so an older app meeting a newer bundle says so instead of
|
|
48
48
|
failing somewhere deep in a hook.
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Three fields are **refused** in a distributed bundle, and it is worth
|
|
51
51
|
understanding why:
|
|
52
52
|
|
|
53
53
|
- **No `category`** makes an extension *core* to the host: always enabled, and
|
|
@@ -57,6 +57,79 @@ understanding why:
|
|
|
57
57
|
- **`experiments`** gates availability against the *app's* experiment catalog.
|
|
58
58
|
A key the host doesn't define hides the extension permanently, with nothing
|
|
59
59
|
in the UI to explain it.
|
|
60
|
+
- **`enabledByDefault`** is a shipping app's answer for the extensions it
|
|
61
|
+
carries whether the operator asked for them or not. An installed bundle is
|
|
62
|
+
one the operator went and got, so the answer is always yes, and a manifest
|
|
63
|
+
saying otherwise describes a state it cannot reach.
|
|
64
|
+
|
|
65
|
+
**`geo` is now `relevance`**, and a manifest still carrying the old name is
|
|
66
|
+
refused at packing and at publishing rather than ignored. Nothing reads
|
|
67
|
+
`geo`, and no check anywhere looks at unknown top-level keys, so accepting it
|
|
68
|
+
would publish a regional extension as a worldwide one and tell its author
|
|
69
|
+
nothing. Rename the key; the four geographic lists inside it are unchanged.
|
|
70
|
+
|
|
71
|
+
The app itself does NOT refuse either of these at install. The packer and the
|
|
72
|
+
catalog answer to the author, who can fix the manifest and publish again;
|
|
73
|
+
refusing at the door tells the operator instead, who can do nothing about a
|
|
74
|
+
published manifest but go without an extension that would have worked — and
|
|
75
|
+
every bundle published before these rules existed carries one. Neither costs
|
|
76
|
+
them anything: an installed extension is enabled because they installed it,
|
|
77
|
+
and an unread `geo` makes it worldwide rather than regional. What the app
|
|
78
|
+
refuses is what it cannot run.
|
|
79
|
+
|
|
80
|
+
### Relevance
|
|
81
|
+
|
|
82
|
+
`relevance` says where, when and for whom the extension matters. The catalog
|
|
83
|
+
ranks its listing by it, for the operators the extension was written for, and
|
|
84
|
+
the Extensions panel's Catalog section shows the catalog's rows in that
|
|
85
|
+
order; nothing hides an extension over it, and a manifest without one is
|
|
86
|
+
worldwide and undated. The build index carries `relevance` for the catalog
|
|
87
|
+
and for later use; the panel does not rank the app's own extensions by it.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
"relevance": {
|
|
91
|
+
"countries": ["us"],
|
|
92
|
+
"dates": ["2026-09-19", "2027-09-18"],
|
|
93
|
+
"interests": ["cw"]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Where.** Four lists, each an allowlist over the operator's own callsign:
|
|
98
|
+
`entities` (DXCC prefixes as the country file names them: `K`, `VE`, `CE`),
|
|
99
|
+
`countries` (ISO alpha-2: `us`, `cl`), `continents` (`AF AS EU NA OC SA AN`)
|
|
100
|
+
and `ituRegions` (`1`, `2`, `3`). A missing or empty list does not gate;
|
|
101
|
+
values within a list are ORed, the lists ANDed; case is the matcher's job.
|
|
102
|
+
These are the geo keys of HaLo's notices, rule for rule.
|
|
103
|
+
|
|
104
|
+
**When.** `dates` lists the UTC days the extension's events start, as
|
|
105
|
+
`YYYY-MM-DD`. Start days, not spans: a reader asking "is this soon" needs no
|
|
106
|
+
more, and the extension's own code stays the only place that knows when an
|
|
107
|
+
event opens and closes to the minute. An event on a fixed calendar lists the
|
|
108
|
+
occurrences it knows about; one that runs to a weekly rule lists none,
|
|
109
|
+
because a list that must be right every week is a list that goes stale. An
|
|
110
|
+
absent or empty `dates` says nothing about timing — never "never".
|
|
111
|
+
|
|
112
|
+
**For whom.** `interests` draws on a closed vocabulary: the modes `cw`,
|
|
113
|
+
`phone`, `digital`; the band groups `hf`, `vhf`, `uhf`, `microwave`; the
|
|
114
|
+
styles `satellite`, `portable`, `qrp`. Only for an extension *dedicated* to
|
|
115
|
+
one — a contest that permits CW is not a `cw` extension, while the CWops CWT
|
|
116
|
+
is. Listing what an extension merely allows makes every interest match
|
|
117
|
+
everything, which is the same as listing none. The list is closed because an
|
|
118
|
+
interest nothing else spells the same way matches nobody while looking like
|
|
119
|
+
it works.
|
|
120
|
+
|
|
121
|
+
The packer refuses:
|
|
122
|
+
|
|
123
|
+
- a `relevance` that is not an object, or a key inside it other than those six;
|
|
124
|
+
- a list that is not a list, or an element that is not a non-empty string;
|
|
125
|
+
- a continent outside `AF AS EU NA OC SA AN`, or an ITU region outside `1`–`3`;
|
|
126
|
+
- a country that is not two letters, or an entity prefix that is not letters,
|
|
127
|
+
digits and `/` (with an optional leading `*`);
|
|
128
|
+
- a date that is not `YYYY-MM-DD`, or that names a day which does not exist;
|
|
129
|
+
- an interest outside the vocabulary above.
|
|
130
|
+
|
|
131
|
+
The matcher reads a key it cannot parse as "does not gate", so each of the
|
|
132
|
+
shape errors would otherwise ship the bundle as worldwide.
|
|
60
133
|
|
|
61
134
|
## Using the host's libraries
|
|
62
135
|
|
|
@@ -95,6 +168,15 @@ The host checks those before loading, and refuses a bundle it cannot satisfy —
|
|
|
95
168
|
which is a message naming the library and both versions, instead of a call
|
|
96
169
|
failing somewhere deep in a hook against a major you never tested.
|
|
97
170
|
|
|
171
|
+
**Declare the version you actually build against, not the oldest that
|
|
172
|
+
compiles.** That check compares versions, and cannot see which *functions* a
|
|
173
|
+
version has. A library that gained an export — `qsonToCabrillo` arrived in
|
|
174
|
+
`@ham2k/lib-qson-cabrillo` 1.2.0, whose earlier releases only read Cabrillo —
|
|
175
|
+
satisfies a `^1.0.0` declaration on a host too old to have it, so the bundle
|
|
176
|
+
installs, loads, and throws the first time an operator asks for the feature
|
|
177
|
+
that needs it. The build's own message suggests the version installed beside
|
|
178
|
+
you for exactly this reason; take it rather than rounding down.
|
|
179
|
+
|
|
98
180
|
The packer refuses a bundle that reaches for a shared module **without**
|
|
99
181
|
declaring it, and one that declares a package the host doesn't carry. Anything
|
|
100
182
|
outside that list you bundle yourself, as normal.
|
|
@@ -262,6 +344,80 @@ from whoever it claims; it is not worth it while a key is all there is.
|
|
|
262
344
|
|
|
263
345
|
Removing an extension removes what it stored with it.
|
|
264
346
|
|
|
347
|
+
### Installing from a link
|
|
348
|
+
|
|
349
|
+
A web page can hand the app a bundle to install:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
com.ham2k.logger:///install_extension?url=https://example.org/my-ext.h2kext
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Every edition of Ham2K Logger answers that scheme, so on a device with more
|
|
356
|
+
than one installed the platform decides which one opens; the catalog keeps a
|
|
357
|
+
plain download link beside its install button for that reason. The
|
|
358
|
+
edition-specific `com.ham2k.logger.dev` / `.next` / `.prod` schemes exist
|
|
359
|
+
but a link should not need them. The URL must be `https`, redirects are followed by hand and refused the
|
|
360
|
+
moment one leaves `https`, and the consent screen names the host the bytes
|
|
361
|
+
came from. Nothing is written until the operator agrees.
|
|
362
|
+
|
|
363
|
+
### Pre-loaded extensions
|
|
364
|
+
|
|
365
|
+
The app ships eight of the catalog's own extensions already packaged, under
|
|
366
|
+
`app/assets/preloaded-extensions/`, and installs them into the ordinary store
|
|
367
|
+
on first run: `ham2k-pota`, `ham2k-sota`, `ham2k-wwff`, and the five lookups.
|
|
368
|
+
Afterwards they are ordinary installed extensions — the same rows, the same
|
|
369
|
+
update check, the same Update and uninstall — so an operator starts with a
|
|
370
|
+
working app and the catalog can move it forward from there.
|
|
371
|
+
|
|
372
|
+
They are installed once, not every launch. A record of the highest version
|
|
373
|
+
ever pre-loaded per key is what makes that true: an app update carrying a
|
|
374
|
+
newer bundle installs it, a catalog release already ahead of it is left
|
|
375
|
+
alone, and a key the operator uninstalled stays gone.
|
|
376
|
+
|
|
377
|
+
Each one that renames a built-in — `ham2k-pota` is the built-in `pota` under
|
|
378
|
+
a name the catalog can serve — carries the operator's settings, panel values
|
|
379
|
+
and account credentials across on its first install. Copied, not moved: the
|
|
380
|
+
built-in's own state stays where it is.
|
|
381
|
+
|
|
382
|
+
One rule bends for these, and only for them:
|
|
383
|
+
|
|
384
|
+
- **Built-in collisions.** An installed bundle may not claim a key the app
|
|
385
|
+
ships — judged against what the app is currently OFFERING, which under the
|
|
386
|
+
catalog experiment is the core extensions and nothing else. `ham2k-lookup`
|
|
387
|
+
is both a built-in and a pre-load, and could otherwise never install.
|
|
388
|
+
|
|
389
|
+
**Build secrets are not among them, and used to be.** A pre-load holding a
|
|
390
|
+
`ham2k-` key could once read the values its name granted. Nothing needs that
|
|
391
|
+
now: every value it carried — SOTA's OAuth client id, WWFF's API key — ships
|
|
392
|
+
as a constant inside the extension that uses it, because these bundles are
|
|
393
|
+
served to anyone who asks and a `.h2kext` is a zip. An installed extension
|
|
394
|
+
sees no build secret, whatever key it claims and wherever it came from.
|
|
395
|
+
|
|
396
|
+
### Installing from the catalog
|
|
397
|
+
|
|
398
|
+
**Settings → Extensions → Catalog** lists what [catalog.ham2k.net](https://catalog.ham2k.net)
|
|
399
|
+
publishes on its `stable` channel, in the catalog's own order for the
|
|
400
|
+
operator's callsign (`relevance`, above). Install and Update go through the same consent screen as a
|
|
401
|
+
file, over bytes that must hash to the sha256 the listing gives — a mismatch
|
|
402
|
+
is refused before the zip is opened. A bundle that arrived this way is
|
|
403
|
+
recorded as such (`extensionInstalledFrom`: host, sha256, version), and that
|
|
404
|
+
record is what lets it carry a `ham2k-` key: the rule that refuses the prefix
|
|
405
|
+
to a file stands, waived on every read only for a key whose record names
|
|
406
|
+
`catalog.ham2k.net`, and cleared by an uninstall or by a file install of the
|
|
407
|
+
same key. A key the app ships stays refused whatever the catalog says. The
|
|
408
|
+
catalog's own bundle links (`…/api/v1/extensions/<key>/versions/<version>/bundle`)
|
|
409
|
+
take this verified path when they arrive as a deep link; every other host
|
|
410
|
+
takes the plain one above.
|
|
411
|
+
|
|
412
|
+
At most once every six hours — on launch and on resume — the app asks the
|
|
413
|
+
catalog whether anything installed has a newer release, and says so in the
|
|
414
|
+
status bar and on the row; the notice comes down with the last update taken.
|
|
415
|
+
It never installs unasked. A release the catalog has revoked is put on record
|
|
416
|
+
(`extensionRevoked`: version and note) and stops loading on the spot,
|
|
417
|
+
whatever the operator's own switch says — the row shows "Revoked" with the
|
|
418
|
+
note and no switch, and the record dies with an install of another version
|
|
419
|
+
or an uninstall.
|
|
420
|
+
|
|
265
421
|
## What is checked at install
|
|
266
422
|
|
|
267
423
|
Beyond what the packer already refused:
|
|
@@ -269,6 +425,8 @@ Beyond what the packer already refused:
|
|
|
269
425
|
- the key is not one the app ships, and does not begin `ham2k-`;
|
|
270
426
|
- the `api` version is one this build speaks;
|
|
271
427
|
- every `sharedDependencies` range is satisfied by what this build carries;
|
|
428
|
+
- `relevance`, if present, passes the packer's rules — the shape the matcher
|
|
429
|
+
reads and its vocabulary — applied again by `ExtensionRelevance.validate`;
|
|
272
430
|
- entry names are sanitised on extract, so `../` in one writes nothing.
|
|
273
431
|
|
|
274
432
|
Each has its own message. A bundle that cannot be installed says why.
|
|
@@ -276,4 +434,12 @@ Each has its own message. A bundle that cannot be installed says why.
|
|
|
276
434
|
## Native only, for now
|
|
277
435
|
|
|
278
436
|
Installed bundles are files on disk. On web the app runs from its own assets
|
|
279
|
-
and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md).
|
|
437
|
+
and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md). So on
|
|
438
|
+
web there is nothing to install into: no install from a file, no install or
|
|
439
|
+
update from the catalog, and none of the pre-loaded extensions above.
|
|
440
|
+
|
|
441
|
+
"For now" is a deferral, not a fact about the platform. Almost everything
|
|
442
|
+
here is already platform-neutral — the zip walk, the manifest rules, the
|
|
443
|
+
consent screen, the hash check — and what is left is a persistence seam of
|
|
444
|
+
about six operations. `HALO-583` is the card that picks it up, and says what
|
|
445
|
+
has to be decided first.
|
package/docs/forms.md
CHANGED
|
@@ -14,6 +14,7 @@ All types are defined in `extensions/sdk/src/types.ts`.
|
|
|
14
14
|
export type FormFieldType =
|
|
15
15
|
| 'text'
|
|
16
16
|
| 'multiline'
|
|
17
|
+
| 'textTemplate'
|
|
17
18
|
| 'email'
|
|
18
19
|
| 'callsign'
|
|
19
20
|
| 'number'
|
|
@@ -93,6 +94,14 @@ export interface FormHeader {
|
|
|
93
94
|
type: 'header';
|
|
94
95
|
title: string;
|
|
95
96
|
subtitle?: string;
|
|
97
|
+
// How the heading is drawn. Omitted, it is plain text above the fields
|
|
98
|
+
// that follow. 'section' is the app's section band — the accent title
|
|
99
|
+
// over a hairline, which holds the top of the surface while its own rows
|
|
100
|
+
// scroll under it. 'category' is the FILLED band, for a heading that is
|
|
101
|
+
// the top-level grouping of its surface rather than one section inside
|
|
102
|
+
// one (docs/design/user-interface.md § Settings lists). A settings panel
|
|
103
|
+
// is already inside a category, so its own headings take 'section'.
|
|
104
|
+
style?: 'default' | 'section' | 'category';
|
|
96
105
|
devMode?: boolean;
|
|
97
106
|
environment?: string | string[]; // settings-panel-only; see FormField.environment (not `common` — see settings.md)
|
|
98
107
|
}
|
|
@@ -121,6 +130,8 @@ export interface FormMarkdownBlock {
|
|
|
121
130
|
// SDK already permits (see settings.md's settingsPanel note). Not used for
|
|
122
131
|
// account credentials — those have their own testCredentials on AccountHook.
|
|
123
132
|
export interface FormActionElement {
|
|
133
|
+
style?: 'row'; // icon row with a chevron; omit for a button
|
|
134
|
+
icon?: string;
|
|
124
135
|
type: 'action';
|
|
125
136
|
key: string;
|
|
126
137
|
label: string;
|
|
@@ -269,3 +280,28 @@ api.registerHook('form', {
|
|
|
269
280
|
- Any validation error returns back to Dart and is displayed underneath the field. If any fields have errors, submission is aborted.
|
|
270
281
|
3. **Submit transformation**: When all fields are successfully validated, Dart invokes any JS transformation callbacks for each field.
|
|
271
282
|
4. **Completion**: The final transformed state is resolved and returned back to the caller (or saved in the view context).
|
|
283
|
+
|
|
284
|
+
## Text templates
|
|
285
|
+
|
|
286
|
+
`fieldType: 'textTemplate'` is a string field displayed as an editor row in
|
|
287
|
+
forms and settings. It opens a multiline Liquid editor with a sample preview,
|
|
288
|
+
syntax errors, insertable attributes and flow-control examples, and a read-only
|
|
289
|
+
filter reference inside collapsed Templating Docs. Editing and preview stay
|
|
290
|
+
above the scrollable reference. Template rows also display rendered samples. Save commits
|
|
291
|
+
the draft; Cancel, Escape and dismissal discard it. `defaultValue` supplies
|
|
292
|
+
the Reset button. Disabled fields cannot open the editor.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
{
|
|
296
|
+
type: 'field', fieldType: 'textTemplate', key: 'filename',
|
|
297
|
+
label: 'File name', value: '{{ op.date }} {{ log.station }}',
|
|
298
|
+
templateContext: 'export', // 'adif' adds contact attributes; 'text' is generic
|
|
299
|
+
templateSample: { log: { activity: 'POTA', ref: 'US-1234' } },
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The preview uses the same runtime and vocabulary as exports; sample values
|
|
304
|
+
are illustrative. `templateSample` can replace values in `operation`, `qso`
|
|
305
|
+
and `log`. The app provides the rendering callback through
|
|
306
|
+
`TemplateEditorScope`, keeping the form widget independent of extensions.
|
|
307
|
+
See [templates.md](templates.md) for Liquid syntax and supported namespaces.
|
package/docs/hooks.md
CHANGED
|
@@ -29,6 +29,10 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
|
|
|
29
29
|
whole-extension `manifest.experiments` grain would also hide any other
|
|
30
30
|
hook the same extension registers (e.g. `radio-commands`' POWER command
|
|
31
31
|
checks `gato` here; its BAND/MODE/frequency siblings don't).
|
|
32
|
+
* `experiments?: { key, name, aliases }[]` — The experiment catalog itself,
|
|
33
|
+
on or off, so a hook can resolve a typed key-or-alias to one of the keys
|
|
34
|
+
`enabledExperiments` lists (the EXP preview says "Enable"/"Disable" rather
|
|
35
|
+
than "Toggle"). The app remains the authority when it flips the state.
|
|
32
36
|
* `getOperation?(uuid)` / `getQsos?(operationUuid)` — log-data reads, for
|
|
33
37
|
hooks that only receive part of the picture as arguments (e.g. a `command`
|
|
34
38
|
hook handed the operation it's typed into, needing the existing QSOs —
|
|
@@ -170,12 +174,23 @@ interface LookupServiceHook {
|
|
|
170
174
|
least as specific as the guess's current scope — a more general-scoped
|
|
171
175
|
result can never populate a slot a more specific one already claimed,
|
|
172
176
|
even if that slot is currently empty.
|
|
177
|
+
- Location fields merge as a group, never per field. A same-scope result
|
|
178
|
+
bringing a pin (a grid, or a full lat/lon pair) to a guess that has none
|
|
179
|
+
replaces the established city/state/county/country/location with its own
|
|
180
|
+
values (or just clears them, if it carries only the pin) — even where
|
|
181
|
+
per-field priority would have kept the earlier town. A result whose own
|
|
182
|
+
pin loses to an already-established one contributes no location fields at
|
|
183
|
+
all, and a pinless result's town is used only where the higher-priority
|
|
184
|
+
lookups left no town or pin (an entity-level `country` alone doesn't
|
|
185
|
+
count as located): a town is never spliced with a pin — or a town half —
|
|
186
|
+
it wasn't reported with.
|
|
173
187
|
- Grid/lat-lon reconciliation, scoped to fields the result actually set:
|
|
174
188
|
grid-only → lat/lon computed from the grid; lat/lon-only → a 6-digit grid
|
|
175
189
|
computed from them; both given → taken verbatim. Setting grid/lat/lon
|
|
176
190
|
clears city/state/country/county unless the *same* result also supplies
|
|
177
|
-
them
|
|
178
|
-
|
|
191
|
+
them. The converse clause — a town write clearing grid/lat/lon — can only
|
|
192
|
+
sweep a leftover half lat/lon pair: the group rule above blocks a town
|
|
193
|
+
from landing at all where a full pin already stands.
|
|
179
194
|
- `notes`/`history` are per-`CallInfoLookup` fields, not part of the merged
|
|
180
195
|
`guess` — they simply accumulate, unconditionally, in the returned
|
|
181
196
|
`lookups[]` array.
|
|
@@ -233,23 +248,100 @@ fetch-only for now.
|
|
|
233
248
|
|
|
234
249
|
```ts
|
|
235
250
|
interface ExportHook {
|
|
251
|
+
getExportTypes?(args: {}, ctx): Promise<ExportTypeDefinition[]>
|
|
236
252
|
suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
|
|
237
253
|
generateExport(args: ExportRequest, ctx): Promise<ExportResult>
|
|
238
254
|
}
|
|
239
255
|
// ExportOptionsRequest: {operation, qsos, compactFilenames?}
|
|
240
|
-
// ExportOption: {exportType, format, label, filename?, icon?, priority?, selectedByDefault?}
|
|
241
|
-
// ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
|
|
256
|
+
// ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
|
|
257
|
+
// ExportRequest: {operation, qsos, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
|
|
242
258
|
// ExportResult: {filename, mimeType, content}
|
|
243
259
|
```
|
|
244
260
|
|
|
245
261
|
Two-step flow: the Exports Panel calls `suggestExportOptions` on every
|
|
246
262
|
`export` hook to build its selectable list (a hook that omits it never
|
|
247
263
|
appears in the panel), then calls `generateExport` — passing back the
|
|
248
|
-
chosen option's `exportType` — only for the options the user
|
|
249
|
-
core owns file I/O and save/share; the hook only produces content.
|
|
264
|
+
chosen option's `exportType` and `exportKey` — only for the options the user
|
|
265
|
+
selected. The core owns file I/O and save/share; the hook only produces content.
|
|
266
|
+
|
|
267
|
+
`exportType` is the stable kind (for example `potaActivation-adif`); `exportKey`
|
|
268
|
+
identifies an individual option within the hook (for example
|
|
269
|
+
`pota-adif:US-1234`). Options sharing a type must have distinct keys. Omit
|
|
270
|
+
`exportKey` for a single option of a type: selection and generation default
|
|
271
|
+
it to `exportType`. Both values round-trip unchanged when an explicit key is
|
|
272
|
+
provided; the core does not interpret them. Selection also includes the
|
|
273
|
+
hook key and station callsign, keeping different hooks and stations independent.
|
|
274
|
+
|
|
250
275
|
Exporters should compose per-QSO program fields from `adifFields` hooks (see
|
|
251
|
-
below) rather than knowing about specific activities. Implemented by
|
|
252
|
-
|
|
276
|
+
below) rather than knowing about specific activities. Implemented by the whole-log `adif` exporter, activity exports, and contest
|
|
277
|
+
ADIF/Cabrillo exporters.
|
|
278
|
+
|
|
279
|
+
**Registering types and settings.** `getExportTypes` runs without an operation,
|
|
280
|
+
so Settings can list types even before the operator has a matching log:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
async getExportTypes() {
|
|
284
|
+
return [{
|
|
285
|
+
exportType: 'potaActivation-adif',
|
|
286
|
+
activationType: 'potaActivation',
|
|
287
|
+
format: 'adif',
|
|
288
|
+
label: 'POTA',
|
|
289
|
+
defaults: { includePrivateData: false, includeLookupData: true },
|
|
290
|
+
}]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Prefer `<activationType>-<format>` to an extension-key namespace. Two
|
|
295
|
+
extensions offering that type share one settings entry. The highest-priority
|
|
296
|
+
registration supplies its definition; other exporters using it must honor
|
|
297
|
+
that contract. Distinct sponsor requirements can use a distinct type. The
|
|
298
|
+
SDK's `exportTypeDefinition(activationType, format, label, defaults?)` builds
|
|
299
|
+
the conventional definition; the activity and hunting helpers register their
|
|
300
|
+
own types. An exporter without `getExportTypes` still works, but does not
|
|
301
|
+
appear in the export settings list.
|
|
302
|
+
|
|
303
|
+
`ExportSettings` contains `includePrivateData`, `includeLookupData`,
|
|
304
|
+
`customTemplates`, `filenameTemplate`, `compactFilenameTemplate`,
|
|
305
|
+
`titleTemplate`, `adifNotesTemplate`, `adifCommentTemplate`, and
|
|
306
|
+
`adifQslMessageTemplate`. All are optional. Defaults resolve from the SDK,
|
|
307
|
+
then global preferences, then the type's declared defaults.
|
|
308
|
+
`GlobalExportSettings` adds `referenceFilenameTemplate`,
|
|
309
|
+
`referenceCompactFilenameTemplate`, `referenceTitleTemplate` and the matching
|
|
310
|
+
`other…` fields. A type with `templateCategory: 'reference'` uses the reference
|
|
311
|
+
set; other types use the other set. The activity export helper registers
|
|
312
|
+
reference types automatically. ADIF field templates remain common to both sets.
|
|
313
|
+
Saved common filename/title defaults remain fallbacks for categories without
|
|
314
|
+
an explicit setting. Per-type data
|
|
315
|
+
choices override those; per-type template overrides apply only when
|
|
316
|
+
`customTemplates` is on. Empty templates intentionally suppress text. Turning
|
|
317
|
+
custom templates off preserves the operator's edits for later. Non-ADIF
|
|
318
|
+
formats expose only the filename templates and their custom-template switch.
|
|
319
|
+
|
|
320
|
+
Type definitions may supply `templateSample: {operation, log}` for representative
|
|
321
|
+
editor examples. Each format supplies its own filename extension in the sample.
|
|
322
|
+
|
|
323
|
+
An option can supply `qsoCount` when its hook further filters the contacts
|
|
324
|
+
(for example, a hunter export). Otherwise the host derives the count from
|
|
325
|
+
activity and station scope. Filename and title templates use this count.
|
|
326
|
+
|
|
327
|
+
Options can carry `templateData: {ref, refName, activity, modifier, ...}`,
|
|
328
|
+
which populates the `log` namespace. The app renders filenames and titles
|
|
329
|
+
before showing the option, and passes the same `exportSettings`, `exportData`
|
|
330
|
+
and `exportTitle` to generation. It also passes the resolved
|
|
331
|
+
`includePrivateData` and `includeLookupData` for ADIF. Forward those fields
|
|
332
|
+
when delegating through `adifForExport`, or a program's preferences will not
|
|
333
|
+
reach the ADIF writer. Settings apply globally and per type, never per operation.
|
|
334
|
+
The whole-log ADIF defaults to including private data, but its type setting
|
|
335
|
+
can override that default.
|
|
336
|
+
|
|
337
|
+
**How a row looks.** `refType` names the activity the export covers — the
|
|
338
|
+
core slices the file's QSOs by it, and the panel takes the row's icon and
|
|
339
|
+
accent colour from that activity's own control, so a contest's file is
|
|
340
|
+
recognisably the contest's without saying anything about glyphs. `icon` and
|
|
341
|
+
`color` override that, and are how an export that claims no activity — the
|
|
342
|
+
whole-log ADIF — asks for a glyph of its own instead of borrowing one. An
|
|
343
|
+
`icon` name that doesn't resolve falls back to the activity's, same as an
|
|
344
|
+
unreadable `color` does.
|
|
253
345
|
|
|
254
346
|
**Filenames.** Build them with the SDK's `exportFilename` rather than
|
|
255
347
|
spelling one out, passing the request's `compactFilenames` straight through —
|
|
@@ -280,6 +372,11 @@ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
|
|
|
280
372
|
following app-polo's QSON conventions. Handles validation and enrichment of
|
|
281
373
|
activity references on QSOs and operations.
|
|
282
374
|
|
|
375
|
+
`registerHook` takes the type alone. The **qualified** `ref:<type>/<code>` form
|
|
376
|
+
is a manifest claim, not a category — it tells the host which legacy reference
|
|
377
|
+
an extension answers for so it can rewrite one (docs/extensions/README.md), and
|
|
378
|
+
registering a hook under it would register a category nothing ever invokes.
|
|
379
|
+
|
|
283
380
|
```ts
|
|
284
381
|
interface RefHandlerHook {
|
|
285
382
|
validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
|
|
@@ -400,7 +497,10 @@ interface ActivityHook {
|
|
|
400
497
|
//
|
|
401
498
|
// `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
|
|
402
499
|
// kind; a malformed regex disables validation rather than throwing. A
|
|
403
|
-
// mismatch tints the field but still accepts the value
|
|
500
|
+
// mismatch tints the field but still accepts the value — a `refList` segment
|
|
501
|
+
// that fails it becomes a ref like any other, and reaches the log and the
|
|
502
|
+
// exports as typed. The pattern is HaLo's idea of the program rather than the
|
|
503
|
+
// program's own, so it may never cost the operator a reference.
|
|
404
504
|
//
|
|
405
505
|
// `text`, `options` and `serial` values round-trip onto a ref of `refType` under
|
|
406
506
|
// `field`, through the same core-owned path refList writes refs — several may
|
|
@@ -432,8 +532,9 @@ an unrecognized name falls back to a placeholder glyph rather than erroring.
|
|
|
432
532
|
|
|
433
533
|
`color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
|
|
434
534
|
may use sparingly to highlight this control's icon — a tinted circle behind
|
|
435
|
-
it in the Activities picker,
|
|
436
|
-
in the logging panel's secondary pills
|
|
535
|
+
it in the Activities picker, the icon itself once a reference is entered
|
|
536
|
+
in the logging panel's secondary pills, or the reference icon on a spot row
|
|
537
|
+
whose ref is of this control's type. Missing or unrecognized values just
|
|
437
538
|
mean no accent.
|
|
438
539
|
|
|
439
540
|
`processQsoBeforeSave` is the last chance to shape a QSO before it is written,
|
|
@@ -441,7 +542,10 @@ for projecting data the extension owns into the generic QSON fields the rest of
|
|
|
441
542
|
the app reads. A contest keeps its exchange on its own ref (`theirZone`,
|
|
442
543
|
`theirSerial`, …), which nothing generic knows how to read — mirroring it into
|
|
443
544
|
`their.exchange` is what fills the QSO row's exchange column and a plain ADIF
|
|
444
|
-
export.
|
|
545
|
+
export. A contest whose sent exchange changes per QSO (a serial) mirrors that
|
|
546
|
+
into `our.exchange` too, and the column shows it ahead of theirs, in the RST
|
|
547
|
+
cell's order — keep it to the part that varies, since it repeats on every row.
|
|
548
|
+
Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
|
|
445
549
|
`their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
|
|
446
550
|
field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
|
|
447
551
|
refs are shared with the core and with other activities. Return null to do
|
|
@@ -475,15 +579,36 @@ a set of placeless suggestions (dated contest events, say) among themselves, but
|
|
|
475
579
|
never lifts one above a located park. Implemented by: `pota`, `sota`,
|
|
476
580
|
`stateparks`, `cwt`.
|
|
477
581
|
|
|
478
|
-
|
|
479
|
-
`pota
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
582
|
+
**A scoped search offers the reference the operator typed, listed or not.** The
|
|
583
|
+
reference programs (`pota`, `sota` and every one built on `referenceActivity`)
|
|
584
|
+
answer a scoped search with the typed code itself when no row matched it —
|
|
585
|
+
named `unknownReference` where it fits the program's pattern, `invalidReference`
|
|
586
|
+
where it does not. The search is the only way onto the operation, so a program
|
|
587
|
+
whose list is stale, or whose pattern this app has wrong, would otherwise answer
|
|
588
|
+
a reference the operator was handed on the air with "no results". The scope is
|
|
589
|
+
what keeps the MALFORMED half of that narrow: unscoped, every enabled program
|
|
590
|
+
would answer any text at all with an invented reference of its own, burying the
|
|
591
|
+
real matches. (A well-formed code needs no scope — POTA has always offered one,
|
|
592
|
+
since only that program's own pattern can accept it.) A search term that is not
|
|
593
|
+
code-shaped is left alone either way: a single word is how a park or summit gets
|
|
594
|
+
looked up BY NAME, and `pota: beersel` must not answer with an invented BEERSEL.
|
|
595
|
+
The row is marked, and the reference is stored, logged and exported as typed —
|
|
596
|
+
the same rule ADIF import follows for a malformed `*_REF` (below).
|
|
597
|
+
|
|
598
|
+
The activity search box also treats a leading scope word as a scope — typing
|
|
599
|
+
`pota:` (or tapping a row under "Activity Types") asks the extension serving
|
|
600
|
+
that reference type alone, via a key-scoped `invokeHook` rather than the usual
|
|
601
|
+
fan-out. The word is the **reference type's base name**, lowercased —
|
|
602
|
+
`potaActivation` and `pota` both scope as `pota:` — never the extension key: a
|
|
603
|
+
user-published `ki2d-pota` serving `potaActivation` is still scoped as
|
|
604
|
+
`pota:`, since the operator is adding POTA parks whichever extension supplies
|
|
605
|
+
them. Two enabled extensions serving one type share the word, and the scoped
|
|
606
|
+
search asks both. For the hook call to reach the extension, its `activity`
|
|
607
|
+
hook must be registered under its own **extension key**, and its control
|
|
608
|
+
descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
|
|
609
|
+
derives the hook key from the part before the slash. An extension that
|
|
610
|
+
deviates simply never gets scoped: its rows still add references, but the
|
|
611
|
+
scope would ask a hook that doesn't exist and come back empty.
|
|
487
612
|
|
|
488
613
|
`editable` declares whether a reference of this type has anything to edit
|
|
489
614
|
once it's on the operation. It defaults to **false**: a plain reference
|
|
@@ -702,8 +827,8 @@ interface CommandHook {
|
|
|
702
827
|
// CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
|
|
703
828
|
// CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
|
|
704
829
|
// CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
|
|
705
|
-
// sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
|
|
706
|
-
// updateOperation?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
|
|
830
|
+
// setKeyerSpeed?, showKeyer?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
|
|
831
|
+
// updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
|
|
707
832
|
// devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
|
|
708
833
|
// devTimeTravel?, toggleExperiment?}
|
|
709
834
|
```
|
|
@@ -817,9 +942,10 @@ the lookup service's offline `their.guess` decoration. The placement rule
|
|
|
817
942
|
needs the existing log: SEED reads it through `ctx.getQsos`, using the
|
|
818
943
|
`uuid` the logging panel folds into the `operation` map it passes to
|
|
819
944
|
`interpret`; EXP toggles an experimental feature via the `toggleExperiment`
|
|
820
|
-
action field — the hook
|
|
821
|
-
|
|
822
|
-
|
|
945
|
+
action field — the hook parses "EXP <key-or-alias>" and resolves the token
|
|
946
|
+
against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
|
|
947
|
+
resolves it again when it flips the state and reports the outcome, since the
|
|
948
|
+
stored per-experiment state lives app-side;
|
|
823
949
|
DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
|
|
824
950
|
"DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
|
|
825
951
|
anything that turns on the date or on elapsed time can be tested without
|
|
@@ -923,6 +1049,36 @@ can't, since it also blocks execution — which is why SEED returns it on
|
|
|
923
1049
|
every result despite its parameters not being prose: bare "SEED" must be
|
|
924
1050
|
executable (default count) yet still extendable to "SEED 15 3H".
|
|
925
1051
|
|
|
1052
|
+
## ✅ `template` — rendering on the app's behalf
|
|
1053
|
+
|
|
1054
|
+
The Liquid renderer (templates.md) lives in the SDK and runs only inside the
|
|
1055
|
+
extension runtime. A Dart-side surface that wants a template filled in — a
|
|
1056
|
+
CW message about to be keyed (docs/design/cat.md § CW keying) — reaches it
|
|
1057
|
+
through this hook, registered once by the core `templates` extension under
|
|
1058
|
+
the key `templates`:
|
|
1059
|
+
|
|
1060
|
+
```ts
|
|
1061
|
+
interface TemplateHook {
|
|
1062
|
+
render(
|
|
1063
|
+
args: { template: string; operation?; qso?; qsoCount?: number },
|
|
1064
|
+
ctx,
|
|
1065
|
+
): Promise<{ text: string } | { error: string }>
|
|
1066
|
+
// Several templates against one context in one crossing (the keyer
|
|
1067
|
+
// area's button labels): one result per template, in order.
|
|
1068
|
+
renderMany(
|
|
1069
|
+
args: { templates: string[]; operation?; qso?; qsoCount?: number },
|
|
1070
|
+
ctx,
|
|
1071
|
+
): Promise<{ results: ({ text: string } | { error: string })[] }>
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
`operation` and `qso` are the same objects a `panel` hook's `render` gets,
|
|
1076
|
+
and the result is built with the same `templateContext`, so a placeholder
|
|
1077
|
+
means the same thing wherever the app renders it. A template that does not
|
|
1078
|
+
parse comes back as `error` carrying the renderer's own message — a typo in
|
|
1079
|
+
a message the operator typed, to show them, not a bridge failure to report.
|
|
1080
|
+
The app calls them as `ExtensionService.renderTemplate` and `renderTemplates`.
|
|
1081
|
+
|
|
926
1082
|
## ✅ `scoring` — per-operation score batches
|
|
927
1083
|
|
|
928
1084
|
One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
|
|
@@ -964,7 +1120,12 @@ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
|
|
|
964
1120
|
displaces nothing: core's dupe warning is then the only one there is.
|
|
965
1121
|
|
|
966
1122
|
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.
|
|
1123
|
+
that is what tells the host you have ruled on the question. It travels on the
|
|
1124
|
+
live paths too (`scoreQso`, `scoreCandidates`), not just in `qsoScores`. Rule
|
|
1125
|
+
only on what you have seen: a scorer folds only the segments it was running
|
|
1126
|
+
for, so a station absent from YOUR history may have been worked in a stretch
|
|
1127
|
+
you skipped, and staying silent there hands the question to the scorer holding
|
|
1128
|
+
the whole log. And when your
|
|
968
1129
|
verdict means "worked before, and this one counts anyway", say it in one of
|
|
969
1130
|
those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
|
|
970
1131
|
inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
|
|
@@ -999,6 +1160,10 @@ three pure `ContestScorer` functions and gets the loop, the checkpointing and
|
|
|
999
1160
|
both live paths for free.
|
|
1000
1161
|
|
|
1001
1162
|
`scoreQso` answers for ONE not-yet-logged QSO — the callsign being typed.
|
|
1163
|
+
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1164
|
+
automatic) reaches the scorer stamped with the current time on both live
|
|
1165
|
+
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1166
|
+
candidate that states a time keeps it.
|
|
1002
1167
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1003
1168
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1004
1169
|
scorer and every candidate scored against a COPY of the result, so no candidate
|
|
@@ -1199,7 +1364,7 @@ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
|
|
|
1199
1364
|
platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
|
|
1200
1365
|
(username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
|
|
1201
1366
|
host runs the whole PKCE flow itself, see `_doOAuth` in
|
|
1202
|
-
`app/lib/views/
|
|
1367
|
+
`app/lib/views/settings/accounts_panel.dart`).
|
|
1203
1368
|
|
|
1204
1369
|
**`label` / `description` / `fields`** — each either a plain value, or a
|
|
1205
1370
|
function `(args, ctx) => value` called the same way any other hook method is,
|
package/docs/settings.md
CHANGED
|
@@ -374,15 +374,17 @@ and a collision costs a disambiguation prompt on a query that used to be exact.
|
|
|
374
374
|
|
|
375
375
|
## Common Preferences and environment gating
|
|
376
376
|
|
|
377
|
-
The app has
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
The app has one settings screen, **Settings**, whose **Application
|
|
378
|
+
Preferences** panel has a collapsible section per declared group and panel,
|
|
379
|
+
led by **Common Preferences**, a short list of the
|
|
380
|
+
handful of settings most users ever touch. Which fields land on Common
|
|
381
|
+
Preferences —
|
|
380
382
|
and which platforms show a field at all — is declared per-element, not
|
|
381
383
|
maintained as a separate list somewhere else in the app. Any field, link, or
|
|
382
384
|
action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
383
385
|
|
|
384
|
-
- **`common: true`** — also show this element
|
|
385
|
-
|
|
386
|
+
- **`common: true`** — also show this element in the Common Preferences
|
|
387
|
+
section, on top of its own group's. Off by default. Not the same thing as
|
|
386
388
|
`FormFieldOption.common`/`uncommon` above — that's a `multiselect`
|
|
387
389
|
option's own disclosure tier (upfront vs. behind "Show more"), a
|
|
388
390
|
different scope (an option inside one field, not the field itself).
|
|
@@ -393,7 +395,7 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
|
393
395
|
|
|
394
396
|
- **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
|
|
395
397
|
restrict which platform(s) show this element at all. This is a platform
|
|
396
|
-
gate, not a
|
|
398
|
+
gate, not a common-vs-everything one: an excluded field disappears from every
|
|
397
399
|
settings surface on that platform, not just Common Preferences. Every
|
|
398
400
|
token unprefixed is a whitelist (show ONLY there); every token
|
|
399
401
|
`-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
|
|
@@ -445,7 +447,7 @@ target but niche enough it shouldn't clutter the default view:
|
|
|
445
447
|
```
|
|
446
448
|
|
|
447
449
|
Omitting `target` doesn't hide a field from anywhere it already appears —
|
|
448
|
-
|
|
450
|
+
The Settings panel is unaffected either way. It's purely
|
|
449
451
|
additive: an extra place a field can be reached from, on top of wherever
|
|
450
452
|
`common`/`environment` already put it.
|
|
451
453
|
|
|
@@ -453,8 +455,8 @@ additive: an extra place a field can be reached from, on top of wherever
|
|
|
453
455
|
elements can both set `target` and both appear in a target-scoped modal —
|
|
454
456
|
an action's method dispatches through the same extension host call the
|
|
455
457
|
full settings screens use, and an account field's Manage/Connect button
|
|
456
|
-
opens that account's own standalone dialog, exactly as it would from
|
|
457
|
-
Settings.
|
|
458
|
+
opens that account's own standalone dialog, exactly as it would from the
|
|
459
|
+
Settings panel.
|
|
458
460
|
|
|
459
461
|
## Restarting the runtime after a Tier 2 field commits
|
|
460
462
|
|