@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/README.md +58 -0
- package/dist/activityAdifImport.js +45 -0
- package/dist/activityExports.js +157 -0
- package/dist/activityScoring.js +227 -0
- package/dist/appClock.js +42 -0
- package/dist/dxcc.js +25 -0
- package/dist/exportNames.js +57 -0
- package/dist/i18n/activityExports.en.json +4 -0
- package/dist/i18n/activityExports.es.json +4 -0
- package/dist/i18n/activityScoring.en.json +11 -0
- package/dist/i18n/activityScoring.es.json +11 -0
- package/dist/i18n.js +43 -0
- package/dist/index.d.ts +1361 -0
- package/dist/index.js +231 -0
- package/dist/location.js +19 -0
- package/dist/modes.js +28 -0
- package/dist/number-format.js +68 -0
- package/dist/referenceActivity.js +226 -0
- package/dist/scoring.js +163 -0
- package/dist/settingsPanels.js +59 -0
- package/dist/templateContext.js +113 -0
- package/dist/templates.js +76 -0
- package/dist/testOperation.js +6 -0
- package/dist/types.js +0 -0
- package/package.json +110 -0
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
|
+
};
|
package/dist/location.js
ADDED
|
@@ -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
|
+
};
|