@ham2k/extension-sdk 0.5.6 → 0.6.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/dist/scoring.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { copyOnWrite } from "./copyOnWrite.js";
1
2
  const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
3
+ const HELD_CHECKPOINTS = 4;
4
+ const RESUME_MISSING = "resume-missing";
5
+ const CHECKPOINT_HELD_KEY = "$checkpointHeld";
2
6
  function copySheet(sheet) {
3
7
  return JSON.parse(JSON.stringify(sheet));
4
8
  }
5
- function openCheckpoint(resumeFrom, resumeDay) {
6
- return { sheet: copySheet(resumeFrom), day: typeof resumeDay === "number" ? resumeDay : void 0 };
9
+ function operationUuidOf(operation) {
10
+ return typeof operation.uuid === "string" ? operation.uuid : "";
7
11
  }
8
12
  function dayOf(qso) {
9
13
  const millis = Number(qso.startAtMillis ?? 0);
@@ -25,6 +29,26 @@ function candidateAsOfNow(qso) {
25
29
  function contestScorer(scorer, options = {}) {
26
30
  const scope = options.scope ?? "always";
27
31
  const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
32
+ const held = /* @__PURE__ */ new Map();
33
+ function hold(operationUuid, key, sheet, day) {
34
+ held.delete(operationUuid);
35
+ held.set(operationUuid, { key, sheet, day });
36
+ while (held.size > HELD_CHECKPOINTS) held.delete(held.keys().next().value);
37
+ }
38
+ function resolveCheckpoint(args) {
39
+ const operationUuid = operationUuidOf(args.operation);
40
+ const key = typeof args.resumeKey === "string" ? args.resumeKey : void 0;
41
+ if (args.resumeFrom !== void 0) {
42
+ const day = typeof args.resumeDay === "number" ? args.resumeDay : void 0;
43
+ const sheet = args.resumeFrom;
44
+ if (key !== void 0) hold(operationUuid, key, sheet, day);
45
+ return { key: key ?? "", sheet, day };
46
+ }
47
+ if (key === void 0) return void 0;
48
+ const entry = held.get(operationUuid);
49
+ if (entry === void 0 || entry.key !== key) throw new Error(`${RESUME_MISSING}: ${key}`);
50
+ return entry;
51
+ }
28
52
  function refOfScopedType(operation) {
29
53
  if (!scopedRefTypes) return void 0;
30
54
  const refs = operation.refs ?? [];
@@ -66,33 +90,24 @@ function contestScorer(scorer, options = {}) {
66
90
  return { sheet, day: currentDay };
67
91
  }
68
92
  async function liveScoresheet(args, ctx, excludeUuid) {
69
- if (args.resumeFrom !== void 0) {
70
- const { sheet, day } = openCheckpoint(args.resumeFrom, args.resumeDay);
93
+ const resumed = resolveCheckpoint(args);
94
+ if (resumed !== void 0) {
95
+ const sheet = copyOnWrite(resumed.sheet);
71
96
  const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
72
- if (tail.length === 0) return { sheet, day };
73
- return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
97
+ if (tail.length === 0) return { sheet, day: resumed.day };
98
+ return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, resumed.day);
74
99
  }
75
100
  const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
76
101
  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
- );
102
+ const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
103
+ return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
89
104
  }
90
105
  return {
91
106
  scope,
92
107
  async scoreQsos(args, ctx) {
93
108
  const qsos = chronological(args.qsos);
94
- const resumed = args.resumeFrom !== void 0 ? openCheckpoint(args.resumeFrom, args.resumeDay) : void 0;
95
- const start = resumed?.sheet ?? scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
109
+ const resumed = resolveCheckpoint(args);
110
+ const start = resumed !== void 0 ? copySheet(resumed.sheet) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
96
111
  const qsoScores = {};
97
112
  const daySections = [];
98
113
  const { sheet: end, day: endDay } = fold(
@@ -113,15 +128,15 @@ function contestScorer(scorer, options = {}) {
113
128
  args.segments,
114
129
  resumed?.day
115
130
  );
131
+ const checkpointHeld = typeof args.checkpointKey === "string";
132
+ if (checkpointHeld) hold(operationUuidOf(args.operation), args.checkpointKey, end, endDay);
116
133
  return {
117
134
  qsoScores,
118
135
  daySections,
119
- operationSummary: scorer.summarizeScore(
120
- { scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
121
- ctx
122
- ),
136
+ operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
123
137
  scoresheet: end,
124
- ...endDay !== void 0 && { scoresheetDay: endDay }
138
+ ...endDay !== void 0 && { scoresheetDay: endDay },
139
+ ...checkpointHeld && { checkpointHeld: true }
125
140
  };
126
141
  },
127
142
  /// Live path: score the candidate against the log so far and report only
@@ -129,30 +144,36 @@ function contestScorer(scorer, options = {}) {
129
144
  /// discarded, which is exactly why one `scoreQso` can serve both paths.
130
145
  async scoreQso(args, ctx) {
131
146
  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
- );
136
- return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
147
+ const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
148
+ const checkpointHeld = typeof args.resumeKey === "string";
149
+ return {
150
+ notices: score.notices ?? [],
151
+ alerts: score.alerts ?? [],
152
+ ...score.dupe !== void 0 && { dupe: score.dupe },
153
+ ...checkpointHeld && { checkpointHeld: true }
154
+ };
137
155
  },
138
156
  /// The same live judgement as `scoreQso`, for many candidates at once —
139
157
  /// the Spots Panel annotating every spot on screen. The log is folded ONCE
140
- /// and each candidate scored against its own copy of the result, so a
158
+ /// and each candidate scored against its own view of the result, so a
141
159
  /// candidate can neither see nor become a duplicate of another (both spots
142
160
  /// for the same unworked call read as unworked, which is what an operator
143
161
  /// scanning a band expects).
144
162
  ///
145
- /// The copy is per candidate rather than per call because
163
+ /// The view is per candidate rather than per call because
146
164
  /// `ContestScorer.scoreQso` may mutate the scoresheet it is handed — the
147
165
  /// contract that makes the batch fold linear. Without it the first
148
- /// candidate's call would count as worked for every candidate after it.
166
+ /// candidate's call would count as worked for every candidate after it. A
167
+ /// view, not a copy: copying a long log's worked-call map forty times over
168
+ /// was the cost of the whole pass.
149
169
  async scoreCandidates(args, ctx) {
150
170
  const { sheet: base } = await liveScoresheet(args, ctx);
151
171
  const result = {};
172
+ if (typeof args.resumeKey === "string") result[CHECKPOINT_HELD_KEY] = true;
152
173
  for (const candidate of args.candidates) {
153
174
  const { score } = scorer.scoreQso(
154
175
  {
155
- scoresheet: copySheet(base),
176
+ scoresheet: copyOnWrite(base),
156
177
  qso: candidateAsOfNow(candidate.qso),
157
178
  operation: args.operation,
158
179
  ref: args.ref,
@@ -174,6 +195,8 @@ function tally(key, forScope, total, extra = {}) {
174
195
  return { key, for: forScope, total, ...extra };
175
196
  }
176
197
  export {
198
+ CHECKPOINT_HELD_KEY,
199
+ RESUME_MISSING,
177
200
  contestScorer,
178
201
  tally
179
202
  };
@@ -37,7 +37,7 @@ The same manifest a built-in extension carries, plus `api`:
37
37
  "version": "1.2.3",
38
38
  "description": "Notes about the stations I work",
39
39
  "category": "dashboard",
40
- "api": 1,
40
+ "api": 2,
41
41
  "domains": ["notes.example.org"],
42
42
  "icon": "note-text"
43
43
  }
@@ -45,7 +45,10 @@ The same manifest a built-in extension carries, plus `api`:
45
45
 
46
46
  `api` is the extension API the bundle was built against. The host refuses one
47
47
  it does not speak, so an older app meeting a newer bundle says so instead of
48
- failing somewhere deep in a hook.
48
+ failing somewhere deep in a hook — and the catalog does not list it for that
49
+ app at all. Declare the lowest version that has what the bundle uses, so it
50
+ reaches every app that can run it: 2 adds `host.webSocket`, and the packer
51
+ refuses a manifest declaring `webSockets` under anything less.
49
52
 
50
53
  Three fields are **refused** in a distributed bundle, and it is worth
51
54
  understanding why:
@@ -198,8 +201,8 @@ below — manifest, source, build script — so the rest of this section is what
198
201
  change rather than what to type.
199
202
 
200
203
  ```js
201
- import { build } from 'esbuild'
202
- import { buildExtension } from '@ham2k/extension-tools'
204
+ import { build } from "esbuild"
205
+ import { buildExtension } from "@ham2k/extension-tools"
203
206
 
204
207
  await buildExtension(build, { dir: import.meta.dirname })
205
208
  ```
@@ -224,7 +227,7 @@ own module state — and nothing anywhere that says so. If the second copy is
224
227
  what you want, which is how you use a version the host does not carry, say so:
225
228
 
226
229
  ```js
227
- await buildExtension(build, { dir: import.meta.dirname, inline: ['liquidjs'] })
230
+ await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
228
231
  ```
229
232
 
230
233
  `extensions/samples/hello-world` is the shortest complete example. Declaring
@@ -254,7 +257,7 @@ It reports every problem at once rather than one per run:
254
257
  h2kext-pack: ./build is not ready to package:
255
258
  manifest.json: missing required field 'name'
256
259
  manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
257
- manifest.json: missing 'api' — declare the extension API this was built against (currently 1)
260
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 2)
258
261
  ```
259
262
 
260
263
  `--force-name` waives the naming convention below. It exists for Ham2K's own
@@ -318,8 +321,8 @@ needs one degrades the same way it does in a build without that value
318
321
  configured.
319
322
 
320
323
  The sandbox is otherwise the same one the built-in extensions run in — no
321
- filesystem, no network beyond the `domains` the manifest declares and the user
322
- approved, no timers.
324
+ filesystem, no network beyond the `domains` and `webSockets` the manifest
325
+ declares and the user approved, no timers.
323
326
 
324
327
  ## Installing one
325
328
 
package/docs/hooks.md CHANGED
@@ -499,11 +499,13 @@ interface ActivityHook {
499
499
  suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
500
500
  processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
501
501
  }
502
- // LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?, order?,
503
- // optionType?, allowsMultiple?, editable?, input}
502
+ // LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
503
+ // order?, optionType?, allowsMultiple?, editable?, input}
504
504
  // `shortLabel` is the same name in fewer characters, for a field the logging
505
505
  // row has squeezed below its label's width. The core measures and picks; the
506
506
  // extension only says what the short form is.
507
+ // `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
508
+ // the arrow keys still reach it. Every shipped contest sets it on Our Serial.
507
509
  // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
508
510
  // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
509
511
 
@@ -1161,8 +1163,10 @@ standing beside your credit, on the one row that can show only one.
1161
1163
  A scorer's internal *rules* stay private to its scoresheet: contests don't
1162
1164
  share a rule vocabulary, so only notices, alerts, points and summary tallies
1163
1165
  cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
1164
- return it; copying accumulated state per QSO is quadratic, and the harness
1165
- copies any checkpoint at its boundary.
1166
+ return it; copying accumulated state per QSO is quadratic. The batch pass
1167
+ copies the checkpoint it resumes from, once; the live paths hand the scorer a
1168
+ copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
1169
+ writes land in the view and the held sheet is never touched.
1166
1170
 
1167
1171
  ```ts
1168
1172
  // The wire shape the harness produces, for reference:
@@ -1171,17 +1175,19 @@ interface ScoringHook {
1171
1175
  scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
1172
1176
  scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
1173
1177
  }
1174
- // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
1178
+ // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
1175
1179
  // ScoreQsosResult: {
1176
1180
  // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
1177
1181
  // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
1178
1182
  // operationSummary: Record<tallyKey, ScoreTally>,
1179
1183
  // scoresheet, // checkpoint to resume from
1180
1184
  // scoresheetDay?, // the day it ends on, handed back as resumeDay
1185
+ // checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
1181
1186
  // }
1182
- // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1183
- // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1184
- // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
1187
+ // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1188
+ // QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
1189
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1190
+ // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
1185
1191
  ```
1186
1192
 
1187
1193
  All three arrive already implemented by `contestScorer()`; a scorer writes the
@@ -1192,22 +1198,48 @@ both live paths for free.
1192
1198
  A candidate that carries no `startAtMillis` (the draft's time is still
1193
1199
  automatic) reaches the scorer stamped with the current time on both live
1194
1200
  paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
1195
- candidate that states a time keeps it. On both live paths a `resumeFrom`
1196
- checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
1197
- the harness copies the checkpoint, replays that tail onto it (continuing the
1198
- day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
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.
1201
+ candidate that states a time keeps it. On both live paths a checkpoint, with
1202
+ the `qsos` logged since it, stands in for the whole-log read: the harness
1203
+ views the checkpoint copy-on-write, replays that tail onto the view
1204
+ (continuing the day `resumeDay` names, so no scorer's per-day tally restarts
1205
+ mid-day) and scores the candidate against the result. The host sends a
1206
+ checkpoint only when it can prove the log is what the checkpoint folded plus
1207
+ appended contacts; otherwise the harness reads the log through `ctx.getQsos`
1208
+ and folds it cold. The batched `scoreQsos` resumes the same way: after the
1209
+ first pass of a session, an append arrives as a checkpoint plus the appended
1210
+ contacts, and only their verdicts come back.
1211
+
1212
+ **A checkpoint is held in the runtime and named, not sent, once it is
1213
+ there.** A sheet the size of a long log's worked-call map crossing the bridge
1214
+ on every keystroke would be most of the keystroke's cost. So the host names
1215
+ each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
1216
+ the batch pass produces it, and the harness keeps the end sheet under that
1217
+ name — one per operation, the last few operations. A live call, or the next
1218
+ append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
1219
+ `resumeKey` is filed under it too. A name the harness does not hold — a
1220
+ runtime restarted since, or one operation more than it keeps — is an error
1221
+ (`resume-missing`), never a silent cold fold, and the host answers it by
1222
+ sending the sheet once more. The host names a checkpoint only to a harness
1223
+ that has said `checkpointHeld: true` for it; a scorer built on an SDK without
1224
+ the cache never says so and keeps receiving the sheet. A `scoreCandidates`
1225
+ answer says it under the reserved key `$checkpointHeld`, since its other
1226
+ entries are the caller's candidate keys — a candidate key never starts with
1227
+ `$`.
1228
+
1229
+ The view a scorer gets on the live paths takes every kind of write —
1230
+ assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
1231
+ that would reach the base sheet by other means: `Object.freeze` (or `seal`,
1232
+ `preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
1233
+ or carries accessors is outside the contract and throws at the first touch.
1234
+
1204
1235
  `scoreCandidates` answers the same question for many at once, which is what the
1205
1236
  Spots Panel asks to mark the spots already worked: the log is folded once per
1206
- scorer and every candidate scored against a COPY of the result, so no candidate
1207
- can become another's duplicate. Candidates carry no uuid — a spot is not a
1208
- record — so results come back under caller-chosen keys.
1237
+ scorer and every candidate scored against its own copy-on-write VIEW of the
1238
+ result, so no candidate can become another's duplicate and no candidate pays
1239
+ for a copy of the log. Candidates carry no uuid — a spot is not a record — so
1240
+ results come back under caller-chosen keys.
1209
1241
 
1210
- `Qsos.watchForOperation` already filters deleted rows and orders by
1242
+ `QsosRepository.forOperation` already filters deleted rows and orders by
1211
1243
  `startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
1212
1244
  `deleted`/`event` row bookkeeping on this side of the bridge.
1213
1245
  `ScoringService` (`app/lib/services/scoring_service.dart`) writes
package/docs/templates.md CHANGED
@@ -224,7 +224,7 @@ will jump rather than count. Show HH:MM.
224
224
  | Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
225
225
  | Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
226
226
  | ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
227
- | 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 |
227
+ | 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 CW Keyer dialog (Settings → Radio, or the Station dialog's keyer row) |
228
228
 
229
229
  NOTES and COMMENT default to QSO notes and are withheld when private data
230
230
  is off. QSLMSG defaults to empty for program exports, and for the whole-log
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
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
  })