@ham2k/extension-sdk 0.2.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/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
@@ -67,7 +67,3 @@ in, so your editor works with only this package installed.
67
67
 
68
68
  Install one only to `inline` it — to ship your own copy because you need a
69
69
  version the host does not carry.
70
-
71
- ## License
72
-
73
- 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.2.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
+ };
@@ -6,9 +6,39 @@ async function hostApi() {
6
6
  }
7
7
  const NEARBY_DELTA = 1.5;
8
8
  const MAX_SUGGESTIONS = 30;
9
+ const learnedPrefixes = /* @__PURE__ */ new Map();
10
+ async function learnedReferencePrefix(category, entityPrefix, fallback) {
11
+ const cacheKey = `${category}:${entityPrefix}`;
12
+ const cached = learnedPrefixes.get(cacheKey);
13
+ if (cached) return cached;
14
+ const host = await hostApi();
15
+ let rows = [];
16
+ try {
17
+ rows = await host.dbLookupSelectAll(category, "", entityPrefix);
18
+ } catch (e) {
19
+ host.log(`${category}: prefix lookup failed for ${entityPrefix}: ${e}`);
20
+ }
21
+ const counts = /* @__PURE__ */ new Map();
22
+ for (const row of rows) {
23
+ const prefix = row.key.split("-")[0];
24
+ if (prefix) counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
25
+ }
26
+ let best;
27
+ for (const [prefix, count] of counts) {
28
+ if (best === void 0 || count > (counts.get(best) ?? 0)) best = prefix;
29
+ }
30
+ if (best === void 0) return fallback;
31
+ learnedPrefixes.set(cacheKey, best);
32
+ return best;
33
+ }
9
34
  function normalize(ref) {
10
35
  return (ref ?? "").toUpperCase().trim();
11
36
  }
37
+ function looksLikeReference(text, referenceRegex) {
38
+ if (referenceRegex.test(text)) return true;
39
+ if (/\s/.test(text)) return false;
40
+ return /[0-9]/.test(text) || /[-/]/.test(text);
41
+ }
12
42
  function refsOfType(container, type) {
13
43
  return (container.refs ?? []).filter((r) => r.type === type && r.ref);
14
44
  }
@@ -36,8 +66,22 @@ function referenceActivity(config) {
36
66
  return null;
37
67
  }
38
68
  }
