@ham2k/extension-sdk 0.2.0 → 0.3.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 +0 -4
- package/dist/activityExports.js +12 -6
- package/dist/activityScoring.js +2 -0
- package/dist/dxcc.js +15 -2
- package/dist/index.d.ts +71 -4
- package/dist/index.js +16 -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 +10 -2
- package/docs/hooks.md +120 -23
- package/docs/settings.md +12 -11
- package/docs/templates.md +11 -9
- 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/dist/templateContext.js
CHANGED
|
@@ -26,6 +26,10 @@ function opValues(operation, extra = {}) {
|
|
|
26
26
|
return {
|
|
27
27
|
...atMillis > 0 ? dateValues(atMillis) : { date: "", dateCompact: "", time: "", at: "" },
|
|
28
28
|
station,
|
|
29
|
+
// `call` says the same thing, for symmetry with `qso.call` — the name an
|
|
30
|
+
// operator writing a CW message reaches for first; without it,
|
|
31
|
+
// `{{ op.call }}` renders empty with nothing to say why.
|
|
32
|
+
call: station,
|
|
29
33
|
// Deduped, like halo_core's `Operation.stationCalls`: "N0DEV, N0DEV" is a
|
|
30
34
|
// typo, and a template fanning out over this would otherwise repeat
|
|
31
35
|
// itself once per typo.
|
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
|
@@ -70,8 +70,8 @@ export interface FormField {
|
|
|
70
70
|
// fields — a whole section header can be dev-mode-only too.
|
|
71
71
|
devMode?: boolean;
|
|
72
72
|
// Settings-panel-only (ignored in ad hoc forms) — see settings.md's
|
|
73
|
-
// "Common
|
|
74
|
-
// the app's Common
|
|
73
|
+
// "Common Settings and environment gating". Also shows this field on
|
|
74
|
+
// the app's Common Settings quick-access panel.
|
|
75
75
|
common?: boolean;
|
|
76
76
|
// Settings-panel-only. Restricts which platform(s) show this field at
|
|
77
77
|
// all: one or more platform tokens (`ios`, `android`, `macos`,
|
|
@@ -93,6 +93,14 @@ export interface FormHeader {
|
|
|
93
93
|
type: 'header';
|
|
94
94
|
title: string;
|
|
95
95
|
subtitle?: string;
|
|
96
|
+
// How the heading is drawn. Omitted, it is plain text above the fields
|
|
97
|
+
// that follow. 'section' is the app's section band — the accent title
|
|
98
|
+
// over a hairline, which holds the top of the surface while its own rows
|
|
99
|
+
// scroll under it. 'category' is the FILLED band, for a heading that is
|
|
100
|
+
// the top-level grouping of its surface rather than one section inside
|
|
101
|
+
// one (docs/design/user-interface.md § Settings lists). A settings panel
|
|
102
|
+
// is already inside a category, so its own headings take 'section'.
|
|
103
|
+
style?: 'default' | 'section' | 'category';
|
|
96
104
|
devMode?: boolean;
|
|
97
105
|
environment?: string | string[]; // settings-panel-only; see FormField.environment (not `common` — see settings.md)
|
|
98
106
|
}
|
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.
|
|
@@ -237,7 +252,7 @@ interface ExportHook {
|
|
|
237
252
|
generateExport(args: ExportRequest, ctx): Promise<ExportResult>
|
|
238
253
|
}
|
|
239
254
|
// ExportOptionsRequest: {operation, qsos, compactFilenames?}
|
|
240
|
-
// ExportOption: {exportType, format, label, filename?, icon?, priority?, selectedByDefault?}
|
|
255
|
+
// ExportOption: {exportType, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
|
|
241
256
|
// ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
|
|
242
257
|
// ExportResult: {filename, mimeType, content}
|
|
243
258
|
```
|
|
@@ -251,6 +266,15 @@ Exporters should compose per-QSO program fields from `adifFields` hooks (see
|
|
|
251
266
|
below) rather than knowing about specific activities. Implemented by: `adif`
|
|
252
267
|
(key `adif`, one option); Cabrillo will register here too.
|
|
253
268
|
|
|
269
|
+
**How a row looks.** `refType` names the activity the export covers — the
|
|
270
|
+
core slices the file's QSOs by it, and the panel takes the row's icon and
|
|
271
|
+
accent colour from that activity's own control, so a contest's file is
|
|
272
|
+
recognisably the contest's without saying anything about glyphs. `icon` and
|
|
273
|
+
`color` override that, and are how an export that claims no activity — the
|
|
274
|
+
whole-log ADIF — asks for a glyph of its own instead of borrowing one. An
|
|
275
|
+
`icon` name that doesn't resolve falls back to the activity's, same as an
|
|
276
|
+
unreadable `color` does.
|
|
277
|
+
|
|
254
278
|
**Filenames.** Build them with the SDK's `exportFilename` rather than
|
|
255
279
|
spelling one out, passing the request's `compactFilenames` straight through —
|
|
256
280
|
that is what makes the user's "Use compact file names" setting apply to your
|
|
@@ -280,6 +304,11 @@ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
|
|
|
280
304
|
following app-polo's QSON conventions. Handles validation and enrichment of
|
|
281
305
|
activity references on QSOs and operations.
|
|
282
306
|
|
|
307
|
+
`registerHook` takes the type alone. The **qualified** `ref:<type>/<code>` form
|
|
308
|
+
is a manifest claim, not a category — it tells the host which legacy reference
|
|
309
|
+
an extension answers for so it can rewrite one (docs/extensions/README.md), and
|
|
310
|
+
registering a hook under it would register a category nothing ever invokes.
|
|
311
|
+
|
|
283
312
|
```ts
|
|
284
313
|
interface RefHandlerHook {
|
|
285
314
|
validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
|
|
@@ -400,7 +429,10 @@ interface ActivityHook {
|
|
|
400
429
|
//
|
|
401
430
|
// `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
|
|
402
431
|
// kind; a malformed regex disables validation rather than throwing. A
|
|
403
|
-
// mismatch tints the field but still accepts the value
|
|
432
|
+
// mismatch tints the field but still accepts the value — a `refList` segment
|
|
433
|
+
// that fails it becomes a ref like any other, and reaches the log and the
|
|
434
|
+
// exports as typed. The pattern is HaLo's idea of the program rather than the
|
|
435
|
+
// program's own, so it may never cost the operator a reference.
|
|
404
436
|
//
|
|
405
437
|
// `text`, `options` and `serial` values round-trip onto a ref of `refType` under
|
|
406
438
|
// `field`, through the same core-owned path refList writes refs — several may
|
|
@@ -432,8 +464,9 @@ an unrecognized name falls back to a placeholder glyph rather than erroring.
|
|
|
432
464
|
|
|
433
465
|
`color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
|
|
434
466
|
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
|
|
467
|
+
it in the Activities picker, the icon itself once a reference is entered
|
|
468
|
+
in the logging panel's secondary pills, or the reference icon on a spot row
|
|
469
|
+
whose ref is of this control's type. Missing or unrecognized values just
|
|
437
470
|
mean no accent.
|
|
438
471
|
|
|
439
472
|
`processQsoBeforeSave` is the last chance to shape a QSO before it is written,
|
|
@@ -441,7 +474,10 @@ for projecting data the extension owns into the generic QSON fields the rest of
|
|
|
441
474
|
the app reads. A contest keeps its exchange on its own ref (`theirZone`,
|
|
442
475
|
`theirSerial`, …), which nothing generic knows how to read — mirroring it into
|
|
443
476
|
`their.exchange` is what fills the QSO row's exchange column and a plain ADIF
|
|
444
|
-
export.
|
|
477
|
+
export. A contest whose sent exchange changes per QSO (a serial) mirrors that
|
|
478
|
+
into `our.exchange` too, and the column shows it ahead of theirs, in the RST
|
|
479
|
+
cell's order — keep it to the part that varies, since it repeats on every row.
|
|
480
|
+
Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
|
|
445
481
|
`their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
|
|
446
482
|
field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
|
|
447
483
|
refs are shared with the core and with other activities. Return null to do
|
|
@@ -475,15 +511,36 @@ a set of placeless suggestions (dated contest events, say) among themselves, but
|
|
|
475
511
|
never lifts one above a located park. Implemented by: `pota`, `sota`,
|
|
476
512
|
`stateparks`, `cwt`.
|
|
477
513
|
|
|
478
|
-
|
|
479
|
-
`pota
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
514
|
+
**A scoped search offers the reference the operator typed, listed or not.** The
|
|
515
|
+
reference programs (`pota`, `sota` and every one built on `referenceActivity`)
|
|
516
|
+
answer a scoped search with the typed code itself when no row matched it —
|
|
517
|
+
named `unknownReference` where it fits the program's pattern, `invalidReference`
|
|
518
|
+
where it does not. The search is the only way onto the operation, so a program
|
|
519
|
+
whose list is stale, or whose pattern this app has wrong, would otherwise answer
|
|
520
|
+
a reference the operator was handed on the air with "no results". The scope is
|
|
521
|
+
what keeps the MALFORMED half of that narrow: unscoped, every enabled program
|
|
522
|
+
would answer any text at all with an invented reference of its own, burying the
|
|
523
|
+
real matches. (A well-formed code needs no scope — POTA has always offered one,
|
|
524
|
+
since only that program's own pattern can accept it.) A search term that is not
|
|
525
|
+
code-shaped is left alone either way: a single word is how a park or summit gets
|
|
526
|
+
looked up BY NAME, and `pota: beersel` must not answer with an invented BEERSEL.
|
|
527
|
+
The row is marked, and the reference is stored, logged and exported as typed —
|
|
528
|
+
the same rule ADIF import follows for a malformed `*_REF` (below).
|
|
529
|
+
|
|
530
|
+
The activity search box also treats a leading scope word as a scope — typing
|
|
531
|
+
`pota:` (or tapping a row under "Activity Types") asks the extension serving
|
|
532
|
+
that reference type alone, via a key-scoped `invokeHook` rather than the usual
|
|
533
|
+
fan-out. The word is the **reference type's base name**, lowercased —
|
|
534
|
+
`potaActivation` and `pota` both scope as `pota:` — never the extension key: a
|
|
535
|
+
user-published `ki2d-pota` serving `potaActivation` is still scoped as
|
|
536
|
+
`pota:`, since the operator is adding POTA parks whichever extension supplies
|
|
537
|
+
them. Two enabled extensions serving one type share the word, and the scoped
|
|
538
|
+
search asks both. For the hook call to reach the extension, its `activity`
|
|
539
|
+
hook must be registered under its own **extension key**, and its control
|
|
540
|
+
descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
|
|
541
|
+
derives the hook key from the part before the slash. An extension that
|
|
542
|
+
deviates simply never gets scoped: its rows still add references, but the
|
|
543
|
+
scope would ask a hook that doesn't exist and come back empty.
|
|
487
544
|
|
|
488
545
|
`editable` declares whether a reference of this type has anything to edit
|
|
489
546
|
once it's on the operation. It defaults to **false**: a plain reference
|
|
@@ -702,8 +759,8 @@ interface CommandHook {
|
|
|
702
759
|
// CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
|
|
703
760
|
// CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
|
|
704
761
|
// CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
|
|
705
|
-
// sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
|
|
706
|
-
// updateOperation?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
|
|
762
|
+
// setKeyerSpeed?, showKeyer?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
|
|
763
|
+
// updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
|
|
707
764
|
// devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
|
|
708
765
|
// devTimeTravel?, toggleExperiment?}
|
|
709
766
|
```
|
|
@@ -817,9 +874,10 @@ the lookup service's offline `their.guess` decoration. The placement rule
|
|
|
817
874
|
needs the existing log: SEED reads it through `ctx.getQsos`, using the
|
|
818
875
|
`uuid` the logging panel folds into the `operation` map it passes to
|
|
819
876
|
`interpret`; EXP toggles an experimental feature via the `toggleExperiment`
|
|
820
|
-
action field — the hook
|
|
821
|
-
|
|
822
|
-
|
|
877
|
+
action field — the hook parses "EXP <key-or-alias>" and resolves the token
|
|
878
|
+
against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
|
|
879
|
+
resolves it again when it flips the state and reports the outcome, since the
|
|
880
|
+
stored per-experiment state lives app-side;
|
|
823
881
|
DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
|
|
824
882
|
"DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
|
|
825
883
|
anything that turns on the date or on elapsed time can be tested without
|
|
@@ -923,6 +981,36 @@ can't, since it also blocks execution — which is why SEED returns it on
|
|
|
923
981
|
every result despite its parameters not being prose: bare "SEED" must be
|
|
924
982
|
executable (default count) yet still extendable to "SEED 15 3H".
|
|
925
983
|
|
|
984
|
+
## ✅ `template` — rendering on the app's behalf
|
|
985
|
+
|
|
986
|
+
The Liquid renderer (templates.md) lives in the SDK and runs only inside the
|
|
987
|
+
extension runtime. A Dart-side surface that wants a template filled in — a
|
|
988
|
+
CW message about to be keyed (docs/design/cat.md § CW keying) — reaches it
|
|
989
|
+
through this hook, registered once by the core `templates` extension under
|
|
990
|
+
the key `templates`:
|
|
991
|
+
|
|
992
|
+
```ts
|
|
993
|
+
interface TemplateHook {
|
|
994
|
+
render(
|
|
995
|
+
args: { template: string; operation?; qso?; qsoCount?: number },
|
|
996
|
+
ctx,
|
|
997
|
+
): Promise<{ text: string } | { error: string }>
|
|
998
|
+
// Several templates against one context in one crossing (the keyer
|
|
999
|
+
// area's button labels): one result per template, in order.
|
|
1000
|
+
renderMany(
|
|
1001
|
+
args: { templates: string[]; operation?; qso?; qsoCount?: number },
|
|
1002
|
+
ctx,
|
|
1003
|
+
): Promise<{ results: ({ text: string } | { error: string })[] }>
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
`operation` and `qso` are the same objects a `panel` hook's `render` gets,
|
|
1008
|
+
and the result is built with the same `templateContext`, so a placeholder
|
|
1009
|
+
means the same thing wherever the app renders it. A template that does not
|
|
1010
|
+
parse comes back as `error` carrying the renderer's own message — a typo in
|
|
1011
|
+
a message the operator typed, to show them, not a bridge failure to report.
|
|
1012
|
+
The app calls them as `ExtensionService.renderTemplate` and `renderTemplates`.
|
|
1013
|
+
|
|
926
1014
|
## ✅ `scoring` — per-operation score batches
|
|
927
1015
|
|
|
928
1016
|
One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
|
|
@@ -964,7 +1052,12 @@ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
|
|
|
964
1052
|
displaces nothing: core's dupe warning is then the only one there is.
|
|
965
1053
|
|
|
966
1054
|
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.
|
|
1055
|
+
that is what tells the host you have ruled on the question. It travels on the
|
|
1056
|
+
live paths too (`scoreQso`, `scoreCandidates`), not just in `qsoScores`. Rule
|
|
1057
|
+
only on what you have seen: a scorer folds only the segments it was running
|
|
1058
|
+
for, so a station absent from YOUR history may have been worked in a stretch
|
|
1059
|
+
you skipped, and staying silent there hands the question to the scorer holding
|
|
1060
|
+
the whole log. And when your
|
|
968
1061
|
verdict means "worked before, and this one counts anyway", say it in one of
|
|
969
1062
|
those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
|
|
970
1063
|
inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
|
|
@@ -999,6 +1092,10 @@ three pure `ContestScorer` functions and gets the loop, the checkpointing and
|
|
|
999
1092
|
both live paths for free.
|
|
1000
1093
|
|
|
1001
1094
|
`scoreQso` answers for ONE not-yet-logged QSO — the callsign being typed.
|
|
1095
|
+
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1096
|
+
automatic) reaches the scorer stamped with the current time on both live
|
|
1097
|
+
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1098
|
+
candidate that states a time keeps it.
|
|
1002
1099
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1003
1100
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1004
1101
|
scorer and every candidate scored against a COPY of the result, so no candidate
|
|
@@ -1199,7 +1296,7 @@ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
|
|
|
1199
1296
|
platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
|
|
1200
1297
|
(username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
|
|
1201
1298
|
host runs the whole PKCE flow itself, see `_doOAuth` in
|
|
1202
|
-
`app/lib/views/
|
|
1299
|
+
`app/lib/views/settings/accounts_panel.dart`).
|
|
1203
1300
|
|
|
1204
1301
|
**`label` / `description` / `fields`** — each either a plain value, or a
|
|
1205
1302
|
function `(args, ctx) => value` called the same way any other hook method is,
|
package/docs/settings.md
CHANGED
|
@@ -372,17 +372,18 @@ Aliases are for names an operator would plausibly *reach for* — not a thesauru
|
|
|
372
372
|
Every alias is one more thing that can collide with another setting's real name,
|
|
373
373
|
and a collision costs a disambiguation prompt on a query that used to be exact.
|
|
374
374
|
|
|
375
|
-
## Common
|
|
375
|
+
## Common Settings and environment gating
|
|
376
376
|
|
|
377
|
-
The app has
|
|
378
|
-
group and panel,
|
|
379
|
-
settings most users ever touch. Which fields land on Common
|
|
377
|
+
The app has one settings screen, **Settings**: a collapsible section per
|
|
378
|
+
declared group and panel, led by **Common Settings**, a short list of the
|
|
379
|
+
handful of settings most users ever touch. Which fields land on Common
|
|
380
|
+
Settings —
|
|
380
381
|
and which platforms show a field at all — is declared per-element, not
|
|
381
382
|
maintained as a separate list somewhere else in the app. Any field, link, or
|
|
382
383
|
action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
383
384
|
|
|
384
|
-
- **`common: true`** — also show this element
|
|
385
|
-
|
|
385
|
+
- **`common: true`** — also show this element in the Common Settings
|
|
386
|
+
section, on top of its own group's. Off by default. Not the same thing as
|
|
386
387
|
`FormFieldOption.common`/`uncommon` above — that's a `multiselect`
|
|
387
388
|
option's own disclosure tier (upfront vs. behind "Show more"), a
|
|
388
389
|
different scope (an option inside one field, not the field itself).
|
|
@@ -393,8 +394,8 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
|
393
394
|
|
|
394
395
|
- **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
|
|
395
396
|
restrict which platform(s) show this element at all. This is a platform
|
|
396
|
-
gate, not a
|
|
397
|
-
settings surface on that platform, not just Common
|
|
397
|
+
gate, not a common-vs-everything one: an excluded field disappears from every
|
|
398
|
+
settings surface on that platform, not just Common Settings. Every
|
|
398
399
|
token unprefixed is a whitelist (show ONLY there); every token
|
|
399
400
|
`-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
|
|
400
401
|
forms in one attribute is invalid and treated as if `environment` were
|
|
@@ -445,7 +446,7 @@ target but niche enough it shouldn't clutter the default view:
|
|
|
445
446
|
```
|
|
446
447
|
|
|
447
448
|
Omitting `target` doesn't hide a field from anywhere it already appears —
|
|
448
|
-
|
|
449
|
+
The Settings panel is unaffected either way. It's purely
|
|
449
450
|
additive: an extra place a field can be reached from, on top of wherever
|
|
450
451
|
`common`/`environment` already put it.
|
|
451
452
|
|
|
@@ -453,8 +454,8 @@ additive: an extra place a field can be reached from, on top of wherever
|
|
|
453
454
|
elements can both set `target` and both appear in a target-scoped modal —
|
|
454
455
|
an action's method dispatches through the same extension host call the
|
|
455
456
|
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.
|
|
457
|
+
opens that account's own standalone dialog, exactly as it would from the
|
|
458
|
+
Settings panel.
|
|
458
459
|
|
|
459
460
|
## Restarting the runtime after a Tier 2 field commits
|
|
460
461
|
|
package/docs/templates.md
CHANGED
|
@@ -38,14 +38,14 @@ the namespaces are deliberately polo's. The syntax around them does not:
|
|
|
38
38
|
|
|
39
39
|
## What a template can name
|
|
40
40
|
|
|
41
|
-
| namespace | panel `render` | export filename | ADIF fields |
|
|
42
|
-
|
|
43
|
-
| `app` | ✓ | — | ✓ |
|
|
44
|
-
| `now` | ✓ | ✓ | ✓ |
|
|
45
|
-
| `op` | ✓ | dates only | ✓ |
|
|
46
|
-
| `qso` | when the placement's triggers ask for it | — | ✓ |
|
|
47
|
-
| `config` | ✓ | — | — |
|
|
48
|
-
| `log` | — | ✓ | ✓ |
|
|
41
|
+
| namespace | panel `render` | export filename | ADIF fields | CW message |
|
|
42
|
+
|---|---|---|---|---|
|
|
43
|
+
| `app` | ✓ | — | ✓ | ✓ |
|
|
44
|
+
| `now` | ✓ | ✓ | ✓ | ✓ |
|
|
45
|
+
| `op` | ✓ | dates only | ✓ | ✓ |
|
|
46
|
+
| `qso` | when the placement's triggers ask for it | — | ✓ | the draft contact, as far as it is typed |
|
|
47
|
+
| `config` | ✓ | — | — | — |
|
|
48
|
+
| `log` | — | ✓ | ✓ | — |
|
|
49
49
|
|
|
50
50
|
A namespace a surface has nothing for is **absent**, not blank — which is
|
|
51
51
|
what makes `{% if qso %}` an honest question. Filenames get only the date
|
|
@@ -61,7 +61,8 @@ An ISO-8601 UTC string, for the `date` filter: `{{ now | date: '%H:%M' }}`.
|
|
|
61
61
|
|
|
62
62
|
### `op` — the operation
|
|
63
63
|
`station` (the field as typed, which may hold several comma-separated
|
|
64
|
-
callsigns
|
|
64
|
+
callsigns; `call` is the same value, the name `qso.call` has), `stations`
|
|
65
|
+
(that list, uppercased and deduped), `operator`,
|
|
65
66
|
`title` (the generated, ref-derived one), `userTitle` (the operator's own
|
|
66
67
|
words), `grid`, `refs`, `uuid`, `qsoCount`, `date`, `dateCompact`, `time`,
|
|
67
68
|
`at`, `startDate`, `startTime`, `startAt`, `endDate`, `endTime`, `endAt`.
|
|
@@ -192,6 +193,7 @@ will jump rather than count. Show HH:MM.
|
|
|
192
193
|
| Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
|
|
193
194
|
| Export filenames | `sdk/src/exportNames.ts` `NAME_TEMPLATES` | not yet |
|
|
194
195
|
| ADIF NOTES / COMMENT / QSLMSG | `core/adif`'s `TEXT_FIELD_TEMPLATES` | not yet |
|
|
196
|
+
| CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's Messages… dialog |
|
|
195
197
|
|
|
196
198
|
COMMENT and QSLMSG are empty, so nothing is written for them. app-polo
|
|
197
199
|
defaults COMMENT to the QSO's notes and QSLMSG to the operation's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ham2k/extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ham2k",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"sdk",
|
|
11
11
|
"logging"
|
|
12
12
|
],
|
|
13
|
-
"license": "
|
|
13
|
+
"license": "MIT",
|
|
14
14
|
"author": "Sebastian Delmont <sd@ham2k.com>",
|
|
15
15
|
"homepage": "https://ham2k.com",
|
|
16
16
|
"repository": {
|
|
@@ -25,8 +25,17 @@
|
|
|
25
25
|
"subpath, so a tool asking this package its own version is told the package",
|
|
26
26
|
"does not exist. Common enough that npm's own docs call it out."
|
|
27
27
|
],
|
|
28
|
+
"//ham2k-source": [
|
|
29
|
+
"A condition only this repo's own test run asks for (`--conditions=ham2k-source`),",
|
|
30
|
+
"so a test can import the SDK by name and get the TypeScript sources rather",
|
|
31
|
+
"than a build of them — the same thing esbuild's alias does when it bundles an",
|
|
32
|
+
"extension. Deliberately not the conventional `development`: the published",
|
|
33
|
+
"tarball ships `dist` and not `src`, and a consumer who happened to run with",
|
|
34
|
+
"that common condition would be pointed at files their install does not have."
|
|
35
|
+
],
|
|
28
36
|
"exports": {
|
|
29
37
|
".": {
|
|
38
|
+
"ham2k-source": "./src/index.ts",
|
|
30
39
|
"types": "./dist/index.d.ts",
|
|
31
40
|
"default": "./dist/index.js"
|
|
32
41
|
},
|