@ham2k/extension-sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,139 @@
1
+ # Writing a Ham2K Logger extension
2
+
3
+ *This file describes the published `@ham2k/extension-sdk` package, as installed
4
+ in an extension project's `node_modules`. Working inside the Ham2K Logger
5
+ repository itself is a different setup — see `docs/extensions/README.md` there.*
6
+
7
+ You are building an extension for the Ham2K Logger — a TypeScript module the
8
+ app loads into a sandbox and calls through **hooks**. Extensions are where the
9
+ app's domain knowledge lives: award programs, contests, callsign lookups, spot
10
+ sources, exporters, panels.
11
+
12
+ Everything you need is in this package. `samples/` holds one worked extension
13
+ per shape — start there, with the one whose shape matches what you are
14
+ building. `docs/` is the reference, pinned to the version of the SDK installed
15
+ here; `dist/index.d.ts` is the typed surface, and is the final word wherever
16
+ prose and types disagree.
17
+
18
+ Paths like `app/lib/config/secret_policy.dart` or `app/experiments.yaml`,
19
+ mentioned in those docs, are files in the Ham2K Logger's own repository
20
+ (<https://github.com/ham2k/halo>). They are not part of your project and you
21
+ cannot read them here.
22
+
23
+ ## The shape of a project
24
+
25
+ ```
26
+ manifest.json your extension's public identity — read without running code
27
+ src/index.ts imports the manifest, calls defineExtension
28
+ build.mjs runs @ham2k/extension-tools' build preset
29
+ build/ generated: index.js + manifest.json
30
+ ```
31
+
32
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes all of it.
33
+
34
+ ## The smallest whole extension
35
+
36
+ ```ts
37
+ import { defineExtension } from '@ham2k/extension-sdk'
38
+ import type { PanelHook } from '@ham2k/extension-sdk'
39
+
40
+ import manifest from '../manifest.json'
41
+
42
+ const HelloPanel: PanelHook = {
43
+ async getPanels() {
44
+ return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
45
+ },
46
+ async render() {
47
+ return { kind: 'markdown', content: '# Hello, World!' }
48
+ },
49
+ }
50
+
51
+ defineExtension({
52
+ ...manifest,
53
+ onActivation({ registerHook }) {
54
+ registerHook('panel', { key: manifest.key, hook: HelloPanel })
55
+ },
56
+ })
57
+ ```
58
+
59
+ `manifest.json` needs at least `key`, `name`, `version`, `description`,
60
+ `category`, `api`, and the `sharedDependencies` below. The manifest is spread
61
+ into `defineExtension` rather than retyped: two sources for the same identity
62
+ drift, and the packer reads the file.
63
+
64
+ ## Building and shipping
65
+
66
+ ```sh
67
+ node build.mjs # → build/index.js
68
+ npx -p @ham2k/extension-tools h2kext-pack build -o my-extension.h2kext
69
+ ```
70
+
71
+ The packer is also the checker. It reports every problem at once and refuses
72
+ anything that would install cleanly and then misbehave where you cannot watch
73
+ it, so a clean pack is the strongest evidence available outside the app itself.
74
+ Run it whenever you have changed the manifest or the hooks you register.
75
+
76
+ `tsc --noEmit` is the other half: the hook interfaces are typed, and a hook that
77
+ typechecks is registered under a category the host knows, with the methods it
78
+ will actually call.
79
+
80
+ ## Where to read next
81
+
82
+ | Doing this | Read |
83
+ | --- | --- |
84
+ | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel) |
85
+ | Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
86
+ | A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
87
+ | Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
88
+ | Contest or award scoring, and the UI an activity contributes | `docs/hooks.md` §`scoring`, §`activity` |
89
+ | A settings screen for your extension | `docs/settings.md` |
90
+ | Any form, dialog or field the user fills in | `docs/forms.md` |
91
+ | Panel text, export filenames, ADIF fields — anything with `{{ }}` in it | `docs/templates.md` |
92
+ | Packaging, the `.h2kext` format, keys, using the host's libraries | `docs/distribution.md` |
93
+
94
+ `docs/hooks.md` is long. Read the section for the category you are registering,
95
+ not the file.
96
+
97
+ ## What the sandbox is
98
+
99
+ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
100
+ evaluating one script. Reach the outside world through `host`:
101
+
102
+ ```ts
103
+ import { host } from '@ham2k/extension-sdk'
104
+
105
+ await host.fetch(url) // https only, and only domains your manifest lists
106
+ await host.kvGet('key') // storage, namespaced to your extension
107
+ await host.kvSet('key', value)
108
+ await host.showForm(form) // ask the user something
109
+ await host.showMessage({ … }) // tell them something
110
+ host.log('…') // the app's console
111
+ ```
112
+
113
+ `dist/index.d.ts` has the rest, with the types.
114
+
115
+ ## Traps worth knowing before you write anything
116
+
117
+ - **A panel returns content, not widgets.** `{ kind: 'markdown', content }` and
118
+ its siblings. There is no way to reach the app's UI toolkit, by design.
119
+ - **`host.fetch` only reaches domains your manifest's `domains` lists**, over
120
+ https. An undeclared host is refused at the call, not at build.
121
+ - **Using a shared library without declaring it fails the build.** The app
122
+ carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
123
+ `sharedDependencies` rewrites your imports into lookups on the app's single
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
126
+ version the app does not carry. A scaffolded manifest already declares the
127
+ eight this SDK's own modules reach — importing anything from it pulls them
128
+ in — so deleting one from the manifest breaks a build that was working.
129
+ - **Your key is `<callsign>-<name>`**, lowercase. It namespaces your storage and
130
+ is what the app shows when it says where something came from. `ham2k-` is
131
+ reserved.
132
+ - **`host.secret()` returns null** for anything not built into the app. Degrade;
133
+ don't throw. A hook that throws takes its whole feature down.
134
+ - **Hook method names are routed by string.** The host finds your hook by its
135
+ registered key and then calls the method by name, so `renderPanel` where
136
+ `render` was meant is an extension that installs, activates, and does nothing.
137
+ This is what typing your hook as `PanelHook` (or its siblings) catches.
138
+ - **Every hook is async and crosses a bridge.** Some context reads carry a whole
139
+ log — fine when exporting, not on a per-keystroke path.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sebastian Delmont <sd@ham2k.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -26,7 +26,7 @@ const HelloPanel: PanelHook = {
26
26
  defineExtension({
27
27
  ...manifest,
28
28
  onActivation({ registerHook }) {
29
- registerHook('panel', { key: 'ki2d-hello-world', hook: HelloPanel })
29
+ registerHook('panel', { key: manifest.key, hook: HelloPanel })
30
30
  },
31
31
  })
32
32
  ```
@@ -34,9 +34,24 @@ defineExtension({
34
34
  A panel returns *content*, not widgets — which is what keeps extensions out of
35
35
  the app's UI toolkit.
36
36
 
37
- `@ham2k/extension-tools` builds and packages it into a `.h2kext` the app can
38
- install. See [the extension docs](https://github.com/ham2k/halo/tree/main/docs/extensions)
39
- for hooks, forms, templates and distribution.
37
+ `npx -p @ham2k/extension-tools h2kext-init <yourcallsign>-<name>` writes
38
+ all of that and the build script; the same package packages the result into a
39
+ `.h2kext` the app can install.
40
+
41
+ ## The reference is in this package
42
+
43
+ `samples/` holds one worked extension per shape — a callsign lookup with the
44
+ credentials it needs, an award program, a contest and its scorer, an HTML
45
+ panel. Each builds and packs; each says in its header what it leaves out.
46
+
47
+ `AGENTS.md` beside this file is the map — the manifest, the sandbox's limits,
48
+ and which of `docs/` answers what. `docs/` holds the references themselves:
49
+ every hook category, forms, settings panels, templates, and the bundle format.
50
+
51
+ They travel in the package rather than being linked to, so they are readable
52
+ offline and describe the version installed here rather than whatever the
53
+ repository says today. If an agent is writing your extension, point it at
54
+ `node_modules/@ham2k/extension-sdk/AGENTS.md` — `h2kext-init` does that for you.
40
55
 
41
56
  ## About the peer dependencies
42
57
 
@@ -52,7 +67,3 @@ in, so your editor works with only this package installed.
52
67
 
53
68
  Install one only to `inline` it — to ship your own copy because you need a
54
69
  version the host does not carry.
55
-
56
- ## License
57
-
58
- MPL-2.0
@@ -1,4 +1,5 @@
1
1
  import { exportFilename, startMillisOf } from "./exportNames.js";
2
+ import { segmentsWith } from "./segments.js";
2
3
  import { createCachedTranslator } from "./i18n.js";
3
4
  import en from "./i18n/activityExports.en.json";
4
5
  import es from "./i18n/activityExports.es.json";
@@ -50,13 +51,16 @@ function activityExportHook(rules) {
50
51
  if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
51
52
  throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
52
53
  }
53
- const refs = (args.operation.refs ?? []).filter(
54
- (r) => r?.type !== rules.activationType || r?.ref === wanted
55
- );
56
- const operation = { ...args.operation, refs };
54
+ const claimOnly = (operation) => ({
55
+ ...operation,
56
+ refs: (operation.refs ?? []).filter(
57
+ (r) => r?.type !== rules.activationType || r?.ref === wanted
58
+ )
59
+ });
57
60
  const content = await adifForExport({
58
- operation,
61
+ operation: claimOnly(args.operation),
59
62
  qsos: args.qsos,
63
+ segments: segmentsWith(args.segments, claimOnly),
60
64
  includePrivateData: args.includePrivateData,
61
65
  mainHandler: rules.key,
62
66
  includeFieldsFrom: rules.includeFieldsFrom
@@ -113,6 +117,7 @@ function huntingExportHook(rules) {
113
117
  const content = await adifForExport({
114
118
  operation: args.operation,
115
119
  qsos: huntedQsos(args.qsos ?? []),
120
+ segments: args.segments,
116
121
  includePrivateData: args.includePrivateData,
117
122
  // The hunter file is submitted to ONE program too — see
118
123
  // `activityExportHook.generateExport`. `rules.key` is the ACTIVITY's
@@ -131,11 +136,12 @@ function huntingExportHook(rules) {
131
136
  }
132
137
  };
133
138
  }
134
- async function adifForExport({ operation, qsos, includePrivateData, mainHandler, includeFieldsFrom }) {
139
+ async function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }) {
135
140
  const { hooks } = await import("@ham2k/extension-sdk");
136
141
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
137
142
  operation,
138
143
  qsos,
144
+ segments,
139
145
  exportType: "adif",
140
146
  // Forwarded, or the delegate defaults to withholding and the user's
141
147
  // "include private info" setting silently does nothing here.
@@ -127,6 +127,8 @@ function activityScorer(rules) {
127
127
  score.alerts = ["duplicate"];
128
128
  } else if (verdict) {
129
129
  score.notices = [NOTICE_KEYS[verdict]];
130
+ } else if (priors.length > 0 && score.value > 0 && freshRefRescuesActivation) {
131
+ score.dupe = false;
130
132
  }
131
133
  const packed = packContact(day, band, mode, huntedRefs);
132
134
  if (priors.length > 0) priors.push(packed);
package/dist/dxcc.js CHANGED
@@ -2,8 +2,8 @@ import { parseCallsign } from "@ham2k/lib-callsigns";
2
2
  import { annotateFromCountryFile, useBuiltinCountryFile } from "@ham2k/lib-country-files";
3
3
  import { DXCC_BY_CODE } from "@ham2k/lib-dxcc-data";
4
4
  useBuiltinCountryFile();
5
- function annotateCallAgainstCountryFile(call) {
6
- return annotateFromCountryFile(parseCallsign(call));
5
+ function annotateCallAgainstCountryFile(call, options) {
6
+ return annotateFromCountryFile(parseCallsign(call), options);
7
7
  }
8
8
  function entityPrefixForCall(call) {
9
9
  if (!call) return void 0;
@@ -17,8 +17,21 @@ function regionAndCountryForDxccCode(dxccCode) {
17
17
  const entity = DXCC_BY_CODE[dxccCode];
18
18
  return { ituRegion: entity?.ituRegion, countryCode: entity?.countryCode };
19
19
  }
20
+ function countryPrefixForCall(call) {
21
+ if (!call) return void 0;
22
+ const annotated = annotateCallAgainstCountryFile(call);
23
+ const { countryCode } = regionAndCountryForDxccCode(annotated.dxccCode);
24
+ return countryCode ? countryCode.toUpperCase() : annotated.entityPrefix;
25
+ }
26
+ function entityForCall(call) {
27
+ if (!call) return {};
28
+ const { dxccCode, continent, entityName } = annotateCallAgainstCountryFile(call);
29
+ return { dxccCode, continent, entityName };
30
+ }
20
31
  export {
21
32
  annotateCallAgainstCountryFile,
33
+ countryPrefixForCall,
34
+ entityForCall,
22
35
  entityPrefixForCall,
23
36
  flagForDxccCode,
24
37
  regionAndCountryForDxccCode
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.1.0
1
+ // @ham2k/extension-sdk 0.3.0
2
2
  export type CallInfo = {
3
3
  call: string;
4
4
  baseCall?: string;
@@ -103,8 +103,14 @@ export interface HookContext {
103
103
  distanceUnits?: string;
104
104
  decimalSeparator?: string;
105
105
  groupingSeparator?: string;
106
+ gridPrecision?: number;
106
107
  edition?: string;
107
108
  enabledExperiments?: string[];
109
+ experiments?: {
110
+ key: string;
111
+ name: string;
112
+ aliases: string[];
113
+ }[];
108
114
  getOperation?(uuid: string): Promise<Record<string, JSONValue> | null>;
109
115
  getQsos?(operationUuid: string): Promise<Record<string, JSONValue>[] | null>;
110
116
  getSpotsForCall?(call: string, maxAgeMinutes?: number): Promise<Spot[]>;
@@ -275,6 +281,7 @@ export interface SerialInputDescriptor {
275
281
  export interface FormInputDescriptor {
276
282
  kind: "form";
277
283
  refType: string;
284
+ alsoHandles?: string[];
278
285
  form: FormDefinition;
279
286
  }
280
287
  export interface TextInputDescriptor {
@@ -304,6 +311,10 @@ export interface OptionsInputDescriptor {
304
311
  matchOn?: ("code" | "name")[];
305
312
  allowFreeform?: boolean;
306
313
  preferredCodes?: string[];
314
+ multiValue?: {
315
+ separator: string;
316
+ inheritPrefix?: number;
317
+ };
307
318
  minCharsForSuggestions?: number;
308
319
  placeholder?: string;
309
320
  uppercase?: boolean;
@@ -380,6 +391,7 @@ export interface AdifImportHook {
380
391
  export interface ExportRequest {
381
392
  operation: Record<string, JSONValue>;
382
393
  qsos: Record<string, JSONValue>[];
394
+ segments?: OperationSegmentPayload[];
383
395
  exportType?: string;
384
396
  compactFilenames?: boolean;
385
397
  includePrivateData?: boolean;
@@ -464,6 +476,7 @@ export interface ScoreQsoRequest {
464
476
  export interface QsoScoreNotices {
465
477
  notices: string[];
466
478
  alerts: string[];
479
+ dupe?: boolean;
467
480
  }
468
481
  export interface ScoreCandidatesRequest {
469
482
  operation: Record<string, JSONValue>;
@@ -517,6 +530,10 @@ export interface CommandAction {
517
530
  setPower?: {
518
531
  watts: number;
519
532
  };
533
+ setKeyerSpeed?: {
534
+ wpm: number;
535
+ };
536
+ showKeyer?: Record<string, never>;
520
537
  updateQso?: Record<string, JSONValue>;
521
538
  addQso?: Record<string, JSONValue>;
522
539
  sendSpots?: {
@@ -540,6 +557,10 @@ export interface CommandAction {
540
557
  operatorCall?: string;
541
558
  multiIdentifier?: string;
542
559
  };
560
+ markSegment?: {
561
+ kind: "start" | "break" | "stop";
562
+ setLocation?: boolean;
563
+ };
543
564
  setCallField?: {
544
565
  text: string;
545
566
  };
@@ -575,6 +596,9 @@ export interface CommandAction {
575
596
  deltaMillis?: number;
576
597
  speed?: number;
577
598
  };
599
+ devBench?: {
600
+ qsoCount?: number;
601
+ };
578
602
  toggleExperiment?: {
579
603
  token: string;
580
604
  };
@@ -723,6 +747,14 @@ export interface ActivationApi {
723
747
  registerHook(category: string, params: RegisterHookParams): void;
724
748
  hostCall(method: string, params: Record<string, unknown>): Promise<unknown>;
725
749
  }
750
+ export interface ExtensionRelevance {
751
+ entities?: string[];
752
+ countries?: string[];
753
+ continents?: string[];
754
+ ituRegions?: number[];
755
+ dates?: string[];
756
+ interests?: string[];
757
+ }
726
758
  export interface ExtensionManifest {
727
759
  key: string;
728
760
  name: string;
@@ -739,6 +771,7 @@ export interface ExtensionManifest {
739
771
  allowUserAgentOverride?: boolean;
740
772
  requiresLocation?: boolean;
741
773
  experiments?: string[];
774
+ relevance?: ExtensionRelevance;
742
775
  translations?: Record<string, {
743
776
  name?: string;
744
777
  description?: string;
@@ -883,7 +916,7 @@ export interface FormHeader {
883
916
  title: string;
884
917
  subtitle?: string;
885
918
  devMode?: boolean;
886
- style?: "default" | "section";
919
+ style?: "default" | "section" | "category";
887
920
  environment?: string | string[];
888
921
  }
889
922
  export interface FormSectionDivider {
@@ -1129,13 +1162,17 @@ type AnnotatedCallInfo$1 = CombinedCallInfo & {
1129
1162
  originalValues?: AnnotatedCallInfo$1;
1130
1163
  cfValues?: AnnotatedCallInfo$1;
1131
1164
  };
1132
- export declare function annotateCallAgainstCountryFile(call: string): AnnotatedCallInfo$1;
1165
+ export declare function annotateCallAgainstCountryFile(call: string, options?: {
1166
+ wae?: boolean;
1167
+ }): AnnotatedCallInfo$1;
1133
1168
  export declare function entityPrefixForCall(call: string | undefined): string | undefined;
1134
1169
  export declare function flagForDxccCode(dxccCode: number | undefined): string | undefined;
1135
1170
  export declare function regionAndCountryForDxccCode(dxccCode: number | undefined): {
1136
1171
  ituRegion?: number;
1137
1172
  countryCode?: string;
1138
1173
  };
1174
+ export declare function countryPrefixForCall(call: string | undefined): string | undefined;
1175
+ export declare function entityForCall(call: string | undefined): Pick<AnnotatedCallInfo$1, "dxccCode" | "continent" | "entityName">;
1139
1176
  export declare const LOCATION_ACCURACY: {
1140
1177
  readonly EXACT: 0;
1141
1178
  readonly ACCURATE: 2;
@@ -1149,6 +1186,14 @@ export declare function applyLatLonFromApiFields(target: {
1149
1186
  }, raw: Record<string, unknown>): void;
1150
1187
  export declare function canonicalMode(mode: string): string;
1151
1188
  export declare function modesMatchForDupe(worked: string, candidate: string): boolean;
1189
+ export declare function expectedSideband({ freqKHz, band }: {
1190
+ freqKHz?: number;
1191
+ band?: string;
1192
+ }): string | undefined;
1193
+ export declare function isUnexpectedSideband(mode: string | undefined | null, { freqKHz, band }: {
1194
+ freqKHz?: number;
1195
+ band?: string;
1196
+ }): boolean;
1152
1197
  export interface ContestScorerOptions {
1153
1198
  scope?: ScoringScope;
1154
1199
  }
@@ -1156,6 +1201,8 @@ export declare function contestScorer<S extends Scoresheet = Scoresheet>(scorer:
1156
1201
  scope: ScoringScope;
1157
1202
  };
1158
1203
  export declare function tally(key: string, forScope: "day" | "operation", total: number, extra?: Partial<ScoreTally>): ScoreTally;
1204
+ export declare function operationForQso(base: Record<string, JSONValue>, qso: Record<string, JSONValue>, segments?: OperationSegmentPayload[]): Record<string, JSONValue>;
1205
+ export declare function segmentsWith(segments: OperationSegmentPayload[] | undefined, transform: (operation: Record<string, JSONValue>) => Record<string, JSONValue>): OperationSegmentPayload[] | undefined;
1159
1206
  export type UniqueAxis = "day" | "band" | "mode" | "ref";
1160
1207
  export interface ActivityScoringRules {
1161
1208
  label: string;
@@ -1210,9 +1257,10 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
1210
1257
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1211
1258
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1212
1259
  };
1213
- export declare function adifForExport({ operation, qsos, includePrivateData, mainHandler, includeFieldsFrom }: {
1260
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }: {
1214
1261
  operation: Record<string, JSONValue>;
1215
1262
  qsos: Record<string, JSONValue>[];
1263
+ segments?: OperationSegmentPayload[];
1216
1264
  includePrivateData?: boolean;
1217
1265
  mainHandler: string;
1218
1266
  includeFieldsFrom?: string[];
@@ -1236,6 +1284,12 @@ export interface ExportNameParts {
1236
1284
  }
1237
1285
  export declare function exportFilename(parts: ExportNameParts): string;
1238
1286
  export declare function startMillisOf(operation: Record<string, JSONValue>, qsos?: Record<string, JSONValue>[]): number | undefined;
1287
+ export type RefTransform = {
1288
+ pattern: string;
1289
+ replacement: string;
1290
+ flags?: string;
1291
+ };
1292
+ export declare function transformsForPrefix(prefix: string): RefTransform[];
1239
1293
  export interface ReferenceActivityConfig {
1240
1294
  key: string;
1241
1295
  label: string;
@@ -1254,7 +1308,20 @@ export interface ReferenceActivityConfig {
1254
1308
  adifRefField?: string;
1255
1309
  allowsMultiple?: boolean;
1256
1310
  linkUrl?: (reference: string) => string;
1311
+ transforms?: RefTransform[];
1312
+ refInput?: (args: RefInputArgs, ctx: HookContext) => Promise<RefInputExtras> | RefInputExtras;
1313
+ }
1314
+ export interface RefInputArgs {
1315
+ operation: Record<string, JSONValue>;
1316
+ qso?: Record<string, JSONValue>;
1317
+ side: "activation" | "hunting";
1318
+ }
1319
+ export interface RefInputExtras {
1320
+ placeholder?: string;
1321
+ transforms?: RefTransform[];
1257
1322
  }
1323
+ export declare function learnedReferencePrefix(category: string, entityPrefix: string, fallback: string): Promise<string>;
1324
+ export declare function looksLikeReference(text: string, referenceRegex: RegExp): boolean;
1258
1325
  export interface ReferenceActivityHooks {
1259
1326
  refHandler: Record<string, unknown>;
1260
1327
  activityHook: Record<string, unknown>;
package/dist/index.js CHANGED
@@ -1,17 +1,19 @@
1
- export * from "./types";
2
- export * from "./i18n";
3
- export * from "./dxcc";
4
- export * from "./location";
5
- export * from "./modes";
6
- export * from "./scoring";
7
- export * from "./activityScoring";
8
- export * from "./activityExports";
9
- export * from "./activityAdifImport";
10
- export * from "./exportNames";
11
- export * from "./referenceActivity";
12
- export * from "./templates";
13
- export * from "./templateContext";
14
- export * from "./testOperation";
1
+ export * from "./types.js";
2
+ export * from "./i18n.js";
3
+ export * from "./dxcc.js";
4
+ export * from "./location.js";
5
+ export * from "./modes.js";
6
+ export * from "./scoring.js";
7
+ export * from "./segments.js";
8
+ export * from "./activityScoring.js";
9
+ export * from "./activityExports.js";
10
+ export * from "./activityAdifImport.js";
11
+ export * from "./exportNames.js";
12
+ export * from "./referenceActivity.js";
13
+ export * from "./refTransforms.js";
14
+ export * from "./templates.js";
15
+ export * from "./templateContext.js";
16
+ export * from "./testOperation.js";
15
17
  let currentExtensionKey = "unknown";
16
18
  const privilegedHostCalls = {};
17
19
  function kernel() {
package/dist/modes.js CHANGED
@@ -22,7 +22,27 @@ function modesMatchForDupe(worked, candidate) {
22
22
  const superMode = COARSE_SUPER_MODES[c];
23
23
  return Boolean(superMode) && KNOWN_MODES.has(w) && superModeForMode(w) === superMode;
24
24
  }
25
+ const LSB_BANDS = /* @__PURE__ */ new Set(["2190m", "2200m", "630m", "560m", "160m", "80m", "75m", "40m"]);
26
+ const UPPER_SIDEBAND_FLOOR_KHZ = 9e3;
27
+ const SIXTY_METRE_LO_KHZ = 5e3;
28
+ const SIXTY_METRE_HI_KHZ = 6e3;
29
+ function expectedSideband({ freqKHz, band }) {
30
+ if (freqKHz != null) {
31
+ if (freqKHz >= UPPER_SIDEBAND_FLOOR_KHZ) return "USB";
32
+ return freqKHz >= SIXTY_METRE_LO_KHZ && freqKHz < SIXTY_METRE_HI_KHZ ? "USB" : "LSB";
33
+ }
34
+ if (!band) return void 0;
35
+ return LSB_BANDS.has(band) ? "LSB" : "USB";
36
+ }
37
+ function isUnexpectedSideband(mode, { freqKHz, band }) {
38
+ const upper = (mode ?? "").toUpperCase();
39
+ if (upper !== "USB" && upper !== "LSB") return false;
40
+ const expected = expectedSideband({ freqKHz, band });
41
+ return expected !== void 0 && upper !== expected;
42
+ }
25
43
  export {
26
44
  canonicalMode,
45
+ expectedSideband,
46
+ isUnexpectedSideband,
27
47
  modesMatchForDupe
28
48
  };
@@ -0,0 +1,34 @@
1
+ function transformsForPrefix(prefix) {
2
+ return [
3
+ // a run of spaces between two refs -> ", " (normalize to one separator style)
4
+ { pattern: "([A-Z0-9]-\\d+|TEST) +(?=[A-Z0-9])", replacement: "${1}, ", flags: "gi" },
5
+ // bare number (or "TEST"), at the start or right after a separator -> prefix-number
6
+ // (the separator itself is captured and replayed, not consumed, so a
7
+ // pasted "US-1234,5678" keeps its comma instead of merging into one ref).
8
+ // Braced `${1}`/`${2}`, not bare `$1`/`$2` — `prefix` can itself start
9
+ // with a digit (e.g. DXCC "3D2" for Fiji), and a bare "$1" immediately
10
+ // followed by that would misparse as capture group 13.
11
+ { pattern: "(^|,\\s*)(\\d\\d+|TEST)", replacement: `\${1}${prefix}-\${2}`, flags: "gi" },
12
+ // "US1234" -> "US-1234". Anchored with a negative lookbehind so it only
13
+ // fires at the START of a not-yet-dashed reference — without it, a
14
+ // multi-character DXCC prefix that mixes letters and digits (e.g.
15
+ // Fiji's "3D2") gets its OWN internal "D2" mismatched as an undashed
16
+ // reference once transform 2 above has already inserted "3D2-1234",
17
+ // corrupting it into "3D-2-1234".
18
+ { pattern: "(?<![A-Z0-9])([A-Z]+)(\\d+|TEST)", replacement: "${1}-${2}", flags: "gi" },
19
+ // NOTE: deliberately no "eagerly insert the next prefix right after a
20
+ // trailing comma" rule here (app-polo's inputs have one) — transform 2
21
+ // above already prefixes a second reference once 2+ digits appear after
22
+ // the comma, the same way it does at the start of the field. An eager
23
+ // version that inserted "US-" the instant a trailing comma appeared
24
+ // makes deleting back through a second reference impossible: backspacing
25
+ // "US-1234,US-5678" down to "US-1234,US-" and then to "US-1234," would
26
+ // immediately re-grow the trailing "US-1234,US-" the transform had just
27
+ // reinserted, so the comma itself could never be reached.
28
+ // strip anything that can't appear in a reference list
29
+ { pattern: "[^A-Z0-9\\-, ]", replacement: "", flags: "gi" }
30
+ ];
31
+ }
32
+ export {
33
+ transformsForPrefix
34
+ };