@ham2k/extension-sdk 0.5.6 → 0.5.7

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 CHANGED
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
34
34
  ## The smallest whole extension
35
35
 
36
36
  ```ts
37
- import { defineExtension } from '@ham2k/extension-sdk'
38
- import type { PanelHook } from '@ham2k/extension-sdk'
37
+ import { defineExtension } from "@ham2k/extension-sdk"
38
+ import type { PanelHook } from "@ham2k/extension-sdk"
39
39
 
40
- import manifest from '../manifest.json'
40
+ import manifest from "../manifest.json"
41
41
 
42
42
  const HelloPanel: PanelHook = {
43
43
  async getPanels() {
44
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
44
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
45
45
  },
46
46
  async render() {
47
- return { kind: 'markdown', content: '# Hello, World!' }
47
+ return { kind: "markdown", content: "# Hello, World!" }
48
48
  },
49
49
  }
50
50
 
51
51
  defineExtension({
52
52
  ...manifest,
53
53
  onActivation({ registerHook }) {
54
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
54
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
55
55
  },
56
56
  })
57
57
  ```
@@ -100,7 +100,7 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
100
100
  evaluating one script. Reach the outside world through `host`:
101
101
 
102
102
  ```ts
103
- import { host } from '@ham2k/extension-sdk'
103
+ import { host } from "@ham2k/extension-sdk"
104
104
 
105
105
  await host.fetch(url) // https only, and only domains your manifest lists
106
106
  await host.kvGet('key') // storage, namespaced to your extension
@@ -114,7 +114,7 @@ host.log('…') // the app's console
114
114
 
115
115
  ## Traps worth knowing before you write anything
116
116
 
117
- - **A panel returns content, not widgets.** `{ kind: 'markdown', content }` and
117
+ - **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
118
118
  its siblings. There is no way to reach the app's UI toolkit, by design.
119
119
  - **`host.fetch` only reaches domains your manifest's `domains` lists**, over
120
120
  https. An undeclared host is refused at the call, not at build.
@@ -122,7 +122,7 @@ host.log('…') // the app's console
122
122
  carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
123
123
  `sharedDependencies` rewrites your imports into lookups on the app's single
124
124
  copy, which is worth roughly thirty times the bundle size. Don't install them.
125
- `inline: ['liquidjs']` in your build ships your own copy, for when you need a
125
+ `inline: ["liquidjs"]` in your build ships your own copy, for when you need a
126
126
  version the app does not carry. A scaffolded manifest already declares the
127
127
  eight this SDK's own modules reach — importing anything from it pulls them
128
128
  in — so deleting one from the manifest breaks a build that was working.
package/README.md CHANGED
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
9
9
  ```
10
10
 
11
11
  ```ts
12
- import { defineExtension } from '@ham2k/extension-sdk'
13
- import type { PanelHook } from '@ham2k/extension-sdk'
12
+ import { defineExtension } from "@ham2k/extension-sdk"
13
+ import type { PanelHook } from "@ham2k/extension-sdk"
14
14
 
15
- import manifest from '../manifest.json'
15
+ import manifest from "../manifest.json"
16
16
 
17
17
  const HelloPanel: PanelHook = {
18
18
  async getPanels() {
19
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
19
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
20
20
  },
21
21
  async render() {
22
- return { kind: 'markdown', content: '# Hello, World!' }
22
+ return { kind: "markdown", content: "# Hello, World!" }
23
23
  },
24
24
  }
25
25
 
26
26
  defineExtension({
27
27
  ...manifest,
28
28
  onActivation({ registerHook }) {
29
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
29
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
30
30
  },
31
31
  })
32
32
  ```
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
23
23
  const prefix = `${rules.key}-adif`;
