@ham2k/extension-sdk 1.12.1 → 1.12.4

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.
@@ -1,11 +1,44 @@
1
1
  import { exportTypeDefinition } from "./exportSettings.js";
2
2
  import { exportFilename, startMillisOf } from "./exportNames.js";
3
- import { segmentsWith } from "./segments.js";
3
+ import { operationForQso, segmentsWith } from "./segments.js";
4
+ import { utcDate } from "./templateContext.js";
4
5
  import { createCachedTranslator } from "./i18n.js";
5
6
  import en from "./i18n/activityExports.en.json";
6
7
  import es from "./i18n/activityExports.es.json";
7
8
  const tFor = createCachedTranslator({ en: { translation: en }, es: { translation: es } });
8
9
  const SEPARATOR = ":";
10
+ const DAY_MARK = "@";
11
+ const DAY_LENGTH = "YYYY-MM-DD".length;
12
+ function byUtcDay(qsos) {
13
+ const timed = /* @__PURE__ */ new Map();
14
+ const timeless = [];
15
+ for (const qso of qsos) {
16
+ const millis = Number(qso.startAtMillis ?? 0);
17
+ if (!(millis > 0)) {
18
+ timeless.push(qso);
19
+ continue;
20
+ }
21
+ const day = utcDate(millis);
22
+ const list = timed.get(day);
23
+ if (list) list.push(qso);
24
+ else timed.set(day, [qso]);
25
+ }
26
+ const days = [...timed.keys()].filter((day) => timed.get(day).some((qso) => !qso.deleted)).sort();
27
+ return new Map(days.map((day, i) => [day, i === 0 ? [...timeless, ...timed.get(day)] : timed.get(day)]));
28
+ }
29
+ function activeQsos(operation, qsos, segments, activationType, ref) {
30
+ if (!segments || segments.length === 0) return qsos;
31
+ const refsAt = (qso) => refsOfType(operationForQso(operation, qso, segments), activationType);
32
+ const segmentRefs = segments.map((segment) => refsOfType(segment.operation, activationType).map((r) => r.ref));
33
+ const known = new Set(segmentRefs.flat());
34
+ if (known.has(ref)) {
35
+ const own = qsos.filter((qso) => refsAt(qso).some((r) => r.ref === ref));
36
+ const movedHere = known.size > 1 && segmentRefs.slice(1).some((refs) => refs.includes(ref));
37
+ if (own.length > 0 || movedHere) return own;
38
+ }
39
+ const active = qsos.filter((qso) => refsAt(qso).length > 0);
40
+ return active.length > 0 ? active : qsos;
41
+ }
9
42
  function refsOfType(container, type) {
10
43
  const refs = container.refs ?? [];
11
44
  return refs.filter((r) => r && r.type === type && typeof r.ref === "string" && r.ref.length > 0).map((r) => ({ ref: r.ref, name: typeof r.name === "string" ? r.name : void 0 }));
@@ -34,10 +67,10 @@ function activityExportHook(rules) {
34
67
  }
35
68
  ];
36
69
  },
37
- async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
70
+ async suggestExportOptions({ operation, qsos, segments, compactFilenames }, ctx) {
38
71
  const refs = refsOfType(operation, rules.activationType);
39
72
  const t = tFor(ctx);
40
- return refs.map((ref) => ({
73
+ const whole = (ref) => ({
41
74
  exportType: `${rules.activationType}-adif`,
42
75
  templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
43
76
  exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
@@ -57,15 +90,44 @@ function activityExportHook(rules) {
57
90
  // being activated.
58
91
  refType: rules.activationType,
59
92
  ref: ref.ref
60
- }));
93
+ });
94
+ const daysOf = (ref) => byUtcDay(activeQsos(operation, qsos ?? [], segments, rules.activationType, ref));
95
+ const split = rules.splitByUtcDay ? refs.map((ref) => ({ ref, days: daysOf(ref.ref) })) : [];
96
+ if (split.some(({ days }) => days.size > 1)) {
97
+ return split.flatMap(
98
+ ({ ref, days }) => days.size === 0 ? [whole(ref)] : [...days].map(([day, dayQsos]) => ({
99
+ exportType: `${rules.activationType}-adif`,
100
+ // The day rides as the filename template's modifier: a template
101
+ // is dated by the whole log's first contact, and two files that
102
+ // differed only in their contents would overwrite each other.
103
+ templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label, modifier: day },
104
+ exportKey: `${prefix}${DAY_MARK}${day}${SEPARATOR}${ref.ref}`,
105
+ format: "adif",
106
+ label: t("adifForRefOnDay", { program: rules.label, ref: ref.ref, day }),
107
+ // Dated by the day's own first contact, which the file is.
108
+ filename: filenameFor(operation, dayQsos, ref.ref, compactFilenames),
109
+ // The day's own count: the operator checking that a day reached
110
+ // its threshold needs that day's number, not the whole log's.
111
+ qsoCount: dayQsos.filter((qso) => !qso.deleted).length,
112
+ icon: rules.icon,
113
+ selectedByDefault: true,
114
+ refType: rules.activationType,
115
+ ref: ref.ref
116
+ }))
117
+ );
118
+ }
119
+ return refs.map(whole);
61
120
  },
