@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.
- package/AGENTS.md +139 -0
- package/LICENSE +21 -0
- package/README.md +19 -8
- 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 +445 -0
- package/docs/forms.md +279 -0
- package/docs/hooks.md +1379 -0
- package/docs/settings.md +524 -0
- package/docs/templates.md +206 -0
- package/package.json +22 -3
- package/samples/README.md +33 -0
- package/samples/k2hrc-cqww/build.mjs +6 -0
- package/samples/k2hrc-cqww/manifest.json +23 -0
- package/samples/k2hrc-cqww/src/index.ts +273 -0
- package/samples/k2hrc-hamqth/build.mjs +6 -0
- package/samples/k2hrc-hamqth/manifest.json +24 -0
- package/samples/k2hrc-hamqth/src/index.ts +202 -0
- package/samples/k2hrc-llota/build.mjs +6 -0
- package/samples/k2hrc-llota/manifest.json +25 -0
- package/samples/k2hrc-llota/src/index.ts +172 -0
- package/samples/k2hrc-radio/build.mjs +6 -0
- package/samples/k2hrc-radio/manifest.json +23 -0
- package/samples/k2hrc-radio/src/index.ts +145 -0
|
@@ -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.
|
|
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": {
|
|
@@ -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,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,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
|
+
}
|