@ham2k/extension-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,231 @@
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";
15
+ let currentExtensionKey = "unknown";
16
+ const privilegedHostCalls = {};
17
+ function kernel() {
18
+ const k = globalThis.__polo;
19
+ if (!k) throw new Error("polo kernel not loaded; kernel.js must be evaluated before extension bundles");
20
+ return k;
21
+ }
22
+ function hostCallFn() {
23
+ const bound = privilegedHostCalls[currentExtensionKey] ?? Object.values(privilegedHostCalls)[0];
24
+ if (bound) return bound;
25
+ return (method, params) => kernel().hostCall(method, params);
26
+ }
27
+ function defineExtension(def) {
28
+ currentExtensionKey = def.key;
29
+ kernel().defineExtension({
30
+ ...def,
31
+ onActivation(api) {
32
+ if (typeof api.hostCall === "function") privilegedHostCalls[def.key] = api.hostCall;
33
+ return def.onActivation(api);
34
+ }
35
+ });
36
+ }
37
+ const hooks = {
38
+ /// EVERY hook in the category — including the CALLER's own. A hook that
39
+ /// delegates within its own category therefore re-enters itself; use
40
+ /// `invokeOne` when you know which extension you want.
41
+ invokeAll(category, method, args, online) {
42
+ return kernel().invokeLocal(category, method, args, online);
43
+ },
44
+ /// One named extension's hook.
45
+ ///
46
+ /// Prefer this over filtering `invokeAll`'s results for the key you wanted.
47
+ /// A contest's `generateExport` delegating the ADIF build is exactly the
48
+ /// recursive shape described above, and loops forever through `invokeAll`.
49
+ invokeOne(category, key, method, args, online) {
50
+ return kernel().invokeLocal(category, method, args, online, key);
51
+ },
52
+ /// Like `invokeAll`, but calls hooks one at a time in priority order,
53
+ /// threading each result into the next call's args via `fold` — needed
54
+ /// when later hooks must see earlier hooks' contributions.
55
+ invokeAllSequential(category, method, initialArgs, fold, online) {
56
+ return kernel().invokeLocalSequential(category, method, initialArgs, fold, online);
57
+ }
58
+ };
59
+ const host = {
60
+ async fetch(url, options) {
61
+ return await hostCallFn()("fetch", { url, ...options });
62
+ },
63
+ async kvGet(key) {
64
+ return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
65
+ },
66
+ /// The device's current GPS position, or null when location is
67
+ /// unavailable, denied, or off — for a command (e.g. WEATHER) that needs a
68
+ /// location fix and has no operation grid to fall back to. May prompt the
69
+ /// user for location permission the first time it's called.
70
+ async getLocation() {
71
+ return await hostCallFn()("getLocation", {});
72
+ },
73
+ /// Read-only build identity — platform, edition, version, build number,
74
+ /// git sha — for a command (REPORT) that needs to say exactly what build it
75
+ /// ran on. `buildNumber` is null on Dev; see VersionInfo.
76
+ async getVersionInfo() {
77
+ return await hostCallFn()("getVersionInfo", {});
78
+ },
79
+ /// The app's settings, read-only: resolved core values (theme, locale,
80
+ /// units, logging toggles, bands/modes, privacy consents, the active
81
+ /// profile's operatorCall, ...) plus every extension's settings group
82
+ /// under `extensions`. Never includes credentials or accounts — those
83
+ /// live in the platform keychain via the account hook system, not in
84
+ /// settings.
85
+ async getSettings() {
86
+ return await hostCallFn()("getSettings", {});
87
+ },
88
+ /// Writes settings into THIS extension's own group (surfaced to other
89
+ /// readers as `extensions.extension_<key>` in getSettings). The namespace
90
+ /// is injected here — core settings and other extensions' groups are not
91
+ /// writable.
92
+ async setSettings(values) {
93
+ await hostCallFn()("setSettings", { ns: currentExtensionKey, values });
94
+ },
95
+ /// Resolves what a SET/SHOW/HIDE command names against the app's settings.
96
+ ///
97
+ /// The catalog it searches is built from the app's declarative settings
98
+ /// registry, so it reaches every setting the settings screen would show and
99
+ /// respects the same `devMode`, `environment` and `visibleWhen` visibility
100
+ /// rules. None of that data — option lists, current values, commit routing
101
+ /// — exists on this side of the bridge, which is why the settings commands
102
+ /// ask rather than carrying their own list.
103
+ ///
104
+ /// `kind` narrows the search: `'show'`/`'hide'` reach only visibility
105
+ /// toggles and derive their own value from the verb, so `value` is for
106
+ /// `'set'` only. Never rejects: an unmatched query answers `{found: false}`,
107
+ /// which is the normal state of a half-typed command.
108
+ async searchSettings(params) {
109
+ return await hostCallFn()("searchSettings", { ...params });
110
+ },
111
+ async kvSet(key, value) {
112
+ await hostCallFn()("kvSet", { ns: currentExtensionKey, key, value });
113
+ },
114
+ async setAccountSession(session) {
115
+ const kvKey = kernel().getAccountKvKey(currentExtensionKey);
116
+ await hostCallFn()("setAccountSession", { ns: currentExtensionKey, kvKey, session });
117
+ },
118
+ /// Presents a ResultMessage imperatively — for hooks whose results can't
119
+ /// carry a `userMessage` (e.g. a lookup hitting an auth error). Hooks
120
+ /// that CAN return one (spots posting) should prefer that instead.
121
+ async showMessage(message) {
122
+ await hostCallFn()("showMessage", { ...message, ns: currentExtensionKey });
123
+ },
124
+ /// Looks up a string from the app's own translations (app/lib/i18n/
125
+ /// {en,es}.i18n.json) by its dot-path key (e.g. "settings.theme.title"),
126
+ /// in whatever locale is currently active — there's no separate
127
+ /// extension-i18n system, this is the same source of truth the rest of
128
+ /// the app reads via its generated `t[key]` lookup. Returns null if the
129
+ /// key doesn't exist or isn't a plain string (e.g. it needs interpolation
130
+ /// params, which this doesn't support).
131
+ async translate(key) {
132
+ return await hostCallFn()("translate", { key });
133
+ },
134
+ /// Same lookup as `translate`, but for many keys in one bridge round
135
+ /// trip — for a panel with a dozen-plus labels, that's a dozen-plus
136
+ /// fewer JS↔Dart hops than calling `translate` in a loop. Missing/non-
137
+ /// string keys come back as null, same as `translate`.
138
+ async translateAll(keys) {
139
+ return await hostCallFn()("translateAll", { keys });
140
+ },
141
+ /// A build-time secret by name, or null when this build has no such value.
142
+ ///
143
+ /// "Secret" in the sense every cloud platform uses it — real credentials and
144
+ /// plain per-build configuration alike: an award's API key, an OAuth client
145
+ /// id that differs per edition, a token tied to whoever's account pays.
146
+ ///
147
+ /// An extension reads the names that start with its own key — `sota` reads
148
+ /// `SOTA_*`. The kernel binds the caller's identity onto the call, so asking
149
+ /// for anything outside that prefix returns null. See
150
+ /// `app/lib/config/secret_policy.dart` for the two escape hatches.
151
+ ///
152
+ /// Expect null and degrade: a fresh checkout, a fork, and every CI build
153
+ /// without the value configured all have none, and a hook that throws
154
+ /// instead of returning empty takes its whole feature down for them.
155
+ ///
156
+ /// This is a stopgap around a flat map. A real secrets service, and flags to
157
+ /// gate the calls that use these, are follow-up work.
158
+ async secret(name) {
159
+ return await hostCallFn()("secret", { name });
160
+ },
161
+ async dbLookupSelectOne(category, key) {
162
+ return await hostCallFn()("dbLookupSelectOne", { category, key });
163
+ },
164
+ /// [subCategory], when given, filters IN THE QUERY rather than after —
165
+ /// which matters because of `limit`: a common word ("trail") can match
166
+ /// thousands of rows worldwide, and the query only returns the first 100
167
+ /// it happens to find. Filtering by subCategory afterward, in the caller,
168
+ /// can end up filtering an already-truncated set down to nothing even
169
+ /// though a real match exists — POTA, which scopes by the caller's own
170
+ /// DXCC entity, passes it here instead, so the limit applies to the
171
+ /// already-narrowed set. SOTA scopes by entity too but needs a PREFIX
172
+ /// match ([subCategory] here is exact-equals only), so it still filters
173
+ /// after the fact and is exposed to the same truncation bug this exists
174
+ /// to avoid.
175
+ ///
176
+ /// [activeOnly] drops rows whose `flags` is explicitly 0 — the convention a
177
+ /// data-file mapper follows for a reference its program has RETIRED — and is
178
+ /// in the query for the same `limit` reason: retirements cluster (a program
179
+ /// delists a whole class at once), so a search for a word that class shares
180
+ /// can fill the cap with retired rows and leave the live matches just past
181
+ /// it. A row that never said (`flags` null) is NOT retired and survives.
182
+ async dbLookupSelectAll(category, query, subCategory, activeOnly) {
183
+ return await hostCallFn()("dbLookupSelectAll", { category, query, subCategory, activeOnly });
184
+ },
185
+ async dbLookupSelectByLocation(category, lat, lon, delta, activeOnly) {
186
+ return await hostCallFn()("dbLookupSelectByLocation", { category, lat, lon, delta, activeOnly });
187
+ },
188
+ async showForm(form, state = {}) {
189
+ return kernel().host.showForm(form, state, currentExtensionKey);
190
+ },
191
+ statusBar: {
192
+ /// A transient single-line message, replacing any prior message under
193
+ /// the same `key`. `key` is automatically namespaced to this extension —
194
+ /// other extensions can't see or clear it.
195
+ async setMessage(params) {
196
+ await hostCallFn()("statusBarSetMessage", { ...params, ns: currentExtensionKey });
197
+ },
198
+ /// A transient progress bar under `key`; omit `value` for indeterminate.
199
+ async setProgress(params) {
200
+ await hostCallFn()("statusBarSetProgress", { ...params, ns: currentExtensionKey });
201
+ },
202
+ /// Removes the message/progress item under `key`, if any.
203
+ async clear(key) {
204
+ await hostCallFn()("statusBarClear", { key, ns: currentExtensionKey });
205
+ },
206
+ /// A richer, dismissible notice, replacing any prior undismissed notice
207
+ /// under the same `key`. Stays in the user's notice history after
208
+ /// dismissal.
209
+ async showNotice(params) {
210
+ await hostCallFn()("statusBarShowNotice", { ...params, ns: currentExtensionKey });
211
+ },
212
+ /// Dismisses the active notice under `key`, if any.
213
+ async dismissNotice(key) {
214
+ await hostCallFn()("statusBarDismissNotice", { key, ns: currentExtensionKey });
215
+ }
216
+ },
217
+ log(message) {
218
+ kernel().log(`[${currentExtensionKey}] ${message}`);
219
+ },
220
+ /// Pushes a later CommandInterpretation for the same `input` a `command`
221
+ /// hook's `interpret()` was called with — see CommandHook's doc. One-way,
222
+ /// like `log`: call it, don't await anything back.
223
+ updateInterpretation(input, interpretation) {
224
+ kernel().updateInterpretation(input, interpretation);
225
+ }
226
+ };
227
+ export {
228
+ defineExtension,
229
+ hooks,
230
+ host
231
+ };
@@ -0,0 +1,19 @@
1
+ const LOCATION_ACCURACY = {
2
+ EXACT: 0,
3
+ // Believed to be the right grid6 — confirmed by contact or GPS.
4
+ ACCURATE: 2,
5
+ // Close enough, maybe a neighbouring grid — e.g. a SOTA summit.
6
+ REASONABLE: 10,
7
+ // May be off by a dozen grids — most POTA parks.
8
+ VAGUE: 100,
9
+ // No better than a general state/province.
10
+ NO_LOCATION: 1e3
11
+ };
12
+ function applyLatLonFromApiFields(target, raw) {
13
+ if (typeof raw.latitude === "number") target.lat = raw.latitude;
14
+ if (typeof raw.longitude === "number") target.lon = raw.longitude;
15
+ }
16
+ export {
17
+ LOCATION_ACCURACY,
18
+ applyLatLonFromApiFields
19
+ };
package/dist/modes.js ADDED
@@ -0,0 +1,28 @@
1
+ import { ADIF_MODES_AND_SUBMODES, ADIF_MODE_FOR_SUBMODE, superModeForMode } from "@ham2k/lib-operation-data";
2
+ const SIDEBAND_SPELLINGS = /* @__PURE__ */ new Set(["SSB", "USB", "LSB"]);
3
+ const MODE_ALIASES = { DV: "DIGITALVOICE" };
4
+ const COARSE_SUPER_MODES = {
5
+ DATA: "DATA",
6
+ DIGI: "DATA",
7
+ DIGITAL: "DATA",
8
+ PHONE: "PHONE"
9
+ };
10
+ const KNOWN_MODES = new Set(ADIF_MODES_AND_SUBMODES.map((mode) => mode.toUpperCase()));
11
+ function canonicalMode(mode) {
12
+ const upper = (mode ?? "").trim().toUpperCase();
13
+ if (SIDEBAND_SPELLINGS.has(upper)) return "SSB";
14
+ return MODE_ALIASES[upper] ?? upper;
15
+ }
16
+ function modesMatchForDupe(worked, candidate) {
17
+ const w = canonicalMode(worked);
18
+ const c = canonicalMode(candidate);
19
+ if (w === c) return true;
20
+ if (!w || !c) return false;
21
+ if (ADIF_MODE_FOR_SUBMODE[w] === c || ADIF_MODE_FOR_SUBMODE[c] === w) return true;
22
+ const superMode = COARSE_SUPER_MODES[c];
23
+ return Boolean(superMode) && KNOWN_MODES.has(w) && superModeForMode(w) === superMode;
24
+ }
25
+ export {
26
+ canonicalMode,
27
+ modesMatchForDupe
28
+ };
@@ -0,0 +1,68 @@
1
+ function roundHalfExpand(absolute, digits) {
2
+ const source = String(absolute);
3
+ if (source.includes("e") || source.includes("E")) return absolute.toFixed(digits);
4
+ const dot = source.indexOf(".");
5
+ const whole = dot < 0 ? source : source.slice(0, dot);
6
+ const fraction = dot < 0 ? "" : source.slice(dot + 1);
7
+ if (fraction.length <= digits) {
8
+ const padded = fraction.padEnd(digits, "0");
9
+ return digits === 0 ? whole : `${whole}.${padded}`;
10
+ }
11
+ let kept = whole + fraction.slice(0, digits);
12
+ if (fraction.charCodeAt(digits) - 48 >= 5) {
13
+ const chars = kept.split("");
14
+ let i = chars.length - 1;
15
+ for (; ; ) {
16
+ if (i < 0) {
17
+ chars.unshift("1");
18
+ break;
19
+ }
20
+ if (chars[i] === "9") {
21
+ chars[i] = "0";
22
+ i--;
23
+ } else {
24
+ chars[i] = String(Number(chars[i]) + 1);
25
+ break;
26
+ }
27
+ }
28
+ kept = chars.join("");
29
+ }
30
+ const split = kept.length - digits;
31
+ const wholeOut = kept.slice(0, split) || "0";
32
+ return digits === 0 ? wholeOut : `${wholeOut}.${kept.slice(split)}`;
33
+ }
34
+ function formatNumberEnUS(n, options) {
35
+ if (!Number.isFinite(n)) return String(n);
36
+ if (Math.abs(n) >= 1e21) return String(n);
37
+ const minimum = options?.minimumFractionDigits ?? 0;
38
+ let maximum = options?.maximumFractionDigits ?? Math.max(minimum, 3);
39
+ if (maximum < minimum) maximum = minimum;
40
+ const negative = n < 0;
41
+ const absolute = Math.abs(n);
42
+ let text = roundHalfExpand(absolute, maximum);
43
+ if (maximum > minimum) {
44
+ text = text.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
45
+ const dot = text.indexOf(".");
46
+ const decimals = dot < 0 ? 0 : text.length - dot - 1;
47
+ if (decimals < minimum) {
48
+ text = (dot < 0 ? `${text}.` : text) + "0".repeat(minimum - decimals);
49
+ }
50
+ }
51
+ const parts = text.split(".");
52
+ if (options?.useGrouping !== false) {
53
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
54
+ }
55
+ const out = parts.join(".");
56
+ return negative ? `-${out}` : out;
57
+ }
58
+ function installNumberFormatPolyfill() {
59
+ const engineFormats = 1234.5.toLocaleString("en-US", { maximumFractionDigits: 0 }) === "1,235";
60
+ if (engineFormats) return;
61
+ Number.prototype.toLocaleString = function(_locales, options) {
62
+ return formatNumberEnUS(Number(this), options);
63
+ };
64
+ }
65
+ export {
66
+ formatNumberEnUS,
67
+ installNumberFormatPolyfill
68
+ };
@@ -0,0 +1,226 @@
1
+ import { distanceOnEarth } from "@ham2k/lib-geo-tools";
2
+ import { activityAdifImport } from "./activityAdifImport.js";
3
+ async function hostApi() {
4
+ const { host } = await import("@ham2k/extension-sdk");
5
+ return host;
6
+ }
7
+ const NEARBY_DELTA = 1.5;
8
+ const MAX_SUGGESTIONS = 30;
9
+ function normalize(ref) {
10
+ return (ref ?? "").toUpperCase().trim();
11
+ }
12
+ function refsOfType(container, type) {
13
+ return (container.refs ?? []).filter((r) => r.type === type && r.ref);
14
+ }
15
+ function relevanceForMatch(row, searchTerm) {
16
+ if (!searchTerm) return 1;
17
+ const term = searchTerm.toUpperCase();
18
+ const key = row.key.toUpperCase();
19
+ const name = (row.name ?? "").toUpperCase();
20
+ if (key === term) return 1;
21
+ if (name.startsWith(term)) return 0.9;
22
+ if (key.includes(term)) return 0.85;
23
+ if (name.includes(term)) return 0.7;
24
+ return 0.6;
25
+ }
26
+ function referenceActivity(config) {
27
+ const category = config.category ?? config.key;
28
+ const program = config.adifProgram ?? config.label;
29
+ const allowsMultiple = config.allowsMultiple ?? false;
30
+ async function lookupReference(ref) {
31
+ const host = await hostApi();
32
+ try {
33
+ return await host.dbLookupSelectOne(category, ref);
34
+ } catch (e) {
35
+ host.log(`${config.key}: database lookup failed for ${ref}: ${e}`);
36
+ return null;
37
+ }
38
+ }
39
+ function decorate(ref, reference, row, unknown) {
40
+ if (!row?.name) return { ...ref, ref: reference, name: unknown, program };
41
+ const data = row.data ?? {};
42
+ return {
43
+ ...ref,
44
+ ref: reference,
45
+ name: row.name,
46
+ grid: data.grid,
47
+ lat: row.lat,
48
+ lon: row.lon,
49
+ location: data.location,
50
+ program,
51
+ label: `${config.label} ${reference}: ${row.name}`,
52
+ shortLabel: `${config.label} ${reference}`
53
+ };
54
+ }
55
+ const refHandler = {
56
+ async validateRef({ ref }, _ctx) {
57
+ const normalized = normalize(ref.ref);
58
+ return { valid: config.referenceRegex.test(normalized), normalized };
59
+ },
60
+ async decorateRef({ ref }, ctx) {
61
+ const t = config.tFor(ctx);
62
+ const reference = normalize(ref.ref);
63
+ if (!config.referenceRegex.test(reference)) {
64
+ return { ...ref, ref: reference, name: t("invalidReference") };
65
+ }
66
+ return decorate(ref, reference, await lookupReference(reference), t("unknownReference"));
67
+ },
68
+ /// Titles ANY reference type this handler was registered for, rather than
69
+ /// only `config.activationType`. These programs have no hunting side, so
70
+ /// every type they register is an activation and every one of them means
71
+ /// "the operation is happening here".
72
+ ///
73
+ /// Checking the type would be actively wrong: `wca` also registers for
74
+ /// `ecaActivation` and `bcaActivation` (English and Belgian castles ARE WCA
75
+ /// references), title dispatch resolves to a SINGLE hook, and WCA wins it by
76
+ /// registration order — so a type check silently costs those two activities
77
+ /// their "at ON-00558" whenever WCA is enabled alongside them. `decorateRef`
78
+ /// above never checked the type either; this makes the pair consistent.
79
+ async suggestOperationTitle({ ref }, _ctx) {
80
+ if (!ref.ref) return null;
81
+ return { at: ref.ref, subtitle: ref.name };
82
+ },
83
+ /// Only for a reference this program would accept: a code the operator is
84
+ /// still typing, or one another program's handler would answer for, would
85
+ /// otherwise send them to a page that cannot exist.
86
+ ///
87
+ /// No `label`: the core names the row instead, and the row's name is the
88
+ /// honest one here. This handler answers for ref types belonging to OTHER
89
+ /// programs (WCA takes `ecaActivation` and `bcaActivation` — see
90
+ /// `suggestOperationTitle` above), so a label built from `config.label`
91
+ /// would read "WCA G-00001" aloud on an ECA reference.
92
+ async linkForRef({ ref }, _ctx) {
93
+ if (!config.linkUrl) return null;
94
+ const reference = normalize(ref.ref);
95
+ if (!config.referenceRegex.test(reference)) return null;
96
+ return { url: config.linkUrl(reference) };
97
+ }
98
+ };
99
+ const activityHook = {
100
+ async operationControls(_args, ctx) {
101
+ return [
102
+ {
103
+ key: `${config.key}/activation`,
104
+ label: config.tFor(ctx)("activationControl"),
105
+ icon: config.icon,
106
+ color: config.color,
107
+ order: 10,
108
+ optionType: "optional",
109
+ allowsMultiple,
110
+ input: {
111
+ kind: "refList",
112
+ refType: config.activationType,
113
+ placeholder: config.placeholder,
114
+ pattern: config.referenceRegex.source
115
+ }
116
+ }
117
+ ];
118
+ },
119
+ // Only offered when the program HAS a hunting side; an activation-only
120
+ // award has nothing to record per QSO. One control either way, unlike
121
+ // app-polo which swaps between an activator and a hunter control depending
122
+ // on whether the operation is activating — both write the same hunting
123
+ // refs, and POTA's HaLo port already settled on the single control.
124
+ ...config.huntingType ? {
125
+ async loggingControls(_args, ctx) {
126
+ return [
127
+ {
128
+ key: `${config.key}/hunter`,
129
+ label: config.tFor(ctx)("huntingControl"),
130
+ icon: config.icon,
131
+ color: config.color,
132
+ order: 10,
133
+ optionType: "optional",
134
+ allowsMultiple: true,
135
+ input: {
136
+ kind: "refList",
137
+ refType: config.huntingType,
138
+ placeholder: config.placeholder,
139
+ pattern: config.referenceRegex.source
140
+ }
141
+ }
142
+ ];
143
+ }
144
+ } : {},
145
+ async suggest({ location, searchTerm }, _ctx) {
146
+ const host = await hostApi();
147
+ const rows = searchTerm ? await host.dbLookupSelectAll(category, searchTerm) : location ? await host.dbLookupSelectByLocation(category, location.lat, location.lon, NEARBY_DELTA) : [];
148
+ const withDistance = rows.map((row) => ({
149
+ row,
150
+ distance: location && row.lat != null && row.lon != null ? distanceOnEarth(location, { lat: row.lat, lon: row.lon }) ?? void 0 : void 0
151
+ }));
152
+ if (!searchTerm) {
153
+ withDistance.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
154
+ }
155
+ return withDistance.slice(0, MAX_SUGGESTIONS).map(({ row, distance }) => {
156
+ const data = row.data ?? {};
157
+ return {
158
+ type: config.activationType,
159
+ ref: row.key,
160
+ name: row.name,
161
+ grid: data.grid,
162
+ lat: row.lat,
163
+ lon: row.lon,
164
+ location: data.location,
165
+ program,
166
+ label: `${config.label} ${row.key}: ${row.name ?? ""}`,
167
+ shortLabel: `${config.label} ${row.key}`,
168
+ distance,
169
+ relevance: relevanceForMatch(row, searchTerm),
170
+ allowsMultiple
171
+ };
172
+ });
173
+ }
174
+ };
175
+ function activationFields(operation) {
176
+ const refs = refsOfType(operation, config.activationType);
177
+ if (refs.length === 0) return [];
178
+ return [
179
+ { name: "MY_SIG", value: program },
180
+ { name: "MY_SIG_INFO", value: refs[0].ref },
181
+ ...config.adifRefField ? [{ name: `MY_${config.adifRefField}_REF`, value: refs.map((r) => r.ref).join(",") }] : []
182
+ ];
183
+ }
184
+ function huntedRefsOn(qso) {
185
+ return config.huntingType ? refsOfType(qso, config.huntingType) : [];
186
+ }
187
+ const adifFieldsHook = {
188
+ async fieldsForOneQSO({ qso, operation }, _ctx) {
189
+ const hunted = huntedRefsOn(qso);
190
+ const huntedJoined = hunted.map((r) => r.ref).join(",");
191
+ return [
192
+ ...hunted.length > 0 ? [
193
+ { name: "SIG", value: program },
194
+ { name: "SIG_INFO", value: huntedJoined },
195
+ ...config.adifRefField ? [{ name: `${config.adifRefField}_REF`, value: huntedJoined }] : []
196
+ ] : [],
197
+ ...activationFields(operation)
198
+ ];
199
+ }
200
+ };
201
+ if (config.huntingType && config.splitRecordsPerHuntedRef) {
202
+ adifFieldsHook.fieldCombinationsForOneQSO = async ({ qso, operation }, _ctx) => {
203
+ const activation = activationFields(operation);
204
+ const hunted = huntedRefsOn(qso);
205
+ if (hunted.length === 0) return [activation];
206
+ return hunted.map((ref) => [
207
+ ...activation,
208
+ { name: "SIG", value: program },
209
+ { name: "SIG_INFO", value: ref.ref },
210
+ // One record names ONE reference, so the program field names it too.
211
+ ...config.adifRefField ? [{ name: `${config.adifRefField}_REF`, value: ref.ref }] : []
212
+ ]);
213
+ };
214
+ }
215
+ const adifImportHook = activityAdifImport({
216
+ sig: program,
217
+ huntingType: config.huntingType,
218
+ activationType: config.activationType,
219
+ refField: config.adifRefField ? `${config.adifRefField.toLowerCase()}_ref` : void 0,
220
+ normalize
221
+ });
222
+ return { refHandler, activityHook, adifFieldsHook, adifImportHook };
223
+ }
224
+ export {
225
+ referenceActivity
226
+ };