69
+ function undecorated(ref, reference, name) {
70
+ return {
71
+ ...ref,
72
+ ref: reference,
73
+ name,
74
+ program,
75
+ label: void 0,
76
+ shortLabel: void 0,
77
+ grid: void 0,
78
+ lat: void 0,
79
+ lon: void 0,
80
+ location: void 0
81
+ };
82
+ }
39
83
  function decorate(ref, reference, row, unknown) {
40
- if (!row?.name) return { ...ref, ref: reference, name: unknown, program };
84
+ if (!row?.name) return undecorated(ref, reference, unknown);
41
85
  const data = row.data ?? {};
42
86
  return {
43
87
  ...ref,
@@ -52,6 +96,16 @@ function referenceActivity(config) {
52
96
  shortLabel: `${config.label} ${reference}`
53
97
  };
54
98
  }
99
+ async function refListInput(refType, args, ctx) {
100
+ const extras = config.refInput ? await config.refInput(args, ctx) : {};
101
+ return {
102
+ kind: "refList",
103
+ refType,
104
+ placeholder: extras.placeholder ?? config.placeholder,
105
+ pattern: config.referenceRegex.source,
106
+ transforms: extras.transforms ?? config.transforms
107
+ };
108
+ }
55
109
  const refHandler = {
56
110
  async validateRef({ ref }, _ctx) {
57
111
  const normalized = normalize(ref.ref);
@@ -61,7 +115,7 @@ function referenceActivity(config) {
61
115
  const t = config.tFor(ctx);
62
116
  const reference = normalize(ref.ref);
63
117
  if (!config.referenceRegex.test(reference)) {
64
- return { ...ref, ref: reference, name: t("invalidReference") };
118
+ return undecorated(ref, reference, t("invalidReference"));
65
119
  }
66
120
  return decorate(ref, reference, await lookupReference(reference), t("unknownReference"));
67
121
  },
@@ -97,7 +151,7 @@ function referenceActivity(config) {
97
151
  }
98
152
  };
99
153
  const activityHook = {
100
- async operationControls(_args, ctx) {
154
+ async operationControls({ operation }, ctx) {
101
155
  return [
102
156
  {
103
157
  key: `${config.key}/activation`,
@@ -107,12 +161,7 @@ function referenceActivity(config) {
107
161
  order: 10,
108
162
  optionType: "optional",
109
163
  allowsMultiple,
110
- input: {
111
- kind: "refList",
112
- refType: config.activationType,
113
- placeholder: config.placeholder,
114
- pattern: config.referenceRegex.source
115
- }
164
+ input: await refListInput(config.activationType, { operation, side: "activation" }, ctx)
116
165
  }
117
166
  ];
118
167
  },
@@ -122,7 +171,7 @@ function referenceActivity(config) {
122
171
  // on whether the operation is activating — both write the same hunting
123
172
  // refs, and POTA's HaLo port already settled on the single control.
124
173
  ...config.huntingType ? {
125
- async loggingControls(_args, ctx) {
174
+ async loggingControls({ operation, qso }, ctx) {
126
175
  return [
127
176
  {
128
177
  key: `${config.key}/hunter`,
@@ -132,17 +181,12 @@ function referenceActivity(config) {
132
181
  order: 10,
133
182
  optionType: "optional",
134
183
  allowsMultiple: true,
135
- input: {
136
- kind: "refList",
137
- refType: config.huntingType,
138
- placeholder: config.placeholder,
139
- pattern: config.referenceRegex.source
140
- }
184
+ input: await refListInput(config.huntingType, { operation, qso, side: "hunting" }, ctx)
141
185
  }
142
186
  ];
143
187
  }
144
188
  } : {},
145
- async suggest({ location, searchTerm }, _ctx) {
189
+ async suggest({ location, searchTerm, scoped }, ctx) {
146
190
  const host = await hostApi();
147
191
  const rows = searchTerm ? await host.dbLookupSelectAll(category, searchTerm) : location ? await host.dbLookupSelectByLocation(category, location.lat, location.lon, NEARBY_DELTA) : [];
148
192
  const withDistance = rows.map((row) => ({
@@ -152,7 +196,7 @@ function referenceActivity(config) {
152
196
  if (!searchTerm) {
153
197
  withDistance.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
154
198
  }
155
- return withDistance.slice(0, MAX_SUGGESTIONS).map(({ row, distance }) => {
199
+ const suggestions = withDistance.slice(0, MAX_SUGGESTIONS).map(({ row, distance }) => {
156
200
  const data = row.data ?? {};
157
201
  return {
158
202
  type: config.activationType,
@@ -170,6 +214,31 @@ function referenceActivity(config) {
170
214
  allowsMultiple
171
215
  };
172
216
  });
217
+ const typed = scoped ? normalize(searchTerm) : "";
218
+ if (typed && looksLikeReference(typed, config.referenceRegex) && !suggestions.some((s) => s.ref === typed)) {
219
+ const t = config.tFor(ctx);
220
+ const valid = config.referenceRegex.test(typed);
221
+ const row = valid ? await lookupReference(typed) : null;
222
+ const data = row?.data ?? {};
223
+ const name = row?.name || (valid ? t("unknownReference") : t("invalidReference"));
224
+ if (suggestions.length >= MAX_SUGGESTIONS) suggestions.pop();
225
+ suggestions.unshift({
226
+ type: config.activationType,
227
+ ref: typed,
228
+ name,
229
+ grid: data.grid,
230
+ lat: row?.lat,
231
+ lon: row?.lon,
232
+ location: data.location,
233
+ program,
234
+ label: `${config.label} ${typed}: ${name}`,
235
+ shortLabel: `${config.label} ${typed}`,
236
+ distance: void 0,
237
+ relevance: 1,
238
+ allowsMultiple
239
+ });
240
+ }
241
+ return suggestions;
173
242
  }
174
243
  };
175
244
  function activationFields(operation) {
@@ -222,5 +291,7 @@ function referenceActivity(config) {
222
291
  return { refHandler, activityHook, adifFieldsHook, adifImportHook };
223
292
  }
224
293
  export {
294
+ learnedReferencePrefix,
295
+ looksLikeReference,
225
296
  referenceActivity
226
297
  };
package/dist/scoring.js CHANGED
@@ -16,6 +16,9 @@ function startAtMillisOf(qso) {
16
16
  const at = qso.startAtMillis;
17
17
  return typeof at === "number" ? at : 0;
18
18
  }
19
+ function candidateAsOfNow(qso) {
20
+ return typeof qso.startAtMillis === "number" ? qso : { ...qso, startAtMillis: Date.now() };
21
+ }
19
22
  function contestScorer(scorer, options = {}) {
20
23
  const scope = options.scope ?? "always";
21
24
  const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
@@ -118,10 +121,10 @@ function contestScorer(scorer, options = {}) {
118
121
  async scoreQso(args, ctx) {
119
122
  const sheet = await liveScoresheet(args, ctx, args.excludeUuid);
120
123
  const { score } = scorer.scoreQso(
121
- { scoresheet: sheet, qso: args.qso, operation: args.operation, ref: args.ref, isNewDay: false },
124
+ { scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false },
122
125
  ctx
123
126
  );
124
- return { notices: score.notices ?? [], alerts: score.alerts ?? [] };
127
+ return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
125
128
  },
126
129
  /// The same live judgement as `scoreQso`, for many candidates at once —
127
130
  /// the Spots Panel annotating every spot on screen. The log is folded ONCE
@@ -141,14 +144,18 @@ function contestScorer(scorer, options = {}) {
141
144
  const { score } = scorer.scoreQso(
142
145
  {
143
146
  scoresheet: copyCheckpoint(base),
144
- qso: candidate.qso,
147
+ qso: candidateAsOfNow(candidate.qso),
145
148
  operation: args.operation,
146
149
  ref: args.ref,
147
150
  isNewDay: false
148
151
  },
149
152
  ctx
150
153
  );
151
- result[candidate.key] = { notices: score.notices ?? [], alerts: score.alerts ?? [] };
154
+ result[candidate.key] = {
155
+ notices: score.notices ?? [],
156
+ alerts: score.alerts ?? [],
157
+ ...score.dupe !== void 0 && { dupe: score.dupe }
158
+ };
152
159
  }
153
160
  return result;
154
161
  }
@@ -0,0 +1,17 @@
1
+ function operationForQso(base, qso, segments) {
2
+ if (!segments || segments.length === 0) return base;
3
+ const at = typeof qso.startAtMillis === "number" ? qso.startAtMillis : 0;
4
+ let effective = base;
5
+ for (const segment of segments) {
6
+ if (segment.fromMillis > at) break;
7
+ effective = segment.operation;
8
+ }
9
+ return effective;
10
+ }
11
+ function segmentsWith(segments, transform) {
12
+ return segments?.map((segment) => ({ ...segment, operation: transform(segment.operation) }));
13
+ }
14
+ export {
15
+ operationForQso,
16
+ segmentsWith
17
+ };