@ham2k/extension-sdk 0.1.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.
@@ -0,0 +1,206 @@
1
+ # Templates
2
+
3
+ One templating engine for every place HaLo turns log data into text: a
4
+ panel's document, an export's filename, an ADIF field. One vocabulary too —
5
+ `{{ op.station }}` means the same thing wherever it appears.
6
+
7
+ The engine is **[Liquid](https://liquidjs.com)** (`liquidjs`), a kernel
8
+ shared module wrapped by `sdk/src/templates.ts`. Everything goes through that
9
+ wrapper: it owns the option set, and a second engine built elsewhere would
10
+ disagree about timezones without anyone noticing.
11
+
12
+ ```ts
13
+ import { renderTemplate, templateContext, triggersForTemplate } from "@ham2k/extension-sdk"
14
+
15
+ const text = renderTemplate("{{ op.station }} — {{ op.qsoCount }} QSOs", templateContext({ operation, qsoCount }))
16
+ ```
17
+
18
+ ## Why Liquid rather than app-polo's Handlebars
19
+
20
+ A template is DATA. Liquid parses and interprets; Handlebars compiles
21
+ through `new Function`. These templates are written by operators, copied
22
+ between operators, and will eventually be editable in settings, so the one
23
+ that cannot become code is the one to have. Liquid also has comparisons
24
+ (`{% if op.qsoCount > 100 %}`) and loops built in, where Handlebars needs a
25
+ registered helper for `>` and Mustache cannot compare at all.
26
+
27
+ Measured in the shipped QuickJS-NG build: 80 KB minified (24 KB gzipped),
28
+ 6 ms to evaluate at startup, 0.33 ms per parse+render of a small template —
29
+ and parsed templates are cached, so repeated renders of one document cost
30
+ about 2 µs each.
31
+
32
+ **Porting from PoLo**: the plain majority of polo's templates —
33
+ `{{ op.date }} {{ log.station }} at {{ log.ref }}` — parse unchanged, because
34
+ the namespaces are deliberately polo's. The syntax around them does not:
35
+ `{{#if}}` is `{% if %}{% endif %}`, `{{#each}}` is `{% for %}{% endfor %}`,
36
+ `{{> Partial}}` has no equivalent, and helpers are filters
37
+ (`{{ dash x }}` → `{{ x | dash }}`).
38
+
39
+ ## What a template can name
40
+
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
+
50
+ A namespace a surface has nothing for is **absent**, not blank — which is
51
+ what makes `{% if qso %}` an honest question. Filenames get only the date
52
+ half of `op` because a filename is built from parts rather than from an
53
+ operation (`exportNames.ts`).
54
+
55
+ ### `app`
56
+ `app.name` — the platform-appropriate name ("Ham2K Logger" on desktop,
57
+ "Ham2K Portable Logger" on mobile).
58
+
59
+ ### `now`
60
+ An ISO-8601 UTC string, for the `date` filter: `{{ now | date: '%H:%M' }}`.
61
+
62
+ ### `op` — the operation
63
+ `station` (the field as typed, which may hold several comma-separated
64
+ callsigns; `call` is the same value, the name `qso.call` has), `stations`
65
+ (that list, uppercased and deduped), `operator`,
66
+ `title` (the generated, ref-derived one), `userTitle` (the operator's own
67
+ words), `grid`, `refs`, `uuid`, `qsoCount`, `date`, `dateCompact`, `time`,
68
+ `at`, `startDate`, `startTime`, `startAt`, `endDate`, `endTime`, `endAt`.
69
+
70
+ `endDate` / `endTime` / `endAt` are the LAST CONTACT'S START — an operation
71
+ row records when its final QSO began and not when it ended, so a template
72
+ printing `{{ op.startTime }}–{{ op.endTime }}` understates the session by
73
+ that contact's length.
74
+
75
+ ### `qso` — one contact
76
+ `call`, `their`, `our` (both whole, so `qso.their.guess.name` reaches the
77
+ lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `notes`,
78
+ `date`, `dateCompact`, `time`, `at`, `startAtMillis`, `refs`.
79
+
80
+ `rstSent` is what WE sent — QSON stores each side's report under its own
81
+ `sent`, and these are named for the operator's view of the contact.
82
+
83
+ ### `config` — a panel's own form values
84
+ Whatever that panel's `form` declared, under the keys it used.
85
+
86
+ ### `log` — one export
87
+ `station`, `ref`, `refName`, `refShortName`, `activity`, `handlerName`,
88
+ `handlerShortName`, `format`, `exportType`, `modifier`, `extension`,
89
+ `compact`.
90
+
91
+ ## Timestamps
92
+
93
+ Two forms, and the rule is worth learning once:
94
+
95
+ - **`…At` fields are ISO-8601 UTC strings** — feed these to the `date`
96
+ filter: `{{ qso.at | date: '%H:%MZ' }}`.
97
+ - **`date` / `dateCompact` / `time` are preformatted UTC** —
98
+ `2026-07-27`, `20260727`, `1430`.
99
+
100
+ Everything is UTC. A log is kept in UTC, and a filename that shifted with
101
+ the reader's zone would name the wrong day.
102
+
103
+ **`op`'s dates can be empty.** `startAtMillisMin`/`startAtMillisMax` are
104
+ columns on the operation row, not keys in the QSON `data` a hook is handed,
105
+ so they arrive only where the caller adds them: the panel host does, and the
106
+ ADIF export passes the first QSO's time instead. Where neither happens,
107
+ `op.date` and the `start…`/`end…` fields render empty rather than falling
108
+ back to today — a pane showing yesterday's log must not print today's date
109
+ with nothing to say it was invented.
110
+
111
+ ## Filters
112
+
113
+ All of Liquid's own (`date`, `downcase`, `upcase`, `strip`, `default`,
114
+ `join`, `size`, `first`, `last`, `round`, `truncate`, `replace`, `map`,
115
+ `where`, …), plus two:
116
+
117
+ | filter | does | example |
118
+ |---|---|---|
119
+ | `dash` | non-alphanumerics → `-`, collapsed and trimmed, **case preserved** | `N0CALL/P` → `N0CALL-P` |
120
+ | `alnum` | strip to alphanumerics | `2026-07-27` → `20260727` |
121
+
122
+ `alnum` is app-polo's `compact` helper under a different name: Liquid already
123
+ has a `compact` (it drops nils from an **array**, and `op.refs`/`qso.refs` are
124
+ arrays), and `registerFilter` overwrites without warning, so taking that name
125
+ would silently break templates written against Liquid's own documentation.
126
+
127
+ `dash` keeps case because callsigns and references are written in capitals
128
+ and stop being legible flattened. For free text, `{{ x | downcase | dash }}`
129
+ is the slug form.
130
+
131
+ ## Traps
132
+
133
+ Each of these was measured against the shipped runtime, not assumed.
134
+
135
+ **An empty string is TRUE.** In Liquid only `nil` and `false` are falsy, so
136
+ `{% if log.modifier %}` fires on `""`. Ask for blankness explicitly:
137
+ `{% if log.modifier != blank %}`.
138
+
139
+ **A raw millisecond timestamp is not a date.** The `date` filter reads a
140
+ number as SECONDS, so epoch millis render in the year 58567. Use the `…At`
141
+ strings.
142
+
143
+ **Unknown names are silent.** An unknown variable renders empty and an
144
+ unknown filter passes its value through. That is deliberate — an operator's
145
+ typo costs one line, not the document — but it means a misspelled
146
+ `{{ op.stationCall }}` looks like missing data rather than a mistake.
147
+
148
+ **Text written before templating existed can lose a span.** `{{ TODO }}` in
149
+ an old note is a perfectly valid reference to a variable that doesn't exist,
150
+ so it renders as nothing at all rather than as an error — the note quietly
151
+ comes back a few words shorter. Only clearly broken syntax (`{% if` with no
152
+ `{% endif %}`) reports itself; `custom-text` then shows the message with the
153
+ operator's own text intact below it, because a pane that answered by
154
+ deleting what somebody wrote would be the worse failure.
155
+
156
+ **Literal braces need `{% raw %}`.** That is also the fix for the case
157
+ above: wrap the passage and it renders as typed.
158
+
159
+ **`{% include %}` throws.** There is no filesystem in the runtime. Partials
160
+ would have to be supplied in memory.
161
+
162
+ **Loops are bounded by time, not by iterations.** A render is abandoned
163
+ after 250 ms with a `TemplateError` naming the limit.
164
+
165
+ ## Errors
166
+
167
+ `renderTemplate` throws `TemplateError`, carrying Liquid's own message with
168
+ the line and column. It does not fall back: a broken template is either an
169
+ operator's typo, which they can only fix if they are told, or a bug in one
170
+ of ours, which tests should catch. Where the template is the operator's,
171
+ show them the message — `custom-text` renders it in the pane.
172
+
173
+ ## Panel triggers
174
+
175
+ `triggersForTemplate(text)` reports which panel triggers a template needs,
176
+ derived from the namespaces it names: `qso` → `qso` + `lookup`, `qsoCount` →
177
+ `qsoLogged`, `op` → `operation`, `now` → `tick:30`. A panel rendering
178
+ operator-written text passes these back as `PanelContent.triggers`
179
+ (hooks.md, `panel`), so each pane pays only for what its own text asks for.
180
+
181
+ Only `{{ … }}` and `{% … %}` spans are read, never the prose around them —
182
+ "Goal: 100 qso today" is a note, not a request to be woken per keystroke.
183
+ Inside a span it is deliberately generous: `{% if qso %}` counts as naming
184
+ the QSO, since a false negative shows stale text with no clue why.
185
+
186
+ `now` is refreshed on a **30-second** tick, so `{{ now | date: '%H:%M:%S' }}`
187
+ will jump rather than count. Show HH:MM.
188
+
189
+ ## Where templates are used today
190
+
191
+ | what | where | editable |
192
+ |---|---|---|
193
+ | Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
194
+ | Export filenames | `sdk/src/exportNames.ts` `NAME_TEMPLATES` | not yet |
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 |
197
+
198
+ COMMENT and QSLMSG are empty, so nothing is written for them. app-polo
199
+ defaults COMMENT to the QSO's notes and QSLMSG to the operation's
200
+ references; both wait here until there is a settings screen to edit them in,
201
+ since a default nobody can see is one nobody can turn off, and these fields
202
+ travel to a program's servers.
203
+
204
+ Whatever fills them must respect the private/public split: `notes` is the
205
+ operator's own words and belongs only in a field the export withholds when
206
+ private data is off. NOTES is such a field. COMMENT and QSLMSG are not.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.1.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": "MPL-2.0",
13
+ "license": "MIT",
14
14
  "author": "Sebastian Delmont <sd@ham2k.com>",
15
15
  "homepage": "https://ham2k.com",
16
16
  "repository": {
@@ -19,11 +19,27 @@
19
19
  "directory": "extensions/sdk"
20
20
  },
21
21
  "type": "module",
22
+ "//exports": [
23
+ "`./package.json` is exported because an `exports` map without it makes the",
24
+ "file unreadable through package resolution: Node refuses an undeclared",
25
+ "subpath, so a tool asking this package its own version is told the package",
26
+ "does not exist. Common enough that npm's own docs call it out."
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
+ ],
22
36
  "exports": {
23
37
  ".": {
38
+ "ham2k-source": "./src/index.ts",
24
39
  "types": "./dist/index.d.ts",
25
40
  "default": "./dist/index.js"
26
- }
41
+ },
42
+ "./package.json": "./package.json"
27
43
  },