24
24
  return {
25
25
  async getExportTypes() {
26
- return [{
27
- ...exportTypeDefinition(rules.activationType, "adif", rules.label),
28
- templateCategory: "reference",
29
- templateSample: rules.templateSample ?? {
30
- log: { ref: "REF-1234", refName: "Example Reference" },
31
- operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
26
+ return [
27
+ {
28
+ ...exportTypeDefinition(rules.activationType, "adif", rules.label),
29
+ templateCategory: "reference",
30
+ templateSample: rules.templateSample ?? {
31
+ log: { ref: "REF-1234", refName: "Example Reference" },
32
+ operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
33
+ }
32
34
  }
33
- }];
35
+ ];
34
36
  },
35
37
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
36
38
  const refs = refsOfType(operation, rules.activationType);
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
113
115
  if (refsOfType(operation, rules.activationType).length > 0) return [];
114
116
  if (huntedQsos(qsos ?? []).length === 0) return [];
115
117
  const t = tFor(ctx);
116
- return [{
117
- exportType,
118
- qsoCount: huntedQsos(qsos ?? []).length,
119
- templateData: { activity: rules.label },
120
- format: "adif",
121
- label: t("adifForHunter", { program: rules.label }),
122
- filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
123
- icon: rules.icon,
124
- color: rules.color,
125
- selectedByDefault: true
126
- // No `refType`: hunted refs live on QSOs, not on operation segments,
127
- // so this isn't the segment-scoped slice `activityExportHook` uses —
128
- // it spans the whole log. (See `HuntingExportRules.color` for why the
129
- // accent has to be threaded explicitly instead.)
130
- }];
118
+ return [
119
+ {
120
+ exportType,
121
+ qsoCount: huntedQsos(qsos ?? []).length,
122
+ templateData: { activity: rules.label },
123
+ format: "adif",
124
+ label: t("adifForHunter", { program: rules.label }),
125
+ filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
126
+ icon: rules.icon,
127
+ color: rules.color,
128
+ selectedByDefault: true
129
+ // No `refType`: hunted refs live on QSOs, not on operation segments,
130
+ // so this isn't the segment-scoped slice `activityExportHook` uses —
131
+ // it spans the whole log. (See `HuntingExportRules.color` for why the
132
+ // accent has to be threaded explicitly instead.)
133
+ }
134
+ ];
131
135
  },
132
136
  async generateExport(args, ctx) {
133
137
  const requested = String(args.exportType ?? "");
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
163
167
  }
164
168
  };
165
169
  }
