@ham2k/extension-sdk 1.12.0 → 1.12.2

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 1.12.0
1
+ // @ham2k/extension-sdk 1.12.2
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -1242,6 +1242,14 @@ export interface ChasingProgram {
1242
1242
  supersedes?: string[];
1243
1243
  refLabel?: string;
1244
1244
  imports?: ChasingImporter[];
1245
+ notices?: Partial<Record<ChaseNoticeVerdict, string>>;
1246
+ modeLabels?: Record<string, string>;
1247
+ translations?: Record<string, ChasingNoticeLabels>;
1248
+ }
1249
+ export type ChaseNoticeVerdict = "newRef" | "newBand" | "newMode" | "unconfirmed";
1250
+ export interface ChasingNoticeLabels {
1251
+ notices?: Partial<Record<ChaseNoticeVerdict, string>>;
1252
+ modeLabels?: Record<string, string>;
1245
1253
  }
1246
1254
  export interface ChasingImporter {
1247
1255
  key: string;
@@ -1595,6 +1603,12 @@ export interface ExtensionRelevance {
1595
1603
  dates?: string[];
1596
1604
  interests?: string[];
1597
1605
  }
1606
+ export type SettingsSection = "activities" | "hunting" | "contests" | "lookups" | "spotting" | "dashboard" | "logging" | "radio" | "map" | "exports";
1607
+ export interface ExtensionSettingsPlacement {
1608
+ section?: SettingsSection | (string & {});
1609
+ sidebar?: boolean;
1610
+ common?: boolean;
1611
+ }
1598
1612
  export interface ExtensionManifest {
1599
1613
  key: string;
1600
1614
  name: string;
@@ -1620,6 +1634,7 @@ export interface ExtensionManifest {
1620
1634
  requiresClusterCommand?: boolean;
1621
1635
  experiments?: string[];
1622
1636
  relevance?: ExtensionRelevance;
1637
+ settings?: ExtensionSettingsPlacement;
1623
1638
  translations?: Record<string, {
1624
1639
  name?: string;
1625
1640
  description?: string;
@@ -144,6 +144,28 @@ The packer refuses:
144
144
  The matcher reads a key it cannot parse as "does not gate", so each of the
145
145
  shape errors would otherwise ship the bundle as worldwide.
146
146
 
147
+ ### Settings
148
+
149
+ An extension with a `settingsPanel` says where its panel goes:
150
+
151
+ ```json
152
+ "settings": { "section": "activities" }
153
+ ```
154
+
155
+ `section` is one of the app's settings sections, picked by the extension's
156
+ main purpose; `sidebar: true` asks for a sidebar entry for a panel that is a
157
+ category of its own, and `common: true` for Common Preferences rows. The section list, and what the app does with a
158
+ placement the manifest does not declare, are in
159
+ [settings.md](./settings.md#where-an-extensions-settings-go).
160
+
161
+ The packer refuses a `settings` that is not an object, a key inside it other
162
+ than those three, a `section` that is not a section key, and a `sidebar` or
163
+ `common` that is not `true` or `false`. It does not refuse a placement beyond
164
+ the usual — a section outside the list, no section for a `settingsPanel`, a
165
+ sidebar entry, Common Preferences — but prints `held for review on the
166
+ catalog` for each, because the catalog holds such an upload for an admin
167
+ even from a trusted publisher.
168
+
147
169
  ## Using the host's libraries
148
170
 
149
171
  The host carries a set of libraries and hands every extension the same
@@ -395,12 +417,14 @@ can move it forward from there.
395
417
 
396
418
  Which ones ship is staged. Today it is the catalog twin of every extension the
397
419
  app used to build in (see [Retired built-ins](#retired-built-ins)), so that an
398
- install moving off them has every one to hand with no network. Once installs
399
- still on a build with the built-ins are few enough, it shrinks to the eight a
400
- new install needs — `ham2k-pota`, `ham2k-sota`, `ham2k-wwff` and the five
401
- lookups — and the rest are fetched from the catalog when switched on
420
+ install moving off them has every one to hand with no network, and
421
+ `ham2k-dxcc`, which replaced the core `dxcc` chasing program and is on for
422
+ everyone as that was. Once installs still on a build with the built-ins are few
423
+ enough, it shrinks to the nine a new install needs — `ham2k-pota`,
424
+ `ham2k-sota`, `ham2k-wwff`, `ham2k-dxcc` and the five lookups — and the rest
425
+ are fetched from the catalog when switched on
402
426
  ([HALO-743](https://cabo.ham2k.com/halo/c/743)). The app's own tests rely on
403
- only those eight, the core, and the never-shipped samples, so the shrink
427
+ only those nine, the core, and the never-shipped samples, so the shrink
404
428
  touches none of them.
405
429
 
406
430
  **They have to be kept current.** A pre-load is frozen into the binary, so
package/docs/hooks.md CHANGED
@@ -1607,7 +1607,14 @@ registerHook("chasing", { key: "dxcc", hook: {
1607
1607
  without invoking anything. Raise `version` whenever the same contacts would
1608
1608
  claim something different: the app keeps no claim from an older version.
1609
1609
  `refLabel` is what one reference is called where a notice names it
1610
- ("New POTA Park"), the program's `label` when left out.
1610
+ ("New POTA Park"), the program's `label` when left out. A program may
1611
+ instead word a verdict's notice itself: `notices` by verdict (`newRef`,
1612
+ `newBand`, `newMode`, `unconfirmed`), where `{{band}}` names the band a
1613
+ new band is on and `{{mode}}` the mode a new mode is in, through
1614
+ `modeLabels`, with both per language under `translations` (`{ es: {
1615
+ notices, modeLabels } }`). A verdict it leaves out, or one whose notice
1616
+ lacks the band or mode its words name, reads the app's way; a guess reads
1617
+ "Probably …" around the program's words.
1611
1618
  `supersedes` names the scoring notices the program's verdicts stand in for
1612
1619
  (DXCC: `newDXCC`): wherever the program judges a call or a spot, the
1613
1620
  scorer's notice is dropped in favor of the verdict.
@@ -1662,9 +1669,12 @@ extension API 9: a bundle that registers a program declares `"api": 9`, which
1662
1669
  the packer enforces, so an older app — which would install it and never ask
1663
1670
  it anything — is never offered it.
1664
1671
 
1665
- Implemented by: `dxcc` (core) — Mixed, per-band and per-mode slots for the
1666
- station call worked from each entity; LoTW and paper QSLs confirm, LoTW credit
1667
- verifies. `ham2k-pota` — one slot per park hunted, worked only; its
1672
+ Implemented by: `ham2k-dxcc` (catalog, pre-loaded; core `dxcc` until
1673
+ HALO-844) — Mixed, per-mode and per-band slots for the station call worked
1674
+ from each entity, worded "DXCC ATNO", "DXCC in CW", "DXCC in 80m"; LoTW and
1675
+ paper QSLs confirm, `credits.dxcc` verifies; the DXCC Challenge is a tally of
1676
+ its band slots. A stored goal for `dxcc` carries to it
1677
+ (`kRenamedChasePrograms`). `k2hrc-dxcc` is the SDK's worked example. `ham2k-pota` — one slot per park hunted, worked only; its
1668
1678
  hunted-parks export verifies. `ham2k-was` — Mixed, per-band and per-mode
1669
1679
  slots (plus FT8 and RTTY) for the station call worked from one 4-character
1670
1680
  grid; LoTW and paper QSLs confirm, `WAS` credit verifies.
@@ -1961,7 +1971,8 @@ stay as drawn and only spread apart or close in; a layer with `stretch: true`
1961
1971
  a line the length of the scene — scales along the scroll instead. That preview holds until
1962
1972
  the render that answers the `commit` arrives, so a scene meant to zoom draws its pieces as
1963
1973
  layers of their own rather than as one picture. A tap on the strip still reaches the button there, and a drag that starts off it
1964
- still scrolls.
1974
+ still scrolls. A trackpad's two-finger swipe scrolls on the strip as anywhere else; a trackpad
1975
+ pinch begun on it zooms by the pinch's own scale, clamped the same way, and commits as a drag does.
1965
1976
 
1966
1977
  A scroll steps a slider or knob by its `step`, down and left lowering it; a one-axis
1967
1978
  control takes a sideways scroll by its own direction. A slider or knob with
@@ -2477,6 +2488,12 @@ schema, kernel dispatch, `ExtensionService`, and `SettingsView` wiring; see
2477
2488
  [settings.md](./settings.md). Complementary to `account`, not a replacement.
2478
2489
  Implemented by: `settings` (the core General panel).
2479
2490
 
2491
+ An extension puts all its settings in one panel, placed in the section that
2492
+ fits its main purpose and declared in the manifest's `settings` — see
2493
+ [Where an extension's settings go](./settings.md#where-an-extensions-settings-go).
2494
+ A category of its own, a sidebar entry or Common Preferences rows are reviewed
2495
+ on the catalog.
2496
+
2480
2497
  ## ✅ `account` — account connections
2481
2498
 
2482
2499
  Account connections (QRZ, LoFi, SOTA): credential fields, `testCredentials`,
package/docs/settings.md CHANGED
@@ -26,6 +26,122 @@ There are two tiers, discriminated by a required `kind` tag.
26
26
 
27
27
  ---
28
28
 
29
+ ## Where an extension's settings go
30
+
31
+ **Put all of an extension's settings in one panel, and that panel in the
32
+ section that fits the extension's main purpose.** The panel shows there as a
33
+ single row that opens it in a dialog. An extension does not add a category of
34
+ its own to Application Preferences, an entry to the Settings sidebar, or
35
+ fields to Common Preferences — not without an admin's review on the catalog.
36
+
37
+ These sections take an extension's panel without review:
38
+
39
+ | `section` | Shown as | For |
40
+ |---|---|---|
41
+ | `activities` | Activities | an activity program — POTA, SOTA, WWFF |
42
+ | `hunting` | Hunting | chasing and awards: what an operator works rather than activates |
43
+ | `contests` | Contests | a contest or event |
44
+ | `lookups` | Lookups | callsign lookups, notes and history |
45
+ | `spotting` | Spotting | a spot source or destination |
46
+ | `dashboard` | Dashboard | a dashboard panel's app-wide preferences |
47
+ | `logging` | Logging | something that changes how QSOs are entered |
48
+ | `radio` | CAT Control | radios, rigs and keyers |
49
+ | `map` | Maps | the map views |
50
+ | `exports` | Exports | export files and formats |
51
+
52
+ Prefer the first six — they say what the extension is. The last four suit an
53
+ extension that tunes something the app already has a section for. A section
54
+ holding no panel is not shown.
55
+
56
+ The section is declared twice: in the **manifest**, which is what the catalog
57
+ reviews and what the app places the panel by, and on the **panel**, for an app
58
+ older than the manifest field. In `manifest.json`:
59
+
60
+ ```json
61
+ {
62
+ "key": "w7abc-park-notes",
63
+ "category": "activity",
64
+ "hooks": ["settingsPanel"],
65
+ "settings": { "section": "activities" }
66
+ }
67
+ ```
68
+
69
+ And where the panel is registered:
70
+
71
+ ```ts
72
+ api.registerHook('settingsPanel', {
73
+ hook: {
74
+ kind: 'simple',
75
+ title: 'Park Notes',
76
+ section: 'activities',
77
+ sidebar: false,
78
+ fields: [ /* … */ ],
79
+ },
80
+ });
81
+ ```
82
+
83
+ `settings` has three keys, all optional:
84
+
85
+ - **`section`** — the key of the section, from the table above.
86
+ - **`sidebar: true`** — a panel that is a category of its own (no `section`,
87
+ or one the app does not have) may also have an entry in the Settings
88
+ sidebar. A panel placed in a section is a row there and never has one.
89
+ Reviewed.
90
+ - **`common: true`** — the panel's fields flagged `common: true` may also show
91
+ in Common Preferences. Reviewed.
92
+
93
+ ### What is reviewed
94
+
95
+ The catalog reads a manifest and never runs a bundle, so the manifest is what
96
+ it reviews. An upload is held for an admin — even one from a trusted
97
+ publisher, whose uploads otherwise skip the queue — when its manifest:
98
+
99
+ - lists `settingsPanel` in `hooks` and names no `settings.section` — the panel
100
+ would be a category of its own;
101
+ - names a `settings.section` outside the table above — a new category, which
102
+ the app also has to know (a section the app does not know is shown as a
103
+ category of its own);
104
+ - sets `settings.sidebar` or `settings.common`.
105
+
106
+ A placement an admin has approved stays approved: a later version whose
107
+ `settings` are unchanged is not held for it again. To ask for a new category
108
+ or a sidebar entry, declare it and say why in the release notes; the reviewer
109
+ reads both. `npm run pack` (`h2kext-pack`) prints `held for review on the
110
+ catalog` for each of these, so it is no surprise.
111
+
112
+ ### What the app enforces
113
+
114
+ The manifest is a promise the code could break, so the app holds every
115
+ extension it did not ship to its manifest, whatever the panel asks for:
116
+
117
+ - the panel goes in the manifest's `settings.section`; with none declared, in
118
+ the panel's own `section` if it is one of the table's; otherwise it is a
119
+ category of its own;
120
+ - it has a sidebar entry only when it is a category of its own, the panel's
121
+ `sidebar` is not false AND the manifest declares `sidebar: true` — so with
122
+ no `settings`, `sidebar` reads as false;
123
+ - its `common: true` fields reach Common Preferences only when the manifest
124
+ declares `common: true`; otherwise the flag is ignored and the fields stay
125
+ in the panel.
126
+
127
+ A panel from an extension whose manifest `hooks` does not list
128
+ `settingsPanel` is not shown at all: the catalog decides what to review by
129
+ that list, so an undeclared panel is one nobody was asked about.
130
+ `buildExtension` fails the build when the bundle registers a panel its
131
+ manifest does not declare, unless it cannot activate the bundle to find out,
132
+ in which case it warns.
133
+
134
+ An extension installed from a `.h2kext` file never passed through the
135
+ catalog, so nobody reviewed its manifest: the app takes its `settings` as
136
+ written, `sidebar` and `common` included. Installing a file is the
137
+ operator's own choice to trust it.
138
+
139
+ Everything else about a panel is untouched: `dataFilesSection` still adds its
140
+ row to the Data Files screen, and `target` still reaches the Logging and
141
+ Developer dialogs (see [Settings targets](#settings-targets)).
142
+
143
+ ---
144
+
29
145
  ## Key namespacing
30
146
 
31
147
  Wherever an extension can define more than one of something under one
@@ -76,7 +192,9 @@ export interface SimpleSettingsPanel {
76
192
  order?: number;
77
193
  // Defaults true. Set false to keep this panel out of the app's top-level
78
194
  // settings navigation (the sidebar list). Independent of dataFilesSection
79
- // below — this only controls the top-level nav.
195
+ // below — this only controls the top-level nav. An extension the app did
196
+ // not ship also needs the manifest's `settings.sidebar: true`, which is
197
+ // reviewed; see "Where an extension's settings go".
80
198
  sidebar?: boolean;
81
199
  // Defaults false. Set true to also show this panel as a row in the Data
82
200
  // Files screen's own "Extensions" section — leave this false for anything
@@ -85,10 +203,12 @@ export interface SimpleSettingsPanel {
85
203
  // the type level — inferring this from `sidebar: false` alone would sweep
86
204
  // any sidebar-opt-out panel in here.
87
205
  dataFilesSection?: boolean;
88
- // The key of one of the app's own settings sections ('spotting', the
89
- // Spotting section) to show this panel inside, as one row that opens it
90
- // in a dialog — instead of a section of its own in Settings and a sidebar
91
- // entry, whatever `sidebar` says. An app without that section (an older
206
+ // The key of one of the app's own settings sections ('activities',
207
+ // 'spotting', … — see "Where an extension's settings go") to show this
208
+ // panel inside, as one row that opens it in a dialog — instead of a
209
+ // section of its own in Settings and a sidebar entry, whatever `sidebar`
210
+ // says. Declare the same key as the manifest's `settings.section`, which is
211
+ // what the app places the panel by. An app without that section (an older
92
212
  // one, or a key it doesn't know) shows the panel as if this weren't set,
93
213
  // so it needs no newer API level.
94
214
  section?: string;
@@ -363,8 +483,11 @@ override) without it cluttering the panel for regular users:
363
483
  A panel with nothing to show at the current developer-mode setting is left
364
484
  out of the Settings screen's list of categories rather than shown as an empty
365
485
  heading, so a panel whose every field is `devMode` appears there only while
366
- developer mode is on. Its sidebar entry is not: give such a panel
367
- `sidebar: false`, or the entry opens on an empty page.
486
+ developer mode is on. A panel placed in a `section` is treated the same way:
487
+ its row there is a developer-mode row when every field it has is `devMode`,
488
+ and it has no row at all when it has no fields on this platform. Its sidebar
489
+ entry is not: give such a panel `sidebar: false`, or the entry opens on an
490
+ empty page.
368
491
 
369
492
  ## `aliases`: short names for the SET command
370
493
 
@@ -404,7 +527,10 @@ maintained as a separate list somewhere else in the app. Any field, link, or
404
527
  action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
405
528
 
406
529
  - **`common: true`** — also show this element in the Common Preferences
407
- section, on top of its own group's. Off by default. Not the same thing as
530
+ section, on top of its own group's. Off by default. An extension's
531
+ `common` fields reach Common Preferences only when its manifest declares
532
+ `settings.common: true`, which is reviewed — see
533
+ [Where an extension's settings go](#where-an-extensions-settings-go). Not the same thing as
408
534
  `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
409
535
  option's own disclosure tier (upfront vs. behind "Show more"), a
410
536
  different scope (an option inside one field, not the field itself).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
package/samples/README.md CHANGED
@@ -10,6 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
10
10
  | `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
11
11
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
12
12
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
13
+ | `k2hrc-dxcc` | A chasing program: what each contact counts toward, and how its notices read (extension API 9) |
13
14
  | `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
14
15
  | `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
15
16
  | `k2hrc-panafall` | A radio's panadapter and waterfall in a scene, styled and tuned by click (extension API 7) |
@@ -0,0 +1,6 @@
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
+
4
+ await buildExtension(build, { dir: import.meta.dirname })
5
+
6
+ console.log("built build/index.js")
@@ -0,0 +1,23 @@
1
+ {
2
+ "key": "k2hrc-dxcc",
3
+ "name": "DXCC (sample)",
4
+ "shortName": "DXCC*",
5
+ "version": "1.0.0",
6
+ "description": "The DXCC entities a station has worked and confirmed, as a worked example of a chasing program",
7
+ "category": "award",
8
+ "icon": "earth",
9
+ "accentColor": "#2E7D32",
10
+ "api": 9,
11
+ "keywords": ["award", "dxcc", "chasing", "sample"],
12
+ "hooks": ["chasing"],
13
+ "sharedDependencies": {
14
+ "@ham2k/lib-callsigns": "^1.0.0",
15
+ "@ham2k/lib-country-files": "^1.0.0",
16
+ "@ham2k/lib-dxcc-data": "^1.0.0",
17
+ "@ham2k/lib-format-tools": "^1.0.0",
18
+ "@ham2k/lib-geo-tools": "^1.0.0",
19
+ "@ham2k/lib-operation-data": "^1.0.0",
20
+ "i18next": "^23.0.0",
21
+ "liquidjs": "^10.0.0"
22
+ }
23
+ }
@@ -0,0 +1,104 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — a chasing program: what each contact counts toward.
5
+ //
6
+ // An award is a checklist, and a chasing program says which lines of it a
7
+ // contact fills. The app keeps the best claim for each line, ranks spots by
8
+ // what working the station would add, and alerts on what is new. The program
9
+ // never reads the log: `claimsForQsos` is a pure function of each contact,
10
+ // because the app also asks it about spots and about the call being typed.
11
+ //
12
+ // One contact makes several claims, one per kind of line the award keeps —
13
+ // here DXCC's three: Mixed (any band, any mode), its band, and its mode. A
14
+ // missing Mixed line is a new entity, a missing band or mode line a new band
15
+ // or mode, and `notices` says how each reads ("DXCC in 80m").
16
+ //
17
+ // The rules here are DXCC's, simplified: the catalog's `ham2k-dxcc` adds
18
+ // deleted entities, maritime mobile, satellites and the DXCC Challenge. See
19
+ // `docs/hooks.md` §`chasing`.
20
+
21
+ import { defineExtension, entityForCall } from "@ham2k/extension-sdk"
22
+ import type { ChasingHook, JSONValue, QsoClaim } from "@ham2k/extension-sdk"
23
+ import { parseCallsign } from "@ham2k/lib-callsigns"
24
+ import { ADIF_MODES_AND_SUBMODES, superModeForMode } from "@ham2k/lib-operation-data"
25
+
26
+ import manifest from "../manifest.json" with { type: "json" }
27
+
28
+ /// DXCC's three mode awards. `superModeForMode` calls anything it does not
29
+ /// know `DATA`, so a mode is checked against ADIF's list first.
30
+ const KNOWN_MODES = new Set(ADIF_MODES_AND_SUBMODES.map((mode) => mode.toUpperCase()))
31
+ const AWARD_MODES: Record<string, string> = { CW: "CW", PHONE: "PHONE", DATA: "DIGITAL" }
32
+
33
+ function obj(value: JSONValue | undefined): Record<string, JSONValue> {
34
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {}
35
+ }
36
+
37
+ /// Who the claim belongs to: an opaque string the program builds. DXCC goes
38
+ /// to a call, for contacts made from one entity — KI2D and KI2D/P share a
39
+ /// checklist, KI2D/VE2 keeps its own.
40
+ function holderFor(station: Record<string, JSONValue>): string | undefined {
41
+ if (typeof station.call !== "string" || !station.call) return undefined
42
+ const call = station.call.trim().toUpperCase()
43
+ const base = parseCallsign(call).baseCall || call
44
+ const entity = typeof station.dxccCode === "number" ? station.dxccCode : entityForCall(call).dxccCode
45
+ return entity == null ? base : `${base}@${entity}`
46
+ }
47
+
48
+ /// `worked` from the contact itself, `confirmed` by a QSL the award takes.
49
+ function levelOf(qso: Record<string, JSONValue>): Pick<QsoClaim, "level" | "by"> {
50
+ const qsl = obj(qso.qsl)
51
+ if (obj(qsl.lotw).received === true) return { level: "confirmed", by: "lotw" }
52
+ if (obj(qsl.qsl).received === true) return { level: "confirmed", by: "qsl" }
53
+ return { level: "worked" }
54
+ }
55
+
56
+ function claimsFor(qso: Record<string, JSONValue>): QsoClaim[] {
57
+ // An event marker's calls are placeholders, not stations.
58
+ if (qso.band === "event") return []
59
+ const their = obj(qso.their)
60
+ const holder = holderFor(obj(qso.our))
61
+ if (typeof their.call !== "string" || !holder) return []
62
+ const { dxccCode, entityName } = entityForCall(their.call)
63
+ if (dxccCode == null) return []
64
+
65
+ const base = { holder, ref: String(dxccCode), ...(entityName ? { label: entityName } : {}), ...levelOf(qso) }
66
+ const claims: QsoClaim[] = [{ ...base, band: null, mode: null }]
67
+ if (typeof qso.band === "string" && qso.band) claims.push({ ...base, band: qso.band, mode: null })
68
+ const mode = typeof qso.mode === "string" && KNOWN_MODES.has(qso.mode.toUpperCase()) ? AWARD_MODES[superModeForMode(qso.mode.toUpperCase())] : undefined
69
+ if (mode) claims.push({ ...base, band: null, mode })
70
+ return claims
71
+ }
72
+
73
+ const program: ChasingHook = {
74
+ // Plain data, read without calling anything. Raise `version` whenever the
75
+ // same contacts would claim something different.
76
+ program: {
77
+ label: "DXCC*",
78
+ icon: "earth",
79
+ version: 1,
80
+ editions: "lifetime",
81
+ notices: { newRef: "New DXCC* entity", newBand: "DXCC* on {{band}}", newMode: "DXCC* in {{mode}}" },
82
+ modeLabels: { PHONE: "Phone", DIGITAL: "Digital" },
83
+ },
84
+ // Keyed by each contact's uuid; one without a uuid has no key to answer under.
85
+ claimsForQsos: ({ qsos }) =>
86
+ Object.fromEntries(qsos.filter((qso) => typeof qso.uuid === "string" && qso.uuid).map((qso) => [qso.uuid as string, claimsFor(qso)])),
87
+ holdersFor: ({ station }) => {
88
+ const holder = holderFor(station)
89
+ return holder ? [holder] : []
90
+ },
91
+ // One tally per thing worth counting: entities, from their Mixed lines.
92
+ summarizeClaims: ({ slots }) => {
93
+ const entities = slots.filter((slot) => slot.band === null && slot.mode === null)
94
+ const confirmed = entities.filter((slot) => slot.level !== "worked").length
95
+ return { entities: { key: "entities", icon: "earth", label: `${entities.length} entities`, summary: `${confirmed} confirmed`, total: entities.length } }
96
+ },
97
+ }
98
+
99
+ defineExtension({
100
+ ...manifest,
101
+ onActivation({ registerHook }) {
102
+ registerHook("chasing", { hook: program, key: manifest.key })
103
+ },
104
+ })