28
44
  "types": "./dist/index.d.ts",
29
45
  "main": "./dist/index.js",
@@ -39,6 +55,9 @@
39
55
  ],
40
56
  "files": [
41
57
  "dist",
58
+ "docs",
59
+ "samples",
60
+ "AGENTS.md",
42
61
  "README.md"
43
62
  ],
44
63
  "engines": {
@@ -0,0 +1,33 @@
1
+ # Samples
2
+
3
+ One extension per shape, each one real: it builds, it packs, and it does what
4
+ it says against the live service. Read the one whose shape matches what you are
5
+ building — the header comment in each `src/index.ts` says what it leaves out.
6
+
7
+ | | |
8
+ | --- | --- |
9
+ | `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
10
+ | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
11
+ | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
12
+ | `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
13
+
14
+ ## Running one
15
+
16
+ ```sh
17
+ mkdir my-extension && cd my-extension
18
+ npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>
19
+ npm install
20
+ ```
21
+
22
+ Then copy a sample's `manifest.json` and `src/` over the scaffolded ones,
23
+ change the `key` back to yours, and:
24
+
25
+ ```sh
26
+ npm run build && npm run pack
27
+ ```
28
+
29
+ The key must stay `<yourcallsign>-<name>` — it namespaces your extension's
30
+ storage, and the packer refuses anything else. The `k2hrc-` keys here belong
31
+ to the samples, so leaving one in place would collide with them.
32
+
33
+ See `../AGENTS.md` for the map, and `../docs/` for the references.
@@ -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-cqww",
3
+ "name": "CQ WW DX (sample)",
4
+ "shortName": "CQWW*",
5
+ "version": "1.0.0",
6
+ "description": "CQ WW DX points and multipliers, as a worked example",
7
+ "category": "contest",
8
+ "icon": "earth",
9
+ "accentColor": "#B03A2E",
10
+ "api": 1,
11
+ "keywords": ["contest", "cqww", "dx", "sample"],
12
+ "hooks": ["activity", "ref:k2hrcCqww", "scoring"],
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,273 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — a contest: an exchange to type, and a score to keep.
5
+ //
6
+ // A contest extension is three things. The `activity` hook contributes the
7
+ // setup form and the per-QSO exchange field; the `ref:` handler says what the
8
+ // operation's contest reference means; the `scoring` hook keeps the score.
9
+ //
10
+ // The scorer is the part worth studying. It is a FOLD: the host hands you a
11
+ // scoresheet and one QSO, you return the scoresheet with that QSO in it. The
12
+ // host owns when it runs — a fresh log, a re-score after an edit, a running
13
+ // total while operating — so a scorer that reaches for anything outside its
14
+ // arguments gives different answers on the same log.
15
+ //
16
+ // The rules here are CQ WW's, and real: points by continent, multipliers per
17
+ // band, one contact per station per band. What this sample leaves out is
18
+ // everything around them — Cabrillo export, the ADIF exchange fields, the
19
+ // country-file zone suggestion, and writing a guessed exchange onto the QSO.
20
+ // See `docs/hooks.md` §`scoring`, and `contests/cqww` in the app's repository
21
+ // for the whole thing.
22
+
23
+ import { annotateCallAgainstCountryFile, contestScorer, defineExtension } from "@ham2k/extension-sdk"
24
+ import type {
25
+ ContestScorer,
26
+ HookContext,
27
+ JSONValue,
28
+ LoggingControlDescriptor,
29
+ Ref,
30
+ ScoreTally,
31
+ } from "@ham2k/extension-sdk"
32
+ import { fmtInteger } from "@ham2k/lib-format-tools"
33
+
34
+ import manifest from "../manifest.json" with { type: "json" }
35
+
36
+ const TYPE = "k2hrcCqww"
37
+
38
+ /// CQ WW is an HF contest: the WARC bands are excluded by the rules, not by
39
+ /// oversight.
40
+ const VALID_BANDS = ["160m", "80m", "40m", "20m", "15m", "10m"]
41
+
42
+ /// A CQ zone as typed: 1-40, with or without a leading zero. The core anchors
43
+ /// and case-folds it, so this is only the body.
44
+ const ZONE_PATTERN = "0?(?:[1-9]|[1-3][0-9]|40)"
45
+
46
+ function str(value: JSONValue | undefined): string {
47
+ return typeof value === "string" ? value : ""
48
+ }
49
+
50
+ function refOfType(container: Record<string, JSONValue>, type: string): Record<string, JSONValue> | undefined {
51
+ return ((container.refs as Record<string, JSONValue>[] | undefined) ?? []).find((r) => r?.type === type)
52
+ }
53
+
54
+ /// "05" and "5" are the same multiplier and must not count twice.
55
+ function normalizeZone(value: JSONValue | undefined): string {
56
+ const digits = String(value ?? "").trim().replace(/^0+(?=\d)/, "")
57
+ return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
58
+ }
59
+
60
+ type Scoresheet = {
61
+ /// call → the bands already worked with it. One QSO per station per band.
62
+ workedByCall: Record<string, string[]>
63
+ /// Every `band|Z<zone>` and `band|C<entity>` seen. Multipliers are per band,
64
+ /// so the band is part of the key.
65
+ mults: Record<string, number>
66
+ qsos: number
67
+ points: number
68
+ /// Ours, resolved once: every point below is relative to where we are.
69
+ ourContinent?: string
70
+ ourEntity?: string
71
+ }
72
+
73
+ const CQWWScorer: ContestScorer<Scoresheet> = {
74
+ /// Called once at the start of a fold. Anything derived from the operation
75
+ /// belongs here, not in `scoreQso` — this runs once, that runs per contact.
76
+ startScoresheet({ operation }): Scoresheet {
77
+ const ours = annotateCallAgainstCountryFile(str(operation.stationCall))
78
+ return {
79
+ workedByCall: {},
80
+ mults: {},
81
+ qsos: 0,
82
+ points: 0,
83
+ ourContinent: ours.continent,
84
+ ourEntity: ours.entityPrefix,
85
+ }
86
+ },
87
+
88
+ /// Mutates and returns the scoresheet it was given.
89
+ ///
90
+ /// The verdict is as important as the number: `dupe` is what greys the QSO
91
+ /// out in the log, and `alerts` is what tells the operator WHY something
92
+ /// scored nothing. A scorer that silently returns 0 leaves them wondering.
93
+ scoreQso({ scoresheet, qso }) {
94
+ const their = (qso.their as Record<string, JSONValue>) ?? {}
95
+ const call = str(their.call)
96
+ if (!call) return { scoresheet, score: { value: 0 } }
97
+
98
+ const band = str(qso.band)
99
+ if (!VALID_BANDS.includes(band)) {
100
+ return { scoresheet, score: { value: 0, alerts: ["invalidBand"] } }
101
+ }
102
+
103
+ const worked = scoresheet.workedByCall[call] ?? []
104
+ if (worked.includes(band)) {
105
+ return { scoresheet, score: { value: 0, dupe: true, alerts: ["duplicate"] } }
106
+ }
107
+
108
+ const theirs = annotateCallAgainstCountryFile(call)
109
+ // The exchange the operator typed wins; the country file's answer for the
110
+ // callsign is the fallback, so a QSO logged without one still scores.
111
+ const zone = normalizeZone(refOfType(qso, TYPE)?.theirZone) || normalizeZone(theirs.cqZone)
112
+
113
+ // Points: nothing within our own DXCC entity, 1 inside our continent, 2
114
+ // for North America to North America, 3 across continents.
115
+ let points = 3
116
+ if (theirs.entityPrefix && theirs.entityPrefix === scoresheet.ourEntity) points = 0
117
+ else if (theirs.continent && theirs.continent === scoresheet.ourContinent) {
118
+ points = theirs.continent === "NA" ? 2 : 1
119
+ }
120
+
121
+ scoresheet.workedByCall[call] = [...worked, band]
122
+ scoresheet.qsos += 1
123
+ scoresheet.points += points
124
+
125
+ // Two multipliers per contact, each counted once per band: the zone, and
126
+ // the country. Recorded even when the contact is worth no points — working
127
+ // your own country still gives you the multiplier.
128
+ if (zone) scoresheet.mults[`${band}|Z${zone}`] = 1
129
+ if (theirs.entityPrefix) scoresheet.mults[`${band}|C${theirs.entityPrefix}`] = 1
130
+
131
+ return { scoresheet, score: { value: points } }
132
+ },
133
+
134
+ /// The score as the operator reads it. `scope` is 'day' or the whole
135
+ /// contest; this sample reports the same totals for both, where the real
136
+ /// extension tracks a separate per-day tally.
137
+ summarizeScore({ scoresheet, scope }): Record<string, ScoreTally> {
138
+ const mults = Object.keys(scoresheet.mults).length
139
+ return {
140
+ [TYPE]: {
141
+ key: TYPE,
142
+ for: scope,
143
+ icon: manifest.icon,
144
+ total: scoresheet.points * mults,
145
+ points: scoresheet.points,
146
+ mults,
147
+ qsos: scoresheet.qsos,
148
+ label: `${fmtInteger(scoresheet.points)} × ${fmtInteger(mults)}`,
149
+ summary: `${fmtInteger(scoresheet.points * mults)}`,
150
+ },
151
+ }
152
+ },
153
+ }
154
+
155
+ const ActivityHook = {
156
+ /// What the operator fills in when adding this contest to an operation.
157
+ async operationControls(
158
+ _args: { operation: Record<string, JSONValue> },
159
+ _ctx: HookContext,
160
+ ): Promise<LoggingControlDescriptor[]> {
161
+ return [
162
+ {
163
+ key: "k2hrc-cqww/setup",
164
+ label: "CQ WW DX (sample)",
165
+ icon: manifest.icon,
166
+ color: manifest.accentColor,
167
+ order: 10,
168
+ input: {
169
+ kind: "form",
170
+ // Naming the ref type is what makes the form's values land on a ref
171
+ // of that type on the operation — which is then what the scorer's
172
+ // `scope` matches, and what the handler below decorates.
173
+ refType: TYPE,
174
+ form: {
175
+ title: "CQ WW DX",
176
+ elements: [
177
+ {
178
+ type: "field",
179
+ fieldType: "radio",
180
+ key: "mode",
181
+ label: "Mode",
182
+ // Separate contests on separate weekends, not a filter.
183
+ options: [
184
+ { value: "CW", label: "CW" },
185
+ { value: "SSB", label: "SSB" },
186
+ { value: "RTTY", label: "RTTY" },
187
+ ],
188
+ },
189
+ { type: "field", fieldType: "text", key: "zone", label: "Our CQ zone", placeholder: "8" },
190
+ ],
191
+ },
192
+ },
193
+ },
194
+ ]
195
+ },
196
+
197
+ /// The one field typed per QSO.
198
+ ///
199
+ /// Contributed only while this operation is actually running the contest.
200
+ /// The core would refuse it anyway, so this guard is an optimization, not
201
+ /// the rule — but it runs on the typing path, once per enabled contest
202
+ /// extension, which is why it is worth having.
203
+ async loggingControls(
204
+ args: { operation: Record<string, JSONValue>; qso?: Record<string, JSONValue> },
205
+ _ctx: HookContext,
206
+ ): Promise<LoggingControlDescriptor[]> {
207
+ if (!refOfType(args.operation, TYPE)) return []
208
+
209
+ const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
210
+ const guess = (their.guess as Record<string, JSONValue>) ?? {}
211
+ const call = str(their.call)
212
+ const suggested =
213
+ normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
214
+
215
+ return [
216
+ {
217
+ key: "k2hrc-cqww/zone",
218
+ label: "Zone",
219
+ icon: manifest.icon,
220
+ color: manifest.accentColor,
221
+ order: 10,
222
+ input: {
223
+ kind: "text",
224
+ refType: TYPE,
225
+ field: "theirZone",
226
+ numeric: true,
227
+ maxLength: 3,
228
+ pattern: ZONE_PATTERN,
229
+ // The guessed zone for THIS callsign, never a format hint like
230
+ // "1-40" — a hint reads as a real value at a glance.
231
+ placeholder: suggested || undefined,
232
+ // Filled in for the operator, but never over what they typed.
233
+ suggestedValue: suggested || undefined,
234
+ },
235
+ },
236
+ ]
237
+ },
238
+ }
239
+
240
+ const RefHandler = {
241
+ async validateRef({ ref }: { ref: Ref }, _ctx: HookContext) {
242
+ return { valid: true, normalized: (ref.ref ?? "").trim() }
243
+ },
244
+
245
+ /// A contest reference names an event, not a place, so what it decorates to
246
+ /// is the contest and the exchange we are sending.
247
+ async decorateRef({ ref }: { ref: Ref }, _ctx: HookContext): Promise<Ref> {
248
+ const mode = str((ref as Record<string, JSONValue>).mode)
249
+ const zone = normalizeZone((ref as Record<string, JSONValue>).zone)
250
+ const label = mode ? `CQ WW ${mode}` : "CQ WW DX"
251
+ return {
252
+ ...ref,
253
+ program: "Contest",
254
+ label,
255
+ shortLabel: label,
256
+ name: zone ? `Zone ${zone}` : "Not configured",
257
+ }
258
+ },
259
+ }
260
+
261
+ defineExtension({
262
+ ...manifest,
263
+ onActivation({ registerHook }) {
264
+ registerHook("activity", { hook: ActivityHook, key: manifest.key })
265
+ registerHook(`ref:${TYPE}`, { hook: RefHandler, key: manifest.key })
266
+ // `scope` is what keeps this scorer out of every operation that is not
267
+ // running this contest.
268
+ registerHook("scoring", {
269
+ hook: contestScorer(CQWWScorer, { scope: { refTypes: [TYPE] } }),
270
+ key: manifest.key,
271
+ })
272
+ },
273
+ })
@@ -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,24 @@
1
+ {
2
+ "key": "k2hrc-hamqth",
3
+ "name": "HamQTH Lookups",
4
+ "shortName": "HamQTH",
5
+ "version": "1.0.0",
6
+ "description": "Looks up callsign details with a free HamQTH.com account",
7
+ "category": "lookup",
8
+ "icon": "card-account-details-outline",
9
+ "accentColor": "#1E6F5C",
10
+ "api": 1,
11
+ "keywords": ["callsign", "lookup", "hamqth", "sample"],
12
+ "hooks": ["account", "lookup"],
13
+ "domains": ["www.hamqth.com"],
14
+ "sharedDependencies": {
15
+ "@ham2k/lib-callsigns": "^1.0.0",
16
+ "@ham2k/lib-country-files": "^1.0.0",
17
+ "@ham2k/lib-dxcc-data": "^1.0.0",
18
+ "@ham2k/lib-format-tools": "^1.0.0",
19
+ "@ham2k/lib-geo-tools": "^1.0.0",
20
+ "@ham2k/lib-operation-data": "^1.0.0",
21
+ "i18next": "^23.0.0",
22
+ "liquidjs": "^10.0.0"
23
+ }
24
+ }