62
121
  async generateExport(args, ctx) {
63
122
  const exportType = String(args.exportType ?? "");
64
123
  const exportKey = String(args.exportKey ?? "");
65
- if (exportType !== `${rules.activationType}-adif` || !exportKey.startsWith(`${prefix}${SEPARATOR}`)) {
124
+ const dayed = exportKey.startsWith(`${prefix}${DAY_MARK}`) && exportKey.charAt(prefix.length + DAY_MARK.length + DAY_LENGTH) === SEPARATOR;
125
+ const day = dayed ? exportKey.slice(prefix.length + DAY_MARK.length, prefix.length + DAY_MARK.length + DAY_LENGTH) : void 0;
126
+ const head = dayed ? prefix.length + DAY_MARK.length + DAY_LENGTH : prefix.length;
127
+ if (exportType !== `${rules.activationType}-adif` || !(dayed || exportKey.startsWith(`${prefix}${SEPARATOR}`))) {
66
128
  throw new Error(`${rules.key}: unknown export type/key '${exportType}' / '${exportKey}'`);
67
129
  }
68
- const wanted = exportKey.slice(prefix.length + SEPARATOR.length);
130
+ const wanted = exportKey.slice(head + SEPARATOR.length);
69
131
  if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
70
132
  throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
71
133
  }
@@ -75,9 +137,13 @@ function activityExportHook(rules) {
75
137
  (r) => r?.type !== rules.activationType || r?.ref === wanted
76
138
  )
77
139
  });
