@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.
@@ -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
+ };
@@ -26,6 +26,10 @@ function opValues(operation, extra = {}) {
26
26
  return {
27
27
  ...atMillis > 0 ? dateValues(atMillis) : { date: "", dateCompact: "", time: "", at: "" },
28
28
  station,
29
+ // `call` says the same thing, for symmetry with `qso.call` — the name an
30
+ // operator writing a CW message reaches for first; without it,
31
+ // `{{ op.call }}` renders empty with nothing to say why.
32
+ call: station,
29
33
  // Deduped, like halo_core's `Operation.stationCalls`: "N0DEV, N0DEV" is a
30
34
  // typo, and a template fanning out over this would otherwise repeat
31
35
  // itself once per typo.