166
- async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
170
+ async function adifForExport({
171
+ operation,
172
+ qsos,
173
+ segments,
174
+ includePrivateData,
175
+ includeLookupData,
176
+ exportSettings,
177
+ exportData,
178
+ exportTitle,
179
+ mainHandler,
180
+ includeFieldsFrom
181
+ }) {
167
182
  const { hooks } = await import("@ham2k/extension-sdk");
168
183
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
169
184
  operation,
@@ -62,9 +62,7 @@ function activityScorer(rules) {
62
62
  if (candidates.length === 0) notice ?? (notice = "mode");
63
63
  }
64
64
  if (unique.has("ref")) {
65
- candidates = candidates.filter(
66
- (c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
67
- );
65
+ candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
68
66
  if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
69
67
  }
70
68
  if (notice) return notice;
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
189
187
  const detail = refKeys.map(
190
188
  (key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
191
189
  );
192
- const parts = [
193
- qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
194
- ];
190
+ const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
195
191
  if (huntedQsos > 0) {
196
192
  const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
197
- parts.push(
198
- huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
199
- );
193
+ parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
200
194
  }
201
195
  return {
202
196
  key: "activation",
@@ -26,22 +26,23 @@ function exportFilename(parts) {
26
26
  const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
27
27
  const extension = parts.extension.replace(/^\./, "");
28
28
  const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
29
- const modifier = [
30
- (parts.modifier ?? "").trim(),
31
- isTestOperation(parts.stationCall) ? "Testing" : ""
32
- ].filter((part) => part !== "").join(" ");
29
+ const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
33
30
  const stem = oneLine(
34
- renderTemplate(NAME_TEMPLATES[shape], {
35
- op: dateValues(millis),
36
- log: logValues({
37
- station: String(parts.stationCall ?? "").trim(),
38
- ref: parts.ref,
39
- activity: parts.activity,
40
- modifier,
41
- extension,
42
- compact: parts.compact
43
- })
44
- }, shape)
31
+ renderTemplate(
32
+ NAME_TEMPLATES[shape],
33
+ {
34
+ op: dateValues(millis),
35
+ log: logValues({
36
+ station: String(parts.stationCall ?? "").trim(),
37
+ ref: parts.ref,
38
+ activity: parts.activity,
39
+ modifier,
40
+ extension,
41
+ compact: parts.compact
42
+ })
43
+ },
44
+ shape
45
+ )
45
46
  );
46
47
  return `${sanitize(stem) || "export"}.${extension}`;
47
48
  }
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
118
118
  const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
119
119
  const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
120
120
  const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
121
- const titleContext = settings.includePrivateData === false ? { ...context, op: { ...context.op, title: "", userTitle: "", userNotes: "", grid: String(context.op.grid ?? "").slice(0, 6) }, log: { ...context.log, modifier: "" } } : context;
121
+ const titleContext = settings.includePrivateData === false ? {
122
+ ...context,
123
+ op: {
124
+ ...context.op,
125
+ title: "",
126
+ userTitle: "",
127
+ userNotes: "",
128
+ grid: String(context.op.grid ?? "").slice(0, 6)
129
+ },
130
+ log: { ...context.log, modifier: "" }
131
+ } : context;
122
132
  const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
123
133
  return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
124
134
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.5.6
1
+ // @ham2k/extension-sdk 0.5.7
2
2
  /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface SvgScene {
4
4
  version: 1;
@@ -1527,7 +1527,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
1527
1527
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1528
1528
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1529
1529
  };
1530
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
1530
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
1531
1531
  operation: Record<string, JSONValue>;
1532
1532
  qsos: Record<string, JSONValue>[];
1533
1533
  segments?: OperationSegmentPayload[];
package/dist/scoring.js CHANGED
@@ -74,18 +74,8 @@ function contestScorer(scorer, options = {}) {
74
74
  }
75
75
  const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
76
76
  const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
77
- const rest = chronological(log).filter(
78
- (q) => !excludeUuid || q.uuid !== excludeUuid
79
- );
80
- return fold(
81
- scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
82
- rest,
83
- args,
84
- ctx,
85
- void 0,
86
- void 0,
87
- args.segments
88
- );
77
+ const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
78
+ return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
89
79
  }
90
80
  return {
91
81
  scope,
@@ -116,10 +106,7 @@ function contestScorer(scorer, options = {}) {
116
106
  return {
117
107
  qsoScores,
118
108
  daySections,
119
- operationSummary: scorer.summarizeScore(
120
- { scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
121
- ctx
122
- ),
109
+ operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
123
110
  scoresheet: end,
124
111
  ...endDay !== void 0 && { scoresheetDay: endDay }
125
112
  };
@@ -129,10 +116,7 @@ function contestScorer(scorer, options = {}) {
129
116
  /// discarded, which is exactly why one `scoreQso` can serve both paths.
130
117
  async scoreQso(args, ctx) {
131
118
  const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
132
- const { score } = scorer.scoreQso(
133
- { scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false },
134
- ctx
135
- );
119
+ const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
136
120
  return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
137
121
  },
138
122
  /// The same live judgement as `scoreQso`, for many candidates at once —
@@ -198,8 +198,8 @@ below — manifest, source, build script — so the rest of this section is what
198
198
  change rather than what to type.
199
199
 
200
200
  ```js
201
- import { build } from 'esbuild'
202
- import { buildExtension } from '@ham2k/extension-tools'
201
+ import { build } from "esbuild"
202
+ import { buildExtension } from "@ham2k/extension-tools"
203
203
 
204
204
  await buildExtension(build, { dir: import.meta.dirname })
205
205
  ```
@@ -224,7 +224,7 @@ own module state — and nothing anywhere that says so. If the second copy is
224
224
  what you want, which is how you use a version the host does not carry, say so:
225
225
 
226
226
  ```js
227
- await buildExtension(build, { dir: import.meta.dirname, inline: ['liquidjs'] })
227
+ await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
228
228
  ```
229
229
 
230
230
  `extensions/samples/hello-world` is the shortest complete example. Declaring
package/docs/hooks.md CHANGED
@@ -1197,10 +1197,11 @@ checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
1197
1197
  the harness copies the checkpoint, replays that tail onto it (continuing the
1198
1198
  day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
1199
1199
  scores the candidate against the result. The host sends a checkpoint only when
1200
- it can prove the log is what the checkpoint folded plus appended contacts
1201
- (`scoreQso`), or when the log has not moved since the checkpoint was written
1202
- (`scoreCandidates`, which sends no tail yet); otherwise the harness reads the
1203
- log through `ctx.getQsos` and folds it cold.
1200
+ it can prove the log is what the checkpoint folded plus appended contacts;
1201
+ otherwise the harness reads the log through `ctx.getQsos` and folds it cold.
1202
+ The batched `scoreQsos` resumes the same way: after the first pass of a
1203
+ session, an append arrives as `resumeFrom` plus the appended contacts, and only
1204
+ their verdicts come back.
1204
1205
  `scoreCandidates` answers the same question for many at once, which is what the
1205
1206
  Spots Panel asks to mark the spots already worked: the log is folded once per
1206
1207
  scorer and every candidate scored against a COPY of the result, so no candidate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -21,14 +21,7 @@
21
21
  // for the whole thing.
22
22
 
23
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"
24
+ import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref, ScoreTally } from "@ham2k/extension-sdk"
32
25
  import { fmtInteger } from "@ham2k/lib-format-tools"
33
26
 
34
27
  import manifest from "../manifest.json" with { type: "json" }
@@ -53,7 +46,9 @@ function refOfType(container: Record<string, JSONValue>, type: string): Record<s
53
46
 
54
47
  /// "05" and "5" are the same multiplier and must not count twice.
55
48
  function normalizeZone(value: JSONValue | undefined): string {
56
- const digits = String(value ?? "").trim().replace(/^0+(?=\d)/, "")
49
+ const digits = String(value ?? "")
50
+ .trim()
51
+ .replace(/^0+(?=\d)/, "")
57
52
  return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
58
53
  }
59
54
 
@@ -154,10 +149,7 @@ const CQWWScorer: ContestScorer<Scoresheet> = {
154
149
 
155
150
  const ActivityHook = {
156
151
  /// 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[]> {
152
+ async operationControls(_args: { operation: Record<string, JSONValue> }, _ctx: HookContext): Promise<LoggingControlDescriptor[]> {
161
153
  return [
162
154
  {
163
155
  key: "k2hrc-cqww/setup",
@@ -209,8 +201,7 @@ const ActivityHook = {
209
201
  const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
210
202
  const guess = (their.guess as Record<string, JSONValue>) ?? {}
211
203
  const call = str(their.call)
212
- const suggested =
213
- normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
204
+ const suggested = normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
214
205
 
215
206
  return [
216
207
  {
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -46,8 +46,7 @@ async function login(ctx: HookContext): Promise<string> {
46
46
  const password = ctx.account?.credentials?.password
47
47
  if (!username || !password) throw new Error("No HamQTH account configured")
48
48
 
49
- const response = await host.fetch(
50
- `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
49
+ const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
51
50
  const sessionId = tag(response.body, "session_id")
52
51
  if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
53
52
 
@@ -62,8 +61,7 @@ async function login(ctx: HookContext): Promise<string> {
62
61
  /// Note that it re-logs-in only ONCE — a second failure is a real one, and
63
62
  /// looping on it would hammer the API behind the operator's back.
64
63
  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}`)
64
+ const search = (id: string) => host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
67
65
 
68
66
  const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
69
67
  const response = await search(sessionId)
@@ -167,13 +165,11 @@ const HamQTHAccount = {
167
165
  if (!username || !credentials.password) return "Enter your HamQTH username and password"
168
166
 
169
167
  try {
170
- const response = await host.fetch(
171
- `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
168
+ const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
172
169
  const sessionId = tag(response.body, "session_id")
173
170
  if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
174
171
 
175
- const check = await host.fetch(
176
- `${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
172
+ const check = await host.fetch(`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
177
173
  const name = tag(check.body, "adr_name")
178
174
  return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
179
175
  } catch (e) {
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -19,12 +19,7 @@
19
19
  // API key the program issues, and the export hooks that submit a log. See
20
20
  // `docs/hooks.md` §`spots` and §`export`.
21
21
 
22
- import {
23
- activityScorer,
24
- contestScorer,
25
- defineExtension,
26
- referenceActivity,
27
- } from "@ham2k/extension-sdk"
22
+ import { activityScorer, contestScorer, defineExtension, referenceActivity } from "@ham2k/extension-sdk"
28
23
  import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
29
24
  import { locationToGrid6 } from "@ham2k/lib-geo-tools"
30
25
 
@@ -116,7 +111,9 @@ const referenceList: DataFileDefinition = {
116
111
  category: "k2hrc-llota",
117
112
 
118
113
  jsonToLookupEntry: (entry: Record<string, any>) => {
119
- const ref = String(entry?.reference_code ?? "").trim().toUpperCase()
114
+ const ref = String(entry?.reference_code ?? "")
115
+ .trim()
116
+ .toUpperCase()
120
117
  // Returning null drops the entry. A list of this size will contain a few
121
118
  // rows that are not references, and throwing would abandon the rest.
122
119
  if (!ref) return null
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -49,11 +49,7 @@ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
49
49
  /// document that does not parse, and the sandbox is a mitigation, not a reason
50
50
  /// to skip this.
51
51
  function escapeHtml(value: string): string {
52
- return value
53
- .replace(/&/g, "&amp;")
54
- .replace(/</g, "&lt;")
55
- .replace(/>/g, "&gt;")
56
- .replace(/"/g, "&quot;")
52
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
57
53
  }
58
54
 
59
55
  /// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -8,16 +8,8 @@
8
8
  "icon": "radio-tower",
9
9
  "accentColor": "#4C6EF5",
10
10
  "api": 1,
11
- "keywords": [
12
- "svg",
13
- "panel",
14
- "sample",
15
- "weather",
16
- "radio"
17
- ],
18
- "hooks": [
19
- "panel"
20
- ],
11
+ "keywords": ["svg", "panel", "sample", "weather", "radio"],
12
+ "hooks": ["panel"],
21
13
  "sharedDependencies": {
22
14
  "@ham2k/lib-callsigns": "^1.0.0",
23
15
  "@ham2k/lib-country-files": "^1.0.0",
@@ -1,12 +1,12 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
2
  // SPDX-License-Identifier: MIT
3
- import { defineExtension } from '@ham2k/extension-sdk'
4
- import manifest from '../manifest.json' with { type: 'json' }
5
- import { createScenePanels } from './scenes.ts'
3
+ import { defineExtension } from "@ham2k/extension-sdk"
4
+ import manifest from "../manifest.json" with { type: "json" }
5
+ import { createScenePanels } from "./scenes.ts"
6
6
 
7
7
  defineExtension({
8
8
  ...manifest,
9
9
  onActivation({ registerHook }) {
10
- registerHook('panel', { key: manifest.key, hook: createScenePanels() })
10
+ registerHook("panel", { key: manifest.key, hook: createScenePanels() })
11
11
  },
12
12
  })
@@ -1,16 +1,20 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
2
  // SPDX-License-Identifier: MIT
3
- import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
3
+ import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
4
4
 
5
- const svg = (body: string, width = 640, height = 360) =>
6
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
5
+ const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
7
6
  const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
8
- const label = (x: number, y: number, text: string, size = 16, color = '#9aaec6') =>
7
+ const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
9
8
  `<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
10
- const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, '#e7edf4')}`
11
- const textLayer = (id: string, value: string, x: number, y: number, width: number,
12
- size: number, prefix = '', suffix = '', decimals = 0): SvgSceneLayer =>
13
- ({ id, x, y, width, height: size + 12, text: { value, prefix, suffix, decimals, size, color: '#a9efcf' } })
9
+ const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, "#e7edf4")}`
10
+ const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): SvgSceneLayer => ({
11
+ id,
12
+ x,
13
+ y,
14
+ width,
15
+ height: size + 12,
16
+ text: { value, prefix, suffix, decimals, size, color: "#a9efcf" },
17
+ })
14
18
 
15
19
  type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
16
20
  /** Inject time for deterministic tests; no timers or per-frame extension work. */
@@ -24,103 +28,214 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
24
28
  s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
25
29
  radios.set(id, s)
26
30
  }
27
- if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
31
+ if (s.connected && s.due && now() >= s.due) {
32
+ s.reported = s.requested
33
+ s.due = 0
34
+ }
28
35
  return s
29
36
  }
30
37
  return {
31
38
  async getPanels() {
32
39
  return [
33
- { key: 'radio', title: 'Radio Front Face · Simulation', icon: 'radio-tower', multiple: true, on: ['tick:1'] },
34
- { key: 'weather', title: 'Weather Chart · Simulation', icon: 'weather-partly-cloudy', multiple: true },
40
+ { key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
41
+ { key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
35
42
  ]
36
43
  },
37
44
  async render(args): Promise<PanelContent> {
38
- if (args.panelKey === 'weather') {
45
+ if (args.panelKey === "weather") {
39
46
  const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
40
- const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(' ')
41
- return { kind: 'svgScene', scene: {
42
- version: 1, width: 640, height: 360, values: { hour: 0 },
43
- layers: [
44
- { id: 'chart', x: 0, y: 0, width: 640, height: 360, svg: svg(`
47
+ const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
48
+ return {
49
+ kind: "svgScene",
50
+ scene: {
51
+ version: 1,
52
+ width: 640,
53
+ height: 360,
54
+ values: { hour: 0 },
55
+ layers: [
56
+ {
57
+ id: "chart",
58
+ x: 0,
59
+ y: 0,
60
+ width: 640,
61
+ height: 360,
62
+ svg: svg(`
45
63
  <rect width="640" height="360" rx="18" fill="#142337"/>
46
- ${label(30, 38, 'FORECAST / SIMULATED DATA', 18, '#e7edf4')}
47
- ${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join('')}
64
+ ${label(30, 38, "FORECAST / SIMULATED DATA", 18, "#e7edf4")}
65
+ ${[15, 20, 25].map((t) => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join("")}
48
66
  <polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
49
- ${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, '+' + i + 'h', 12)}`).join('')}
50
- ${label(30, 334, 'Drag the chart to inspect · Arrow keys to step', 14)}
51
- `) },
52
- { id: 'cursor', x: 59, y: 100, width: 2, height: 172,
53
- svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
54
- translateX: bind('hour', [0, 7], [0, 518]) },
55
- textLayer('hour', 'hour', 30, 52, 250, 24, 'In ', ' hours'),
56
- { ...textLayer('temperature', 'hour', 370, 52, 240, 24, '', ' °C'),
57
- text: { value: 'hour', samples: temperatures, suffix: ' °C', size: 24, color: '#a9efcf' } },
58
- ],
59
- controls: [{ id: 'inspect', kind: 'slider', label: 'Forecast hour', x: 60, y: 100, width: 518, height: 185,
60
- value: 'hour', min: 0, max: 7, step: 1 }],
61
- } }
67
+ ${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, "+" + i + "h", 12)}`).join("")}
68
+ ${label(30, 334, "Drag the chart to inspect · Arrow keys to step", 14)}
69
+ `),
70
+ },
71
+ {
72
+ id: "cursor",
73
+ x: 59,
74
+ y: 100,
75
+ width: 2,
76
+ height: 172,
77
+ svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
78
+ translateX: bind("hour", [0, 7], [0, 518]),
79
+ },
80
+ textLayer("hour", "hour", 30, 52, 250, 24, "In ", " hours"),
81
+ {
82
+ ...textLayer("temperature", "hour", 370, 52, 240, 24, "", " °C"),
83
+ text: { value: "hour", samples: temperatures, suffix: " °C", size: 24, color: "#a9efcf" },
84
+ },
85
+ ],
86
+ controls: [
87
+ { id: "inspect", kind: "slider", label: "Forecast hour", x: 60, y: 100, width: 518, height: 185, value: "hour", min: 0, max: 7, step: 1 },
88
+ ],
89
+ },
90
+ }
62
91
  }
63
92
  const s = state(args.instanceId ?? args.panelKey)
64
- return { kind: 'svgScene', scene: {
65
- version: 1, width: 640, height: 360,
66
- values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
67
- layers: [
68
- { id: 'face', x: 0, y: 0, width: 640, height: 360, svg: svg(`
93
+ return {
94
+ kind: "svgScene",
95
+ scene: {
96
+ version: 1,
97
+ width: 640,
98
+ height: 360,
99
+ values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
100
+ layers: [
101
+ {
102
+ id: "face",
103
+ x: 0,
104
+ y: 0,
105
+ width: 640,
106
+ height: 360,
107
+ svg: svg(`
69
108
  <rect width="640" height="360" rx="18" fill="#142337"/>
70
- ${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
109
+ ${label(28, 35, "RADIO FRONT FACE / SIMULATED CAT", 18, "#e7edf4")}
71
110
  <rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
72
- ${label(42, 78, 'REPORTED VFO', 12)}${label(42, 143, 'REQUESTED', 12)}
111
+ ${label(42, 78, "REPORTED VFO", 12)}${label(42, 143, "REQUESTED", 12)}
73
112
  <path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
74
- ${label(423, 170, '14.0', 12)}${label(565, 170, '14.35', 12)}
75
- ${label(424, 197, 'VFO POSITION', 12)}
113
+ ${label(423, 170, "14.0", 12)}${label(565, 170, "14.35", 12)}
114
+ ${label(424, 197, "VFO POSITION", 12)}
76
115
  <path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
77
- ${label(42, 268, 'Band position / kHz', 14)}
78
- ${label(548, 256, 'LINK', 12)}
79
- ${button(28, 'Connect / disconnect')}${button(234, 'External tune +5 kHz')}${button(440, 'Toggle 2 s / 0 s delay')}
80
- `) },
81
- textLayer('reported', 'reported', 42, 78, 320, 34, '', ' kHz', 1),
82
- textLayer('requested', 'requested', 168, 121, 210, 22, '', '', 1),
83
- { id: 'needle', x: 503, y: 72, width: 8, height: 80, pivot: [0.5, 1], transitionMs: 450,
84
- svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
85
- rotation: bind('reported', [14000, 14350], [-80, 80]) },
86
- { id: 'knob', x: 400, y: 204, width: 72, height: 72,
87
- svg: svg('<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>', 72, 72),
88
- rotation: bind('requested', [14000, 14350], [-150, 150]) },
89
- { id: 'handle', x: 36, y: 224, width: 12, height: 24,
90
- svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
91
- translateX: bind('requested', [14000, 14350], [0, 290]) },
92
- { id: 'led', x: 548, y: 208, width: 40, height: 40,
93
- svg: svg('<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>', 40, 40),
94
- opacity: bind('connected', [0, 1], [0.08, 1]), pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true } },
95
- ],
96
- controls: [
97
- { id: 'tune', label: 'Tuning knob, kHz', kind: 'knob', x: 398, y: 202, width: 76, height: 76,
98
- value: 'requested', min: 14000, max: 14350, step: 0.1, sensitivity: 0.1, event: 'tune' },
99
- { id: 'band', label: 'Band position, kHz', kind: 'slider', x: 42, y: 215, width: 290, height: 42,
100
- value: 'requested', min: 14000, max: 14350, step: 0.1, event: 'tune' },
101
- ...(['connect', 'external', 'delay'] as const).map((event, i) => ({
102
- id: event, label: ['Toggle connection', 'Simulate external tuning', 'Toggle CAT delay'][i],
103
- kind: 'button' as const, x: 28 + i * 206, y: 298, width: 172, height: 38, event,
104
- })),
105
- ],
106
- } }
116
+ ${label(42, 268, "Band position / kHz", 14)}
117
+ ${label(548, 256, "LINK", 12)}
118
+ ${button(28, "Connect / disconnect")}${button(234, "External tune +5 kHz")}${button(440, "Toggle 2 s / 0 s delay")}
119
+ `),
120
+ },
121
+ textLayer("reported", "reported", 42, 78, 320, 34, "", " kHz", 1),
122
+ textLayer("requested", "requested", 168, 121, 210, 22, "", "", 1),
123
+ {
124
+ id: "needle",
125
+ x: 503,
126
+ y: 72,
127
+ width: 8,
128
+ height: 80,
129
+ pivot: [0.5, 1],
130
+ transitionMs: 450,
131
+ svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
132
+ rotation: bind("reported", [14000, 14350], [-80, 80]),
133
+ },
134
+ {
135
+ id: "knob",
136
+ x: 400,
137
+ y: 204,
138
+ width: 72,
139
+ height: 72,
140
+ svg: svg(
141
+ '<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>',
142
+ 72,
143
+ 72,
144
+ ),
145
+ rotation: bind("requested", [14000, 14350], [-150, 150]),
146
+ },
147
+ {
148
+ id: "handle",
149
+ x: 36,
150
+ y: 224,
151
+ width: 12,
152
+ height: 24,
153
+ svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
154
+ translateX: bind("requested", [14000, 14350], [0, 290]),
155
+ },
156
+ {
157
+ id: "led",
158
+ x: 548,
159
+ y: 208,
160
+ width: 40,
161
+ height: 40,
162
+ svg: svg(
163
+ '<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>',
164
+ 40,
165
+ 40,
166
+ ),
167
+ opacity: bind("connected", [0, 1], [0.08, 1]),
168
+ pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true },
169
+ },
170
+ ],
171
+ controls: [
172
+ {
173
+ id: "tune",
174
+ label: "Tuning knob, kHz",
175
+ kind: "knob",
176
+ x: 398,
177
+ y: 202,
178
+ width: 76,
179
+ height: 76,
180
+ value: "requested",
181
+ min: 14000,
182
+ max: 14350,
183
+ step: 0.1,
184
+ sensitivity: 0.1,
185
+ event: "tune",
186
+ },
187
+ {
188
+ id: "band",
189
+ label: "Band position, kHz",
190
+ kind: "slider",
191
+ x: 42,
192
+ y: 215,
193
+ width: 290,
194
+ height: 42,
195
+ value: "requested",
196
+ min: 14000,
197
+ max: 14350,
198
+ step: 0.1,
199
+ event: "tune",
200
+ },
201
+ ...(["connect", "external", "delay"] as const).map((event, i) => ({
202
+ id: event,
203
+ label: ["Toggle connection", "Simulate external tuning", "Toggle CAT delay"][i],
204
+ kind: "button" as const,
205
+ x: 28 + i * 206,
206
+ y: 298,
207
+ width: 172,
208
+ height: 38,
209
+ event,
210
+ })),
211
+ ],
212
+ },
213
+ }
107
214
  },
108
215
  async onEvent(args) {
109
216
  const s = state(args.instanceId ?? args.panelKey)
110
217
  const event = args.event
111
- if (event.action === 'connect') { s.connected = 1 - s.connected; s.due = 0; s.requested = s.reported }
112
- if (event.action === 'delay') s.slow = 1 - s.slow
113
- if (event.action === 'external') {
114
- if (!s.connected) throw new Error('Radio disconnected')
218
+ if (event.action === "connect") {
219
+ s.connected = 1 - s.connected
220
+ s.due = 0
221
+ s.requested = s.reported
222
+ }
223
+ if (event.action === "delay") s.slow = 1 - s.slow
224
+ if (event.action === "external") {
225
+ if (!s.connected) throw new Error("Radio disconnected")
115
226
  s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
116
- s.requested = s.reported; s.due = 0
227
+ s.requested = s.reported
228
+ s.due = 0
117
229
  }
118
- if (event.action === 'tune') {
119
- if (!s.connected) throw new Error('Radio disconnected')
120
- if (typeof event.value !== 'number' || !Number.isFinite(event.value)) throw new Error('Invalid frequency')
230
+ if (event.action === "tune") {
231
+ if (!s.connected) throw new Error("Radio disconnected")
232
+ if (typeof event.value !== "number" || !Number.isFinite(event.value)) throw new Error("Invalid frequency")
121
233
  s.requested = Math.max(14000, Math.min(14350, event.value))
122
234
  s.due = now() + (s.slow ? 2000 : 0)
123
- if (!s.slow) { s.reported = s.requested; s.due = 0 }
235
+ if (!s.slow) {
236
+ s.reported = s.requested
237
+ s.due = 0
238
+ }
124
239
  }
125
240
  return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
126
241
  },