140
+ const qsos = day === void 0 ? args.qsos : byUtcDay(args.qsos ?? []).get(day) ?? [];
141
+ if (day !== void 0 && !qsos.some((qso) => !qso.deleted)) {
142
+ throw new Error(`${rules.key}: no contacts on ${day} for ${wanted}`);
143
+ }
78
144
  const content = await adifForExport({
79
145
  operation: claimOnly(args.operation),
80
- qsos: args.qsos,
146
+ qsos,
81
147
  segments: segmentsWith(args.segments, claimOnly),
82
148
  includePrivateData: args.includePrivateData,
83
149
  includeLookupData: args.includeLookupData,
@@ -89,7 +155,7 @@ function activityExportHook(rules) {
89
155
  operatorFallback: rules.operatorFallback
90
156
  });
91
157
  return {
92
- filename: filenameFor(args.operation, args.qsos, wanted, args.compactFilenames),
158
+ filename: filenameFor(args.operation, qsos, wanted, args.compactFilenames),
93
159
  mimeType: "text/plain",
94
160
  content
95
161
  };
@@ -34,6 +34,7 @@ function activityScorer(rules) {
34
34
  return typeof rule === "function" ? rule(activatedRefs) : rule;
35
35
  }
36
36
  const freshRefRescuesActivation = rules.freshRefRescuesActivation ?? true;
37
+ const distinctCallsPerDay = rules.distinctCallsPerDay ?? false;
37
38
  const tracksActivation = rules.tracksActivation ?? true;
38
39
  const tracksOperators = rules.tracksOperators ?? true;
39
40
  const allowsMultiple = rules.allowsMultipleReferences ?? false;
@@ -125,16 +126,22 @@ function activityScorer(rules) {
125
126
  const now = { day, band, mode, refs: huntedRefs };
126
127
  const priors = sheet.workedByCall[call] ?? [];
127
128
  const verdict = judge(priors, now);
128
- const isDupe = verdict === "duplicate";
129
+ let isDupe = verdict === "duplicate";
129
130
  const freshRefOnly = verdict === "ref" && !freshRefRescuesActivation;
130
- const activationCredit = freshRefOnly ? 0 : activationValue;
131
+ let activationCredit = freshRefOnly ? 0 : activationValue;
132
+ if (distinctCallsPerDay && activationCredit > 0 && !isDupe) {
133
+ const days = sheet.activationCallDays ?? (sheet.activationCallDays = {});
134
+ activationCredit = days[call] === day ? 0 : 1;
135
+ days[call] = day;
136
+ if (activationCredit + huntingValue === 0 && !verdict) isDupe = true;
137
+ }
131
138
  const hasStake = activationValue + huntingValue > 0;
132
139
  const score = { value: isDupe ? 0 : activationCredit + huntingValue };
133
140
  if (!hasStake) {
134
141
  } else if (isDupe) {
135
142
  score.dupe = true;
136
143
  score.alerts = ["duplicate"];
137
- } else if (verdict) {
144
+ } else if (verdict && verdict !== "duplicate") {
138
145
  score.notices = [NOTICE_KEYS[verdict]];
139
146
  } else if (priors.length > 0 && score.value > 0 && freshRefRescuesActivation) {
140
147
  score.dupe = false;
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "adifForRef": "ADIF for {{program}} {{ref}}",
3
+ "adifForRefOnDay": "ADIF for {{program}} {{ref}} on {{day}}",
3
4
  "adifForHunter": "{{program}} Hunter Log"
4
5
  }
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "adifForRef": "ADIF para {{program}} {{ref}}",
3
+ "adifForRefOnDay": "ADIF para {{program}} {{ref}} del {{day}}",
3
4
  "adifForHunter": "Registro de Cazador {{program}}"
4
5
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 1.12.1
1
+ // @ham2k/extension-sdk 1.12.4
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -175,10 +175,18 @@ export interface PanelSceneSpectrum {
175
175
  display?: "both" | "pan" | "waterfall";
176
176
  /**
177
177
  * Which way the waterfall moves; the panadapter sits on the side it comes from. Frequency rises
178
- * left to right for `down` (the default) and `up`, bottom to top for `right` and `left`. Text
179
- * stays upright; offsets and `corner`s are measured as if flowing down.
178
+ * as `rising` says: by default left to right for `down` (the default) and `up`, bottom to top for
179
+ * `right` and top to bottom for `left` — `down` turned a quarter either way. Text stays upright;
180
+ * offsets and `corner`s are measured as if flowing down.
180
181
  */
181
182
  flow?: "down" | "up" | "right" | "left";
183
+ /**
184
+ * Which way frequency rises, across the `flow`: `right` or `left` for `down` and `up`, `down` or
185
+ * `up` for `right` and `left`. Omitted, `right` for `down` and `up`, `up` for `right`, `down` for
186
+ * `left`. Offsets, `align` and `corner`s are still measured as if frequency rose to the right.
187
+ * Extension API 12; before it, `left` rose bottom to top as `right` does.
188
+ */
189
+ rising?: "right" | "left" | "down" | "up";
182
190
  /** The panadapter's share of the height with `both`, 0.1–0.9. Default 0.4. */
183
191
  split?: number;
184
192
  /** dB added to the radio's own floor. */
@@ -222,7 +230,9 @@ export interface PanelSceneSpectrum {
222
230
  /**
223
231
  * Hz shown across the control, from the middle of each sweep. A sweep wider than this is cropped
224
232
  * to it, so a radio whose scope comes in a few widths can be shown at any narrower one — set the
225
- * scope (`setRadioSpectrum`) to the narrowest that covers it. Extension API 7.
233
+ * scope (`setRadioSpectrum`) to the narrowest that covers it. Where the radio's `spectrum` says
234
+ * `autoSpan`, the app sets the scope itself, wide enough for the widest `span` on screen.
235
+ * Extension API 7; `autoSpan` 12.
226
236
  */
227
237
  span?: PanelSceneSpectrumNumber;
228
238
  /** A click tunes to the nearest multiple of this many Hz. Omitted, wherever it lands. */
@@ -408,6 +418,12 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
408
418
  value: string;
409
419
  to: number;
410
420
  };
421
+ /**
422
+ * A `button` only: a click sends, as the event's `value`, where along the button's longer side it
423
+ * landed — 0 at its top or left end, 1 at its bottom or right — so a scale can be clicked at a
424
+ * place. Keys and screen readers activate it with no value, as does an app before this. Extension API 12.
425
+ */
426
+ tapAt?: boolean;
411
427
  /**
412
428
  * 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
413
429
  * keyboard focus or screen-reader node. Anything between is only drawn fainter.
@@ -1099,6 +1115,7 @@ export interface ExportResult {
1099
1115
  export interface ExportOptionsRequest {
1100
1116
  operation: Record<string, JSONValue>;
1101
1117
  qsos: Record<string, JSONValue>[];
1118
+ segments?: OperationSegmentPayload[];
1102
1119
  compactFilenames?: boolean;
1103
1120
  }
1104
1121
  export interface ExportOption {
@@ -1242,6 +1259,14 @@ export interface ChasingProgram {
1242
1259
  supersedes?: string[];
1243
1260
  refLabel?: string;
1244
1261
  imports?: ChasingImporter[];
1262
+ notices?: Partial<Record<ChaseNoticeVerdict, string>>;
1263
+ modeLabels?: Record<string, string>;
1264
+ translations?: Record<string, ChasingNoticeLabels>;
1265
+ }
1266
+ export type ChaseNoticeVerdict = "newRef" | "newBand" | "newMode" | "unconfirmed";
1267
+ export interface ChasingNoticeLabels {
1268
+ notices?: Partial<Record<ChaseNoticeVerdict, string>>;
1269
+ modeLabels?: Record<string, string>;
1245
1270
  }
1246
1271
  export interface ChasingImporter {
1247
1272
  key: string;
@@ -2090,13 +2115,24 @@ export interface PanelRadioSpectrum {
2090
2115
  mode: "center" | "fixed" | null;
2091
2116
  /** Half the width shown while centered — what the radio's menu shows as ±. */
2092
2117
  spanHz: number | null;
2093
- /** The edges while fixed. */
2118
+ /**
2119
+ * The edges shown: while fixed, and while centred where the radio places its scope apart from the
2120
+ * dial (a FlexRadio's panadapter, which a retune inside it does not move). Null where the scope
2121
+ * sits on the dial, `spanHz` either side of it.
2122
+ */
2094
2123
  lowHz: number | null;
2095
2124
  highHz: number | null;
2096
2125
  refLevelDb: number | null;
2097
2126
  speed: PanelRadioSpectrumSpeed | null;
2098
2127
  /** What `setRadioSpectrum` accepts. A span between two is snapped to the nearer. */
2099
2128
  spansHz: number[];
2129
+ /**
2130
+ * The app sets the span itself — a sweep it computes, whose width changes nothing on the radio
2131
+ * (a FlexRadio's) — to the narrowest covering the widest `span` of the spectrum controls shown,
2132
+ * each cropping its own width. A panel then names its width with `span` and leaves
2133
+ * `setRadioSpectrum`'s `spanHz` alone, or the panels take turns. Absent before extension API 12.
2134
+ */
2135
+ autoSpan?: boolean;
2100
2136
  refMinDb: number;
2101
2137
  refMaxDb: number;
2102
2138
  refStepDb: number;
@@ -2415,6 +2451,7 @@ export interface ActivityScoringRules {
2415
2451
  tracksActivation?: boolean;
2416
2452
  tracksOperators?: boolean;
2417
2453
  freshRefRescuesActivation?: boolean;
2454
+ distinctCallsPerDay?: boolean;
2418
2455
  allowsMultipleReferences?: boolean;
2419
2456
  uniquePer?: readonly UniqueAxis[];
2420
2457
  activates?: "daily" | "once";
@@ -2438,6 +2475,7 @@ export type ActivityScoresheet = {
2438
2475
  dayActivatedQsos: number;
2439
2476
  dayHuntedQsos: number;
2440
2477
  dayDuplicates: number;
2478
+ activationCallDays?: Record<string, number>;
2441
2479
  };
2442
2480
  export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
2443
2481
  export interface ActivityExportRules {
@@ -2448,10 +2486,11 @@ export interface ActivityExportRules {
2448
2486
  icon?: string;
2449
2487
  includeFieldsFrom?: string[];
2450
2488
  operatorFallback?: OperatorFallback;
2489
+ splitByUtcDay?: boolean;
2451
2490
  }
2452
2491
  export declare function activityExportHook(rules: ActivityExportRules): {
2453
2492
  getExportTypes(): Promise<ExportTypeDefinition[]>;
2454
- suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
2493
+ suggestExportOptions({ operation, qsos, segments, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
2455
2494
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
2456
2495
  };
2457
2496
  export interface HuntingExportRules {
@@ -417,12 +417,14 @@ can move it forward from there.
417
417
 
418
418
  Which ones ship is staged. Today it is the catalog twin of every extension the
419
419
  app used to build in (see [Retired built-ins](#retired-built-ins)), so that an
420
- install moving off them has every one to hand with no network. Once installs
421
- still on a build with the built-ins are few enough, it shrinks to the eight a
422
- new install needs — `ham2k-pota`, `ham2k-sota`, `ham2k-wwff` and the five
423
- lookups — and the rest are fetched from the catalog when switched on
420
+ install moving off them has every one to hand with no network, and
421
+ `ham2k-dxcc`, which replaced the core `dxcc` chasing program and is on for
422
+ everyone as that was. Once installs still on a build with the built-ins are few
423
+ enough, it shrinks to the nine a new install needs — `ham2k-pota`,
424
+ `ham2k-sota`, `ham2k-wwff`, `ham2k-dxcc` and the five lookups — and the rest
425
+ are fetched from the catalog when switched on
424
426
  ([HALO-743](https://cabo.ham2k.com/halo/c/743)). The app's own tests rely on
425
- only those eight, the core, and the never-shipped samples, so the shrink
427
+ only those nine, the core, and the never-shipped samples, so the shrink
426
428
  touches none of them.
427
429
 
428
430
  **They have to be kept current.** A pre-load is frozen into the binary, so
package/docs/hooks.md CHANGED
@@ -413,7 +413,7 @@ interface ExportHook {
413
413
  suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
414
414
  generateExport(args: ExportRequest, ctx): Promise<ExportResult>
415
415
  }
416
- // ExportOptionsRequest: {operation, qsos, compactFilenames?}
416
+ // ExportOptionsRequest: {operation, qsos, segments?, compactFilenames?}
417
417
  // ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, ref?, priority?, selectedByDefault?}
418
418
  // ExportRequest: {operation, qsos, segments?, events?, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
419
419
  // ExportResult: {filename, mimeType, content}
@@ -1607,7 +1607,14 @@ registerHook("chasing", { key: "dxcc", hook: {
1607
1607
  without invoking anything. Raise `version` whenever the same contacts would
1608
1608
  claim something different: the app keeps no claim from an older version.
1609
1609
  `refLabel` is what one reference is called where a notice names it
1610
- ("New POTA Park"), the program's `label` when left out.
1610
+ ("New POTA Park"), the program's `label` when left out. A program may
1611
+ instead word a verdict's notice itself: `notices` by verdict (`newRef`,
1612
+ `newBand`, `newMode`, `unconfirmed`), where `{{band}}` names the band a
1613
+ new band is on and `{{mode}}` the mode a new mode is in, through
1614
+ `modeLabels`, with both per language under `translations` (`{ es: {
1615
+ notices, modeLabels } }`). A verdict it leaves out, or one whose notice
1616
+ lacks the band or mode its words name, reads the app's way; a guess reads
1617
+ "Probably …" around the program's words.
1611
1618
  `supersedes` names the scoring notices the program's verdicts stand in for
1612
1619
  (DXCC: `newDXCC`): wherever the program judges a call or a spot, the
1613
1620
  scorer's notice is dropped in favor of the verdict.
@@ -1662,9 +1669,12 @@ extension API 9: a bundle that registers a program declares `"api": 9`, which
1662
1669
  the packer enforces, so an older app — which would install it and never ask
1663
1670
  it anything — is never offered it.
1664
1671
 
1665
- Implemented by: `dxcc` (core) — Mixed, per-band and per-mode slots for the
1666
- station call worked from each entity; LoTW and paper QSLs confirm, LoTW credit
1667
- verifies. `ham2k-pota` — one slot per park hunted, worked only; its
1672
+ Implemented by: `ham2k-dxcc` (catalog, pre-loaded; core `dxcc` until
1673
+ HALO-844) — Mixed, per-mode and per-band slots for the station call worked
1674
+ from each entity, worded "DXCC ATNO", "DXCC in CW", "DXCC in 80m"; LoTW and
1675
+ paper QSLs confirm, `credits.dxcc` verifies; the DXCC Challenge is a tally of
1676
+ its band slots. A stored goal for `dxcc` carries to it
1677
+ (`kRenamedChasePrograms`). `k2hrc-dxcc` is the SDK's worked example. `ham2k-pota` — one slot per park hunted, worked only; its
1668
1678
  hunted-parks export verifies. `ham2k-was` — Mixed, per-band and per-mode
1669
1679
  slots (plus FT8 and RTTY) for the station call worked from one 4-character
1670
1680
  grid; LoTW and paper QSLs confirm, `WAS` credit verifies.
@@ -1916,7 +1926,7 @@ the extension. `value` names what it shows and sets.
1916
1926
 
1917
1927
  | Kind | Binds to | Sends | Also takes |
1918
1928
  |---|---|---|---|
1919
- | `button` | — | `activate` | `menu` |
1929
+ | `button` | — | `activate` | `menu`, `tapAt` (extension API 12: a click's `value` is where along the button's longer side it landed, 0 at its top or left end to 1 at its bottom or right; a key or screen reader activates it with none) |
1920
1930
  | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous`, `scrollThrough` |
1921
1931
  | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `invertDrag`, `scrollThrough`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
1922
1932
  | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
@@ -2094,6 +2104,13 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
2094
2104
  (`both`, `pan`, `waterfall`), `split`, and `flow` — which way the waterfall
2095
2105
  moves (`down`, `up`, `right`, `left`), the panadapter on the side it comes
2096
2106
  from.
2107
+ - `rising` (extension API 12): which way frequency increases, across the
2108
+ flow — `right` or `left` for `down` and `up`, `down` or `up` for `right` and
2109
+ `left`; one along the flow is refused. Omitted, each flow is `down` turned a
2110
+ quarter, never mirrored: `right` for `down` and `up`, `up` for `right`,
2111
+ `down` for `left` (before API 12, `left` rose up the screen too). Text stays
2112
+ upright, and offsets, `align` and pointer corners are measured as if
2113
+ frequency rose to the right.
2097
2114
  - `floor`, `range`, `average` (0–99, default 40), `fps` (default 15),
2098
2115
  `waterfallSpeed`, `waterfallFloor`, `waterfallRange`: numbers, or the names
2099
2116
  of scene values the control reads on every sweep.
@@ -2111,7 +2128,10 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
2111
2128
  number or a scene value. A wider sweep is cropped to it, so a radio whose
2112
2129
  scope comes in a few fixed widths can be shown at any narrower one; set the
2113
2130
  scope to the narrowest that covers it. Everything is drawn, labelled and
2114
- clicked on the window shown.
2131
+ clicked on the window shown. Where the radio's `spectrum` says `autoSpan`
2132
+ (extension API 12), the app sets the scope itself, to the narrowest span
2133
+ covering the widest `span` among the spectrum controls on screen, so each
2134
+ panel names its width here and leaves `setRadioSpectrum`'s `spanHz` alone.
2115
2135
  - `follow` (0–0.45): keeps the tuned frequency at least this share of the
2116
2136
  width from either edge, by moving the radio's scope just far enough to put
2117
2137
  it on that margin — the picture scrolls with the dial rather than jumping
@@ -2347,7 +2367,13 @@ for a radio without a scope, otherwise its settings as the radio reports them
2347
2367
  (`mode`, `spanHz`, `lowHz`/`highHz`, `refLevelDb`, `speed`), what it accepts
2348
2368
  (`spansHz`, `refMinDb`…`refStepDb`, `modes`, `speeds`) and `problem`, why it
2349
2369
  cannot run, in the operator's language. The settings are known only while a
2350
- `spectrum` control has the scope running, and null otherwise.
2370
+ `spectrum` control has the scope running, and null otherwise. `autoSpan`
2371
+ (extension API 12) is true where the app sizes the scope for its displays — a
2372
+ sweep it computes, whose width changes nothing on the radio (a FlexRadio's DAX
2373
+ IQ): it runs at the narrowest span covering the widest spectrum control's
2374
+ `span` on screen, each control cropping its own width from it, and narrows
2375
+ again as wider ones go. A panel there names its width with `span`; setting
2376
+ `spanHz` too has the panels take turns.
2351
2377
  `host.setRadioSpectrum({id, selection?, mode?, spanHz?, refLevelDb?, speed?})`
2352
2378
  changes them; a span between two the radio offers is snapped to the nearer. It
2353
2379
  answers `stopped` when no control is running the scope: nothing is queued for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "1.12.1",
3
+ "version": "1.12.4",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
package/samples/README.md CHANGED
@@ -10,6 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
10
10
  | `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
11
11
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
12
12
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
13
+ | `k2hrc-dxcc` | A chasing program: what each contact counts toward, and how its notices read (extension API 9) |
13
14
  | `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
14
15
  | `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
15
16
  | `k2hrc-panafall` | A radio's panadapter and waterfall in a scene, styled and tuned by click (extension API 7) |
@@ -0,0 +1,6 @@
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
+
4
+ await buildExtension(build, { dir: import.meta.dirname })
5
+
6
+ console.log("built build/index.js")
@@ -0,0 +1,23 @@
1
+ {
2
+ "key": "k2hrc-dxcc",
3
+ "name": "DXCC (sample)",
4
+ "shortName": "DXCC*",
5
+ "version": "1.0.0",
6
+ "description": "The DXCC entities a station has worked and confirmed, as a worked example of a chasing program",
7
+ "category": "award",
8
+ "icon": "earth",
9
+ "accentColor": "#2E7D32",
10
+ "api": 9,
11
+ "keywords": ["award", "dxcc", "chasing", "sample"],
12
+ "hooks": ["chasing"],
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,104 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — a chasing program: what each contact counts toward.
5
+ //
6
+ // An award is a checklist, and a chasing program says which lines of it a
7
+ // contact fills. The app keeps the best claim for each line, ranks spots by
8
+ // what working the station would add, and alerts on what is new. The program
9
+ // never reads the log: `claimsForQsos` is a pure function of each contact,
10
+ // because the app also asks it about spots and about the call being typed.
11
+ //
12
+ // One contact makes several claims, one per kind of line the award keeps —
13
+ // here DXCC's three: Mixed (any band, any mode), its band, and its mode. A
14
+ // missing Mixed line is a new entity, a missing band or mode line a new band
15
+ // or mode, and `notices` says how each reads ("DXCC in 80m").
16
+ //
17
+ // The rules here are DXCC's, simplified: the catalog's `ham2k-dxcc` adds
18
+ // deleted entities, maritime mobile, satellites and the DXCC Challenge. See
19
+ // `docs/hooks.md` §`chasing`.
20
+
21
+ import { defineExtension, entityForCall } from "@ham2k/extension-sdk"
22
+ import type { ChasingHook, JSONValue, QsoClaim } from "@ham2k/extension-sdk"
23
+ import { parseCallsign } from "@ham2k/lib-callsigns"
24
+ import { ADIF_MODES_AND_SUBMODES, superModeForMode } from "@ham2k/lib-operation-data"
25
+
26
+ import manifest from "../manifest.json" with { type: "json" }
27
+
28
+ /// DXCC's three mode awards. `superModeForMode` calls anything it does not
29
+ /// know `DATA`, so a mode is checked against ADIF's list first.
30
+ const KNOWN_MODES = new Set(ADIF_MODES_AND_SUBMODES.map((mode) => mode.toUpperCase()))
31
+ const AWARD_MODES: Record<string, string> = { CW: "CW", PHONE: "PHONE", DATA: "DIGITAL" }
32
+
33
+ function obj(value: JSONValue | undefined): Record<string, JSONValue> {
34
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {}
35
+ }
36
+
37
+ /// Who the claim belongs to: an opaque string the program builds. DXCC goes
38
+ /// to a call, for contacts made from one entity — KI2D and KI2D/P share a
39
+ /// checklist, KI2D/VE2 keeps its own.
40
+ function holderFor(station: Record<string, JSONValue>): string | undefined {
41
+ if (typeof station.call !== "string" || !station.call) return undefined
42
+ const call = station.call.trim().toUpperCase()
43
+ const base = parseCallsign(call).baseCall || call
44
+ const entity = typeof station.dxccCode === "number" ? station.dxccCode : entityForCall(call).dxccCode
45
+ return entity == null ? base : `${base}@${entity}`
46
+ }
47
+
48
+ /// `worked` from the contact itself, `confirmed` by a QSL the award takes.
49
+ function levelOf(qso: Record<string, JSONValue>): Pick<QsoClaim, "level" | "by"> {
50
+ const qsl = obj(qso.qsl)
51
+ if (obj(qsl.lotw).received === true) return { level: "confirmed", by: "lotw" }
52
+ if (obj(qsl.qsl).received === true) return { level: "confirmed", by: "qsl" }
53
+ return { level: "worked" }
54
+ }
55
+
56
+ function claimsFor(qso: Record<string, JSONValue>): QsoClaim[] {
57
+ // An event marker's calls are placeholders, not stations.
58
+ if (qso.band === "event") return []
59
+ const their = obj(qso.their)
60
+ const holder = holderFor(obj(qso.our))
61
+ if (typeof their.call !== "string" || !holder) return []
62
+ const { dxccCode, entityName } = entityForCall(their.call)
63
+ if (dxccCode == null) return []
64
+
65
+ const base = { holder, ref: String(dxccCode), ...(entityName ? { label: entityName } : {}), ...levelOf(qso) }
66
+ const claims: QsoClaim[] = [{ ...base, band: null, mode: null }]
67
+ if (typeof qso.band === "string" && qso.band) claims.push({ ...base, band: qso.band, mode: null })
68
+ const mode = typeof qso.mode === "string" && KNOWN_MODES.has(qso.mode.toUpperCase()) ? AWARD_MODES[superModeForMode(qso.mode.toUpperCase())] : undefined
69
+ if (mode) claims.push({ ...base, band: null, mode })
70
+ return claims
71
+ }
72
+
73
+ const program: ChasingHook = {
74
+ // Plain data, read without calling anything. Raise `version` whenever the
75
+ // same contacts would claim something different.
76
+ program: {
77
+ label: "DXCC*",
78
+ icon: "earth",
79
+ version: 1,
80
+ editions: "lifetime",
81
+ notices: { newRef: "New DXCC* entity", newBand: "DXCC* on {{band}}", newMode: "DXCC* in {{mode}}" },
82
+ modeLabels: { PHONE: "Phone", DIGITAL: "Digital" },
83
+ },
84
+ // Keyed by each contact's uuid; one without a uuid has no key to answer under.
85
+ claimsForQsos: ({ qsos }) =>
86
+ Object.fromEntries(qsos.filter((qso) => typeof qso.uuid === "string" && qso.uuid).map((qso) => [qso.uuid as string, claimsFor(qso)])),
87
+ holdersFor: ({ station }) => {
88
+ const holder = holderFor(station)
89
+ return holder ? [holder] : []
90
+ },
91
+ // One tally per thing worth counting: entities, from their Mixed lines.
92
+ summarizeClaims: ({ slots }) => {
93
+ const entities = slots.filter((slot) => slot.band === null && slot.mode === null)
94
+ const confirmed = entities.filter((slot) => slot.level !== "worked").length
95
+ return { entities: { key: "entities", icon: "earth", label: `${entities.length} entities`, summary: `${confirmed} confirmed`, total: entities.length } }
96
+ },
97
+ }
98
+
99
+ defineExtension({
100
+ ...manifest,
101
+ onActivation({ registerHook }) {
102
+ registerHook("chasing", { hook: program, key: manifest.key })
103
+ },
104
+ })