@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,202 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// SAMPLE — a callsign lookup source, with the credentials one needs.
|
|
5
|
+
//
|
|
6
|
+
// HamQTH's XML API is free and its shape is the common one: log in once with a
|
|
7
|
+
// username and password, get a session id back, and spend that id on lookups
|
|
8
|
+
// until it expires. So this sample is really two things an extension author
|
|
9
|
+
// needs together — the `lookup` hook that answers about a callsign, and the
|
|
10
|
+
// `account` hook that is the ONLY way to ask an operator for a secret.
|
|
11
|
+
//
|
|
12
|
+
// Never put a password in a settings field of your own: `account` stores
|
|
13
|
+
// credentials in the platform keychain, keeps the session out of your hands,
|
|
14
|
+
// and gives the operator one place to see what they have connected. A `secret`
|
|
15
|
+
// field in a settings panel is for a bare API key with no login behind it.
|
|
16
|
+
//
|
|
17
|
+
// What this sample leaves out: the country-file cross-checks, the
|
|
18
|
+
// already-know-enough short circuits, and the per-call caching a production
|
|
19
|
+
// source wants. Read `docs/hooks.md` §`lookup` before shipping one.
|
|
20
|
+
|
|
21
|
+
import { defineExtension, host } from "@ham2k/extension-sdk"
|
|
22
|
+
import type { AnnotatedCallInfo, CallInfoLookup, HookContext, JSONValue, LookupResult } from "@ham2k/extension-sdk"
|
|
23
|
+
import { cleanLocationParams } from "@ham2k/lib-geo-tools"
|
|
24
|
+
|
|
25
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
26
|
+
|
|
27
|
+
const API = "https://www.hamqth.com/xml.php"
|
|
28
|
+
|
|
29
|
+
/// HamQTH asks every caller to identify itself, and rejects an empty `prg`.
|
|
30
|
+
const PROGRAM = "ham2k-sample"
|
|
31
|
+
|
|
32
|
+
/// HamQTH's XML is flat and carries no attributes worth reading, so a tag
|
|
33
|
+
/// extractor beats a parser the sandbox would have to ship.
|
|
34
|
+
function tag(xml: string, name: string): string | undefined {
|
|
35
|
+
return xml.match(new RegExp(`<${name}[^>]*>([^<]*)</${name}>`, "i"))?.[1]?.trim() || undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Trades the operator's credentials for a session id, and hands it to the
|
|
39
|
+
/// host to hold.
|
|
40
|
+
///
|
|
41
|
+
/// `host.setAccountSession` rather than a module variable or `host.kvSet`: the
|
|
42
|
+
/// session outlives a reload of this extension, and it is a credential — the
|
|
43
|
+
/// host keeps it beside the password rather than in your storage.
|
|
44
|
+
async function login(ctx: HookContext): Promise<string> {
|
|
45
|
+
const username = ctx.account?.credentials?.username
|
|
46
|
+
const password = ctx.account?.credentials?.password
|
|
47
|
+
if (!username || !password) throw new Error("No HamQTH account configured")
|
|
48
|
+
|
|
49
|
+
const response = await host.fetch(
|
|
50
|
+
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
51
|
+
const sessionId = tag(response.body, "session_id")
|
|
52
|
+
if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
|
|
53
|
+
|
|
54
|
+
await host.setAccountSession({ sessionId })
|
|
55
|
+
return sessionId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// One lookup, renewing the session if this one has aged out.
|
|
59
|
+
///
|
|
60
|
+
/// HamQTH sessions expire on their own schedule, so an expired one is a normal
|
|
61
|
+
/// Tuesday rather than an error: the retry is the happy path, not recovery.
|
|
62
|
+
/// Note that it re-logs-in only ONCE — a second failure is a real one, and
|
|
63
|
+
/// looping on it would hammer the API behind the operator's back.
|
|
64
|
+
async function fetchCall(call: string, ctx: HookContext): Promise<string> {
|
|
65
|
+
const search = (id: string) =>
|
|
66
|
+
host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
67
|
+
|
|
68
|
+
const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
|
|
69
|
+
const response = await search(sessionId)
|
|
70
|
+
|
|
71
|
+
if (/session does not exist|expired/i.test(tag(response.body, "error") ?? "")) {
|
|
72
|
+
await host.setAccountSession({})
|
|
73
|
+
return (await search(await login(ctx))).body
|
|
74
|
+
}
|
|
75
|
+
return response.body
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function lookupCall(
|
|
79
|
+
{ callInfo }: { callInfo: AnnotatedCallInfo; qso: Record<string, JSONValue>; operation: Record<string, JSONValue> },
|
|
80
|
+
ctx: HookContext,
|
|
81
|
+
): Promise<LookupResult> {
|
|
82
|
+
const call = (callInfo.call || "").trim().toUpperCase()
|
|
83
|
+
// Offline, or too short to be a callsign: answer with nothing rather than
|
|
84
|
+
// spend a round-trip. `ctx.online` is the host's own answer, and a lookup
|
|
85
|
+
// hook runs on the typing path.
|
|
86
|
+
if (call.length < 3 || !ctx.online) return []
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const xml = await fetchCall(call, ctx)
|
|
90
|
+
|
|
91
|
+
const error = tag(xml, "error")
|
|
92
|
+
if (error) {
|
|
93
|
+
// "Callsign not found" is an ANSWER, not a fault — most operators work
|
|
94
|
+
// stations HamQTH has never heard of, and logging each one would bury
|
|
95
|
+
// the errors that matter (a bad password, a subscription problem).
|
|
96
|
+
if (!/not found/i.test(error)) host.log(`HamQTH said: ${error}`)
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `<nick>` is a SHORTER FORM of the same name rather than a handle the
|
|
101
|
+
// operator also goes by: "Martin" against an `<adr_name>` of "Martin
|
|
102
|
+
// Kratoska", "Sebastian Delmont" against "Sebastian D Delmont", and for
|
|
103
|
+
// W1AW the identical string twice. Appending it reads as information and
|
|
104
|
+
// is not, so the full name wins and the nickname is only a fallback.
|
|
105
|
+
//
|
|
106
|
+
// QRZ's `nickname` deliberately goes the other way — there it IS what the
|
|
107
|
+
// operator goes by on the air, and its extension shows both. Same-looking
|
|
108
|
+
// field, opposite verdict; don't make the two consistent.
|
|
109
|
+
const name = tag(xml, "adr_name") ?? tag(xml, "nick")
|
|
110
|
+
|
|
111
|
+
const city = tag(xml, "adr_city")
|
|
112
|
+
const state = tag(xml, "us_state")
|
|
113
|
+
|
|
114
|
+
// The coordinate AND the grid. HamQTH reports both, and where a pair is
|
|
115
|
+
// present it outranks the locator — a lookup that kept only the grid would
|
|
116
|
+
// throw away the more precise of the two answers it was given. Parsed
|
|
117
|
+
// through `cleanLocationParams` rather than `Number`, because a service
|
|
118
|
+
// that has no fix for a record says so with an empty string, a "0", or a
|
|
119
|
+
// pair of zeroes, and none of those is a place.
|
|
120
|
+
const [lat, lon] = cleanLocationParams(tag(xml, "latitude"), tag(xml, "longitude"))
|
|
121
|
+
|
|
122
|
+
const result: CallInfoLookup = {
|
|
123
|
+
// HamQTH answers in lower case, whatever was asked.
|
|
124
|
+
call: tag(xml, "callsign")?.toUpperCase() ?? call,
|
|
125
|
+
// Named so the app can say where this came from, and so the merge can
|
|
126
|
+
// tell your answer from another source's.
|
|
127
|
+
source: "hamqth.com",
|
|
128
|
+
scope: "general",
|
|
129
|
+
name,
|
|
130
|
+
location: [city, state].filter((x) => x).join(", "),
|
|
131
|
+
city,
|
|
132
|
+
state,
|
|
133
|
+
country: tag(xml, "country"),
|
|
134
|
+
grid: tag(xml, "grid"),
|
|
135
|
+
lat,
|
|
136
|
+
lon,
|
|
137
|
+
}
|
|
138
|
+
return [result]
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// A lookup that throws takes down the call-info panel for every source,
|
|
141
|
+
// not just this one.
|
|
142
|
+
host.log(`HamQTH lookup failed for ${call}: ${(e as Error).message ?? e}`)
|
|
143
|
+
return []
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// What the operator fills in under Settings → Accounts.
|
|
148
|
+
///
|
|
149
|
+
/// `testCredentials` returns a STRING to show them — it is the only feedback
|
|
150
|
+
/// they get that a password is right, so make it name what was found rather
|
|
151
|
+
/// than saying "OK".
|
|
152
|
+
const HamQTHAccount = {
|
|
153
|
+
label: "HamQTH.com",
|
|
154
|
+
description: "A free account at hamqth.com, for callsign lookups",
|
|
155
|
+
kvKey: "k2hrc-hamqth",
|
|
156
|
+
// The same login on every device the operator owns, so it rides iCloud
|
|
157
|
+
// Keychain. A per-install credential (a device key, a sync token) must NOT
|
|
158
|
+
// set this.
|
|
159
|
+
synchronizable: true,
|
|
160
|
+
fields: [
|
|
161
|
+
{ key: "username", label: "Username", type: "text" as const },
|
|
162
|
+
{ key: "password", label: "Password", type: "secret" as const },
|
|
163
|
+
],
|
|
164
|
+
|
|
165
|
+
async testCredentials(credentials: Record<string, string>, _ctx: HookContext): Promise<string> {
|
|
166
|
+
const username = (credentials.username || "").trim()
|
|
167
|
+
if (!username || !credentials.password) return "Enter your HamQTH username and password"
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const response = await host.fetch(
|
|
171
|
+
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
172
|
+
const sessionId = tag(response.body, "session_id")
|
|
173
|
+
if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
|
|
174
|
+
|
|
175
|
+
const check = await host.fetch(
|
|
176
|
+
`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
177
|
+
const name = tag(check.body, "adr_name")
|
|
178
|
+
return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return `Could not reach HamQTH: ${(e as Error).message ?? e}`
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
defineExtension({
|
|
186
|
+
...manifest,
|
|
187
|
+
onActivation({ registerHook }) {
|
|
188
|
+
// No `priority`, which means 0 — and 0 is where a third-party callbook
|
|
189
|
+
// belongs. The ladder it lands in: the operator's own call notes at 100,
|
|
190
|
+
// QRZ at 99, the Ham2K lookup service at 10, and Call History last at -1,
|
|
191
|
+
// deliberately below the 0 an undeclared hook defaults to, because the log
|
|
192
|
+
// describes a station whenever it was last worked while a live source
|
|
193
|
+
// describes it today.
|
|
194
|
+
//
|
|
195
|
+
// So the default already reads "a real source, but not one that outranks
|
|
196
|
+
// what the operator typed or what the core provides". Set a priority only
|
|
197
|
+
// when you mean to move against that, and know which source you are
|
|
198
|
+
// stepping over.
|
|
199
|
+
registerHook("lookup", { hook: { lookupCall }, key: manifest.key })
|
|
200
|
+
registerHook("account", { hook: HamQTHAccount, key: manifest.key })
|
|
201
|
+
},
|
|
202
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "k2hrc-llota",
|
|
3
|
+
"name": "Lakes on the Air (sample)",
|
|
4
|
+
"shortName": "LLOTA*",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Lake references and activation scoring, as a worked example",
|
|
7
|
+
"category": "activity",
|
|
8
|
+
"icon": "waves",
|
|
9
|
+
"accentColor": "#2F7FA6",
|
|
10
|
+
"api": 1,
|
|
11
|
+
"keywords": ["lakes", "activation", "hunting", "sample"],
|
|
12
|
+
"hooks": ["activity", "adifFields", "adifImport", "dataFile", "ref:k2hrcLlota", "ref:k2hrcLlotaActivation", "scoring"],
|
|
13
|
+
"domains": ["llota.app"],
|
|
14
|
+
"relevance": { "continents": ["SA"] },
|
|
15
|
+
"sharedDependencies": {
|
|
16
|
+
"@ham2k/lib-callsigns": "^1.0.0",
|
|
17
|
+
"@ham2k/lib-country-files": "^1.0.0",
|
|
18
|
+
"@ham2k/lib-dxcc-data": "^1.0.0",
|
|
19
|
+
"@ham2k/lib-format-tools": "^1.0.0",
|
|
20
|
+
"@ham2k/lib-geo-tools": "^1.0.0",
|
|
21
|
+
"@ham2k/lib-operation-data": "^1.0.0",
|
|
22
|
+
"i18next": "^23.0.0",
|
|
23
|
+
"liquidjs": "^10.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// SAMPLE — an award program: references, an offline list, and activation
|
|
5
|
+
// scoring.
|
|
6
|
+
//
|
|
7
|
+
// This is the shape of POTA, SOTA, WWFF and a dozen others, and almost none of
|
|
8
|
+
// it is written by hand. `referenceActivity()` builds the reference handler,
|
|
9
|
+
// the logging controls and the ADIF fields from one description; the manifest
|
|
10
|
+
// says which `ref:` types they answer for. What is left for you is what the
|
|
11
|
+
// program actually is: its reference format, where the list comes from, and
|
|
12
|
+
// what it takes to activate.
|
|
13
|
+
//
|
|
14
|
+
// Modelled on LLOTA (Lakes and Lagoons on the Air) and using its public list.
|
|
15
|
+
// The reference TYPES are namespaced to this sample (`k2hrcLlota`), so
|
|
16
|
+
// installing it cannot collide with the real LLOTA extension's records.
|
|
17
|
+
//
|
|
18
|
+
// What this sample leaves out: the spot feed and spot posting, which need an
|
|
19
|
+
// API key the program issues, and the export hooks that submit a log. See
|
|
20
|
+
// `docs/hooks.md` §`spots` and §`export`.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
activityScorer,
|
|
24
|
+
contestScorer,
|
|
25
|
+
defineExtension,
|
|
26
|
+
referenceActivity,
|
|
27
|
+
} from "@ham2k/extension-sdk"
|
|
28
|
+
import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
|
|
29
|
+
import { locationToGrid6 } from "@ham2k/lib-geo-tools"
|
|
30
|
+
|
|
31
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
32
|
+
|
|
33
|
+
/// The type a QSO carries for a lake the OTHER station was at, and the type an
|
|
34
|
+
/// operation carries while activating one. Two types, because they are two
|
|
35
|
+
/// different claims about the same reference.
|
|
36
|
+
const HUNTING_TYPE = "k2hrcLlota"
|
|
37
|
+
const ACTIVATION_TYPE = "k2hrcLlotaActivation"
|
|
38
|
+
|
|
39
|
+
const REFERENCE_REGEX = /^LL[A-Z0-9]+-(?:[0-9]{4,5}|TEST)$/i
|
|
40
|
+
|
|
41
|
+
/// Every user-visible string reaches the SDK through a translator, so that one
|
|
42
|
+
/// written against several locales needs no different wiring. A real extension
|
|
43
|
+
/// builds this with `createCachedTranslator` over its own JSON files — see
|
|
44
|
+
/// `activities/*/src/i18n.ts` in the app's repository. This is the smallest
|
|
45
|
+
/// thing with the right shape.
|
|
46
|
+
///
|
|
47
|
+
/// `invalidReference` and `unknownReference` are the vocabulary
|
|
48
|
+
/// `referenceActivity` looks up; `activationControl` and `huntingControl` name
|
|
49
|
+
/// the two logging controls it builds.
|
|
50
|
+
const STRINGS: Record<string, string> = {
|
|
51
|
+
invalidReference: "Not a lake reference",
|
|
52
|
+
unknownReference: "Unknown lake",
|
|
53
|
+
activationControl: "Lake",
|
|
54
|
+
huntingControl: "Lake",
|
|
55
|
+
}
|
|
56
|
+
const tFor = (_ctx: { locale?: string }) => (key: string) => STRINGS[key] ?? key
|
|
57
|
+
|
|
58
|
+
/// What it takes to activate: ten contacts in a UTC day.
|
|
59
|
+
///
|
|
60
|
+
/// `uniquePer` is the rule that decides when a repeat contact counts AGAIN —
|
|
61
|
+
/// here, a station already worked is worth another contact on a new band, a
|
|
62
|
+
/// new mode, a new day, or at a different lake. Get this wrong and the score
|
|
63
|
+
/// is wrong in a way no test of yours will notice, because it only shows up on
|
|
64
|
+
/// the second QSO with the same station.
|
|
65
|
+
const SCORING = {
|
|
66
|
+
label: "LLOTA",
|
|
67
|
+
icon: manifest.icon,
|
|
68
|
+
activationType: ACTIVATION_TYPE,
|
|
69
|
+
huntingType: HUNTING_TYPE,
|
|
70
|
+
qsosToActivate: 10,
|
|
71
|
+
// Several lakes activated at once, and one contact crediting several hunted
|
|
72
|
+
// ones. Programs genuinely differ here; most of the simpler awards do not
|
|
73
|
+
// allow it, and the default is false.
|
|
74
|
+
allowsMultipleReferences: true,
|
|
75
|
+
uniquePer: ["band", "mode", "day", "ref"] as const,
|
|
76
|
+
activates: "daily" as const,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { refHandler, activityHook, adifFieldsHook, adifImportHook } = referenceActivity({
|
|
80
|
+
key: "k2hrc-llota",
|
|
81
|
+
label: "LLOTA",
|
|
82
|
+
activationType: ACTIVATION_TYPE,
|
|
83
|
+
huntingType: HUNTING_TYPE,
|
|
84
|
+
referenceRegex: REFERENCE_REGEX,
|
|
85
|
+
icon: manifest.icon,
|
|
86
|
+
color: manifest.accentColor,
|
|
87
|
+
placeholder: "LLUS-0001",
|
|
88
|
+
tFor,
|
|
89
|
+
// Where the `lookups` rows below are written and read. It must match the
|
|
90
|
+
// data file's `category`, or the handler will look up references nothing
|
|
91
|
+
// ever stored.
|
|
92
|
+
category: "k2hrc-llota",
|
|
93
|
+
linkUrl: (reference: string) => `https://llota.app/list/ref/${encodeURIComponent(reference)}`,
|
|
94
|
+
// One ADIF record per hunted lake rather than one naming them all. The
|
|
95
|
+
// programs disagree, and it decides how many contacts the award sees.
|
|
96
|
+
splitRecordsPerHuntedRef: true,
|
|
97
|
+
adifRefField: "LLOTA",
|
|
98
|
+
// Read off the scoring rule rather than restated, so the control and the
|
|
99
|
+
// scorer cannot disagree about whether this award allows n-fers.
|
|
100
|
+
allowsMultiple: SCORING.allowsMultipleReferences,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/// The reference list, downloaded and kept offline.
|
|
104
|
+
///
|
|
105
|
+
/// This is what makes a reference resolve to a lake's name in the field, with
|
|
106
|
+
/// no signal and no per-reference API. The host does the fetching, the
|
|
107
|
+
/// scheduling and the storage; the extension's whole job is turning one
|
|
108
|
+
/// entry of somebody else's JSON into one row.
|
|
109
|
+
const referenceList: DataFileDefinition = {
|
|
110
|
+
key: "k2hrc-llota-references",
|
|
111
|
+
name: (_args: Record<string, never>, _ctx: HookContext) => "LLOTA Lakes",
|
|
112
|
+
description: (_args: Record<string, never>, _ctx: HookContext) => "Every lake reference, for offline lookup",
|
|
113
|
+
url: "https://llota.app/api/public/references?version=lite",
|
|
114
|
+
maxAgeInDays: 30,
|
|
115
|
+
fetchType: "json",
|
|
116
|
+
category: "k2hrc-llota",
|
|
117
|
+
|
|
118
|
+
jsonToLookupEntry: (entry: Record<string, any>) => {
|
|
119
|
+
const ref = String(entry?.reference_code ?? "").trim().toUpperCase()
|
|
120
|
+
// Returning null drops the entry. A list of this size will contain a few
|
|
121
|
+
// rows that are not references, and throwing would abandon the rest.
|
|
122
|
+
if (!ref) return null
|
|
123
|
+
|
|
124
|
+
const lat = typeof entry?.latitude === "number" ? entry.latitude : undefined
|
|
125
|
+
const lon = typeof entry?.longitude === "number" ? entry.longitude : undefined
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
// The country part of the code (`LLUS` of `LLUS-0001`), which is how an
|
|
129
|
+
// operator narrows a search to their own country.
|
|
130
|
+
subCategory: ref.split("-")[0],
|
|
131
|
+
key: ref,
|
|
132
|
+
name: entry?.name,
|
|
133
|
+
lat,
|
|
134
|
+
lon,
|
|
135
|
+
flags: 1,
|
|
136
|
+
data: {
|
|
137
|
+
ref,
|
|
138
|
+
name: entry?.name,
|
|
139
|
+
location: ref.split("-")[0],
|
|
140
|
+
// The grid is DERIVED from the coordinate, never stored instead of it:
|
|
141
|
+
// where both are present the pair outranks the grid, and a grid with
|
|
142
|
+
// no coordinate behind it would quietly lose precision the list had.
|
|
143
|
+
grid: lat != null && lon != null ? locationToGrid6(lat, lon) : undefined,
|
|
144
|
+
lat,
|
|
145
|
+
lon,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
defineExtension({
|
|
152
|
+
...manifest,
|
|
153
|
+
onActivation({ registerHook }) {
|
|
154
|
+
// One handler, both types: an activation and a hunted reference are the
|
|
155
|
+
// same lake, validated and decorated the same way.
|
|
156
|
+
registerHook(`ref:${HUNTING_TYPE}`, { hook: refHandler, key: manifest.key })
|
|
157
|
+
registerHook(`ref:${ACTIVATION_TYPE}`, { hook: refHandler, key: manifest.key })
|
|
158
|
+
registerHook("activity", { hook: activityHook, key: manifest.key })
|
|
159
|
+
registerHook("adifFields", { hook: adifFieldsHook, key: manifest.key })
|
|
160
|
+
registerHook("adifImport", { hook: adifImportHook, key: manifest.key })
|
|
161
|
+
registerHook("dataFile", { hook: referenceList, key: "k2hrc-llota-references" })
|
|
162
|
+
// `contestScorer` wraps the activity rules in the scoring hook's own
|
|
163
|
+
// interface, and `scope` is what keeps this scorer out of operations that
|
|
164
|
+
// have nothing to do with the program.
|
|
165
|
+
registerHook("scoring", {
|
|
166
|
+
hook: contestScorer(activityScorer(SCORING), {
|
|
167
|
+
scope: { refTypes: [ACTIVATION_TYPE, HUNTING_TYPE], huntingRefTypes: [HUNTING_TYPE] },
|
|
168
|
+
}),
|
|
169
|
+
key: manifest.key,
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "k2hrc-radio",
|
|
3
|
+
"name": "Radio Readout",
|
|
4
|
+
"shortName": "Radio",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "A big band, mode and frequency readout across the room",
|
|
7
|
+
"category": "dashboard",
|
|
8
|
+
"icon": "radio-tower",
|
|
9
|
+
"accentColor": "#4C6EF5",
|
|
10
|
+
"api": 1,
|
|
11
|
+
"keywords": ["radio", "frequency", "band", "panel", "sample"],
|
|
12
|
+
"hooks": ["panel"],
|
|
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,145 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// SAMPLE — an HTML panel: what the radio is doing, big enough to read from
|
|
5
|
+
// across the room.
|
|
6
|
+
//
|
|
7
|
+
// A panel returns a DOCUMENT, not widgets, and `html` is the escape hatch for
|
|
8
|
+
// when `markdown` cannot express the layout. It buys you exactly one thing —
|
|
9
|
+
// arbitrary CSS — and costs several:
|
|
10
|
+
//
|
|
11
|
+
// * No JavaScript. None. The document is static from the moment it renders,
|
|
12
|
+
// so anything that changes has to come from another render.
|
|
13
|
+
// * No network for subresources. Fonts, images and stylesheets must be
|
|
14
|
+
// inlined, as text or as `data:` URIs.
|
|
15
|
+
// * No navigation. A link the operator taps opens in their browser; one the
|
|
16
|
+
// document triggers itself is blocked.
|
|
17
|
+
// * It does not render at all where there is no web view — Linux, Windows
|
|
18
|
+
// without WebView2, and the web build. The pane says so, which is a worse
|
|
19
|
+
// experience than markdown that simply works.
|
|
20
|
+
//
|
|
21
|
+
// So reach for `markdown` first: it renders with the app's own typography,
|
|
22
|
+
// theme, font scale and density, and costs no web view. This panel earns the
|
|
23
|
+
// escape hatch because a readout is about type size and nothing else.
|
|
24
|
+
//
|
|
25
|
+
// See `docs/hooks.md` §`panel` for the whole contract, including `form`,
|
|
26
|
+
// `multiple` and every trigger name.
|
|
27
|
+
|
|
28
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
29
|
+
import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
|
|
30
|
+
|
|
31
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
32
|
+
|
|
33
|
+
/// A still image for the panel picker. It carries its own pixels — never a URL,
|
|
34
|
+
/// because the list renders offline and a remote preview would make opening it
|
|
35
|
+
/// a network fetch (and a beacon).
|
|
36
|
+
const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
37
|
+
<rect x="2" y="5" width="20" height="14" rx="2" fill="#4C6EF5"/>
|
|
38
|
+
<rect x="4" y="8" width="16" height="5" rx="1" fill="#1B2559"/>
|
|
39
|
+
<rect x="5.5" y="9.5" width="9" height="2" rx="1" fill="#8FA4FF"/>
|
|
40
|
+
<circle cx="7" cy="16" r="1.2" fill="#1B2559"/>
|
|
41
|
+
<circle cx="11" cy="16" r="1.2" fill="#1B2559"/>
|
|
42
|
+
</svg>`
|
|
43
|
+
|
|
44
|
+
/// Everything that reaches the document goes through here.
|
|
45
|
+
///
|
|
46
|
+
/// The values below are the operator's own — a callsign they typed, a mode
|
|
47
|
+
/// their radio reported — and none of it is trusted markup. A panel that
|
|
48
|
+
/// interpolates raw strings into HTML is one odd character away from a
|
|
49
|
+
/// document that does not parse, and the sandbox is a mitigation, not a reason
|
|
50
|
+
/// to skip this.
|
|
51
|
+
function escapeHtml(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, "&")
|
|
54
|
+
.replace(/</g, "<")
|
|
55
|
+
.replace(/>/g, ">")
|
|
56
|
+
.replace(/"/g, """)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
|
|
60
|
+
/// what makes a frequency readable at a glance rather than counted digit by
|
|
61
|
+
/// digit.
|
|
62
|
+
function formatFrequency(hz: number): string {
|
|
63
|
+
const khz = hz / 1000
|
|
64
|
+
const whole = Math.floor(khz)
|
|
65
|
+
const fraction = Math.round((khz - whole) * 10)
|
|
66
|
+
return `${whole.toLocaleString("en-US").replace(/,/g, ".")}.${fraction}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const RadioPanel: PanelHook = {
|
|
70
|
+
async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
key: "readout",
|
|
74
|
+
title: "Radio Readout",
|
|
75
|
+
description: "Band, mode and frequency, in large type",
|
|
76
|
+
icon: manifest.icon,
|
|
77
|
+
preview: PREVIEW,
|
|
78
|
+
// `on` is a refresh BUDGET, not a subscription: every trigger named
|
|
79
|
+
// here is paid by every placement of this panel, every time it fires.
|
|
80
|
+
//
|
|
81
|
+
// 'qso' is the expensive one — it is the whole QSO being composed, and
|
|
82
|
+
// it changes as fast as the operator types (the host debounces it to
|
|
83
|
+
// one wake per pause). It is the right trigger here anyway, because
|
|
84
|
+
// the band and mode on that draft ARE the radio's, and they are what
|
|
85
|
+
// this panel exists to show. A panel wanting only a resolved callsign
|
|
86
|
+
// should declare 'lookup' instead and get far fewer renders.
|
|
87
|
+
on: ["qso", "operation"],
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async render(args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
|
|
93
|
+
const qso = (args.qso ?? {}) as Record<string, any>
|
|
94
|
+
|
|
95
|
+
// `args.qso` is absent unless some panel in the arrangement asked for it —
|
|
96
|
+
// which this one does. It is still absent when there is no logging panel
|
|
97
|
+
// in the view at all, so the empty state is a real state, not a bug.
|
|
98
|
+
const band = typeof qso.band === "string" ? qso.band : ""
|
|
99
|
+
const mode = typeof qso.mode === "string" ? qso.mode : ""
|
|
100
|
+
const freq = typeof qso.freq === "number" ? qso.freq : undefined
|
|
101
|
+
|
|
102
|
+
const primary = freq ? formatFrequency(freq) : band || "—"
|
|
103
|
+
const unit = freq ? "kHz" : ""
|
|
104
|
+
|
|
105
|
+
// One inline stylesheet, no external anything, and `prefers-color-scheme`
|
|
106
|
+
// rather than a fixed palette: the panel sits inside the app's own theme,
|
|
107
|
+
// and a readout that stays white at night is a readout nobody uses at
|
|
108
|
+
// night. `vw` units so the type grows with the pane rather than the pane
|
|
109
|
+
// scrolling.
|
|
110
|
+
const content = `<style>
|
|
111
|
+
:root { color-scheme: light dark; }
|
|
112
|
+
body {
|
|
113
|
+
margin: 0; height: 100vh;
|
|
114
|
+
display: flex; flex-direction: column;
|
|
115
|
+
align-items: center; justify-content: center;
|
|
116
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
117
|
+
background: #fff; color: #1B2559;
|
|
118
|
+
}
|
|
119
|
+
.frequency { font-size: min(18vw, 22vh); font-weight: 600; line-height: 1; letter-spacing: -0.02em; }
|
|
120
|
+
.unit { font-size: min(4vw, 5vh); opacity: 0.5; margin-left: 0.3em; }
|
|
121
|
+
.details { margin-top: 0.6em; font-size: min(6vw, 7vh); letter-spacing: 0.08em; opacity: 0.75; }
|
|
122
|
+
.details span + span::before { content: "·"; margin: 0 0.5em; opacity: 0.4; }
|
|
123
|
+
.idle { opacity: 0.4; font-size: min(5vw, 6vh); }
|
|
124
|
+
@media (prefers-color-scheme: dark) {
|
|
125
|
+
body { background: #10121A; color: #E8ECFF; }
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
<div class="frequency">${escapeHtml(primary)}<span class="unit">${unit}</span></div>
|
|
129
|
+
<div class="details">
|
|
130
|
+
${band ? `<span>${escapeHtml(band)}</span>` : ""}
|
|
131
|
+
${mode ? `<span>${escapeHtml(mode)}</span>` : ""}
|
|
132
|
+
<span>${args.qsoCount} Q</span>
|
|
133
|
+
</div>
|
|
134
|
+
${freq || band ? "" : '<div class="idle">No radio connected</div>'}`
|
|
135
|
+
|
|
136
|
+
return { kind: "html", content }
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
defineExtension({
|
|
141
|
+
...manifest,
|
|
142
|
+
onActivation({ registerHook }) {
|
|
143
|
+
registerHook("panel", { key: manifest.key, hook: RadioPanel })
|
|
144
|
+
},
|
|
145
|
+
})
|