@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 ADDED
@@ -0,0 +1,58 @@
1
+ # @ham2k/extension-sdk
2
+
3
+ Write extensions for the [Ham2K Logger](https://ham2k.com) — the typed hook
4
+ contracts and the host API an extension is built against.
5
+
6
+ ```sh
7
+ npm install @ham2k/extension-sdk
8
+ npm install --save-dev @ham2k/extension-tools esbuild
9
+ ```
10
+
11
+ ```ts
12
+ import { defineExtension } from '@ham2k/extension-sdk'
13
+ import type { PanelHook } from '@ham2k/extension-sdk'
14
+
15
+ import manifest from '../manifest.json'
16
+
17
+ const HelloPanel: PanelHook = {
18
+ async getPanels() {
19
+ return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
20
+ },
21
+ async render() {
22
+ return { kind: 'markdown', content: '# Hello, World!' }
23
+ },
24
+ }
25
+
26
+ defineExtension({
27
+ ...manifest,
28
+ onActivation({ registerHook }) {
29
+ registerHook('panel', { key: 'ki2d-hello-world', hook: HelloPanel })
30
+ },
31
+ })
32
+ ```
33
+
34
+ A panel returns *content*, not widgets — which is what keeps extensions out of
35
+ the app's UI toolkit.
36
+
37
+ `@ham2k/extension-tools` builds and packages it into a `.h2kext` the app can
38
+ install. See [the extension docs](https://github.com/ham2k/halo/tree/main/docs/extensions)
39
+ for hooks, forms, templates and distribution.
40
+
41
+ ## About the peer dependencies
42
+
43
+ This package lists the libraries the extension host carries — `@ham2k/lib-*`,
44
+ `i18next`, `liquidjs` — as **optional** peers, and you almost certainly do not
45
+ need to install any of them.
46
+
47
+ Your extension declares in its `manifest.json` which of them it uses, and the
48
+ build rewrites each one into a lookup on the host's single instance before
49
+ esbuild ever reaches the filesystem. Taking the host up on that is worth
50
+ roughly thirty times the bundle size. The published types have theirs rolled
51
+ in, so your editor works with only this package installed.
52
+
53
+ Install one only to `inline` it — to ship your own copy because you need a
54
+ version the host does not carry.
55
+
56
+ ## License
57
+
58
+ MPL-2.0
@@ -0,0 +1,45 @@
1
+ function present(...values) {
2
+ return values.find((v) => v !== void 0 && v !== "");
3
+ }
4
+ function splitRefs(value) {
5
+ return value.split(",").map((r) => r.trim()).filter((r) => r.length > 0);
6
+ }
7
+ function refsFor(fields, sigField, sigInfoField, listField, rules) {
8
+ const found = [];
9
+ const list = listField ? fields[listField] : void 0;
10
+ if (list) found.push(...splitRefs(list));
11
+ if (present(fields[`${sigField}_intl`], fields[sigField])?.toUpperCase() === rules.sig.toUpperCase()) {
12
+ const info = present(fields[`${sigInfoField}_intl`], fields[sigInfoField]);
13
+ if (info) found.push(...splitRefs(info));
14
+ }
15
+ return found;
16
+ }
17
+ function activityAdifImport(rules) {
18
+ const listField = rules.refField;
19
+ const myListField = rules.refField ? `my_${rules.refField}` : void 0;
20
+ function refsForOneRecord(fields) {
21
+ const refs = [];
22
+ for (const [type, sigField, sigInfoField, field, scope] of [
23
+ [rules.huntingType, "sig", "sig_info", listField, void 0],
24
+ [rules.activationType, "my_sig", "my_sig_info", myListField, "operation"]
25
+ ]) {
26
+ if (!type) continue;
27
+ const seen = /* @__PURE__ */ new Set();
28
+ for (const raw of refsFor(fields, sigField, sigInfoField, field, rules)) {
29
+ const ref = rules.normalize ? rules.normalize(raw) : raw;
30
+ if (!ref || seen.has(ref)) continue;
31
+ seen.add(ref);
32
+ refs.push(scope ? { type, ref, for: scope } : { type, ref });
33
+ }
34
+ }
35
+ return refs.length > 0 ? { refs } : null;
36
+ }
37
+ return {
38
+ async refsForRecords({ records }, _ctx) {
39
+ return records.map((record) => refsForOneRecord(record.fields ?? {}));
40
+ }
41
+ };
42
+ }
43
+ export {
44
+ activityAdifImport
45
+ };
@@ -0,0 +1,157 @@
1
+ import { exportFilename, startMillisOf } from "./exportNames.js";
2
+ import { createCachedTranslator } from "./i18n.js";
3
+ import en from "./i18n/activityExports.en.json";
4
+ import es from "./i18n/activityExports.es.json";
5
+ const tFor = createCachedTranslator({ en: { translation: en }, es: { translation: es } });
6
+ const SEPARATOR = ":";
7
+ function refsOfType(container, type) {
8
+ const refs = container.refs ?? [];
9
+ return refs.filter((r) => r && r.type === type && typeof r.ref === "string" && r.ref.length > 0).map((r) => ({ ref: r.ref, name: typeof r.name === "string" ? r.name : void 0 }));
10
+ }
11
+ function filenameFor(operation, qsos, ref, compact) {
12
+ return exportFilename({
13
+ stationCall: operation.stationCall,
14
+ ref,
15
+ startAtMillis: startMillisOf(operation, qsos),
16
+ extension: "adi",
17
+ compact
18
+ });
19
+ }
20
+ function activityExportHook(rules) {
21
+ const prefix = `${rules.key}-adif`;
22
+ return {
23
+ async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
24
+ const refs = refsOfType(operation, rules.activationType);
25
+ const t = tFor(ctx);
26
+ return refs.map((ref) => ({
27
+ exportType: `${prefix}${SEPARATOR}${ref.ref}`,
28
+ format: "adif",
29
+ // The FORMAT leads: a park activated on a contest weekend offers
30
+ // several files for the same reference, and "POTA US-1234" alone said
31
+ // nothing about which of them this was.
32
+ label: t("adifForRef", { program: rules.label, ref: ref.ref }),
33
+ // The WHOLE log's first QSO dates the file, not this reference's
34
+ // slice: the slice isn't known until `generateExport`, and a name
35
+ // that changed between being shown and being written would break the
36
+ // panel's promise.
37
+ filename: filenameFor(operation, qsos ?? [], ref.ref, compactFilenames),
38
+ icon: rules.icon,
39
+ selectedByDefault: true,
40
+ // Segment slicing: only the contacts made while this activity ran.
41
+ refType: rules.activationType
42
+ }));
43
+ },
44
+ async generateExport(args, ctx) {
45
+ const exportType = String(args.exportType ?? "");
46
+ if (!exportType.startsWith(`${prefix}${SEPARATOR}`)) {
47
+ throw new Error(`${rules.key}: unknown export type '${exportType}'`);
48
+ }
49
+ const wanted = exportType.slice(prefix.length + SEPARATOR.length);
50
+ if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
51
+ throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
52
+ }
53
+ const refs = (args.operation.refs ?? []).filter(
54
+ (r) => r?.type !== rules.activationType || r?.ref === wanted
55
+ );
56
+ const operation = { ...args.operation, refs };
57
+ const content = await adifForExport({
58
+ operation,
59
+ qsos: args.qsos,
60
+ includePrivateData: args.includePrivateData,
61
+ mainHandler: rules.key,
62
+ includeFieldsFrom: rules.includeFieldsFrom
63
+ });
64
+ return {
65
+ filename: filenameFor(args.operation, args.qsos, wanted, args.compactFilenames),
66
+ mimeType: "text/plain",
67
+ content
68
+ };
69
+ }
70
+ };
71
+ }
72
+ function filenameForHunt(operation, qsos, activity, compact) {
73
+ return exportFilename({
74
+ stationCall: operation.stationCall,
75
+ activity,
76
+ startAtMillis: startMillisOf(operation, qsos),
77
+ extension: "adi",
78
+ compact
79
+ });
80
+ }
81
+ function huntingExportHook(rules) {
82
+ const exportType = `${rules.key}-hunter`;
83
+ function huntedQsos(qsos) {
84
+ return qsos.filter((qso) => refsOfType(qso, rules.huntingType).length > 0);
85
+ }
86
+ return {
87
+ async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
88
+ if (refsOfType(operation, rules.activationType).length > 0) return [];
89
+ if (huntedQsos(qsos ?? []).length === 0) return [];
90
+ const t = tFor(ctx);
91
+ return [{
92
+ exportType,
93
+ format: "adif",
94
+ label: t("adifForHunter", { program: rules.label }),
95
+ filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
96
+ icon: rules.icon,
97
+ color: rules.color,
98
+ selectedByDefault: true
99
+ // No `refType`: hunted refs live on QSOs, not on operation segments,
100
+ // so this isn't the segment-scoped slice `activityExportHook` uses —
101
+ // it spans the whole log. (See `HuntingExportRules.color` for why the
102
+ // accent has to be threaded explicitly instead.)
103
+ }];
104
+ },
105
+ async generateExport(args, ctx) {
106
+ const requested = String(args.exportType ?? "");
107
+ if (requested !== exportType) {
108
+ throw new Error(`${rules.key}: unknown export type '${requested}'`);
109
+ }
110
+ if (refsOfType(args.operation, rules.activationType).length > 0) {
111
+ throw new Error(`${rules.key}: operation now activates ${rules.activationType}; hunter export withdrawn`);
112
+ }
113
+ const content = await adifForExport({
114
+ operation: args.operation,
115
+ qsos: huntedQsos(args.qsos ?? []),
116
+ includePrivateData: args.includePrivateData,
117
+ // The hunter file is submitted to ONE program too — see
118
+ // `activityExportHook.generateExport`. `rules.key` is the ACTIVITY's
119
+ // key ('pota'), which is what its `adifFields` hook registers under;
120
+ // the '-hunter' suffix belongs to the export registration alone.
121
+ mainHandler: rules.key,
122
+ includeFieldsFrom: rules.includeFieldsFrom
123
+ });
124
+ return {
125
+ // The FULL qsos list, matching the option's filename — see
126
+ // `filenameFor`'s identical comment above.
127
+ filename: filenameForHunt(args.operation, args.qsos ?? [], rules.label, args.compactFilenames),
128
+ mimeType: "text/plain",
129
+ content
130
+ };
131
+ }
132
+ };
133
+ }
134
+ async function adifForExport({ operation, qsos, includePrivateData, mainHandler, includeFieldsFrom }) {
135
+ const { hooks } = await import("@ham2k/extension-sdk");
136
+ const entries = await hooks.invokeOne("export", "adif", "generateExport", {
137
+ operation,
138
+ qsos,
139
+ exportType: "adif",
140
+ // Forwarded, or the delegate defaults to withholding and the user's
141
+ // "include private info" setting silently does nothing here.
142
+ includePrivateData,
143
+ mainHandler,
144
+ includeFieldsFrom
145
+ });
146
+ const content = entries.find((e) => e.ok)?.value?.content;
147
+ if (!content) {
148
+ const failed = entries.find((e) => !e.ok)?.error;
149
+ throw new Error(`${mainHandler}: the ADIF exporter produced no file${failed ? ` (${failed})` : ""}`);
150
+ }
151
+ return content;
152
+ }
153
+ export {
154
+ activityExportHook,
155
+ adifForExport,
156
+ huntingExportHook
157
+ };
@@ -0,0 +1,227 @@
1
+ import { createCachedTranslator } from "./i18n.js";
2
+ import { modesMatchForDupe } from "./modes.js";
3
+ import { fmtInteger } from "@ham2k/lib-format-tools";
4
+ import en from "./i18n/activityScoring.en.json";
5
+ import es from "./i18n/activityScoring.es.json";
6
+ const tFor = createCachedTranslator({ en: { translation: en }, es: { translation: es } });
7
+ const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
8
+ function resolveRuleText(value, ctx, fallback) {
9
+ return typeof value === "function" ? value(ctx) : value ?? fallback;
10
+ }
11
+ function str(value) {
12
+ return typeof value === "string" && value.length > 0 ? value : void 0;
13
+ }
14
+ function refsOfType(container, type) {
15
+ const refs = container.refs ?? [];
16
+ return refs.filter((r) => r && r.type === type && typeof r.ref === "string" && r.ref.length > 0).map((r) => r.ref);
17
+ }
18
+ function packContact(day, band, mode, refs) {
19
+ return `${day}|${band}|${mode}|${[...refs].sort().join(",")}`;
20
+ }
21
+ function unpackContact(packed) {
22
+ const [day, band, mode, refs] = packed.split("|");
23
+ return {
24
+ day: Number(day),
25
+ band,
26
+ mode,
27
+ refs: refs ? refs.split(",") : []
28
+ };
29
+ }
30
+ function activityScorer(rules) {
31
+ function thresholdFor(activatedRefs) {
32
+ const rule = rules.qsosToActivate ?? 10;
33
+ return typeof rule === "function" ? rule(activatedRefs) : rule;
34
+ }
35
+ const freshRefRescuesActivation = rules.freshRefRescuesActivation ?? true;
36
+ const tracksActivation = rules.tracksActivation ?? true;
37
+ const allowsMultiple = rules.allowsMultipleReferences ?? false;
38
+ const activatesDaily = (rules.activates ?? "daily") === "daily";
39
+ const unique = new Set(rules.uniquePer ?? []);
40
+ function activationRefsOf(operation) {
41
+ return refsOfType(operation, rules.activationType);
42
+ }
43
+ function huntedRefsOf(qso) {
44
+ if (!rules.huntingType) return [];
45
+ const refs = refsOfType(qso, rules.huntingType);
46
+ return allowsMultiple ? refs : refs.slice(0, 1);
47
+ }
48
+ function judge(priors, now) {
49
+ if (priors.length === 0) return void 0;
50
+ let candidates = priors.map(unpackContact);
51
+ let notice;
52
+ if (unique.has("day")) {
53
+ candidates = candidates.filter((c) => c.day === now.day);
54
+ if (candidates.length === 0) notice ?? (notice = "day");
55
+ }
56
+ if (unique.has("band")) {
57
+ candidates = candidates.filter((c) => c.band === now.band);
58
+ if (candidates.length === 0) notice ?? (notice = "band");
59
+ }
60
+ if (unique.has("mode")) {
61
+ candidates = candidates.filter((c) => modesMatchForDupe(c.mode, now.mode));
62
+ if (candidates.length === 0) notice ?? (notice = "mode");
63
+ }
64
+ if (unique.has("ref")) {
65
+ candidates = candidates.filter(
66
+ (c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
67
+ );
68
+ if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
69
+ }
70
+ if (notice) return notice;
71
+ return candidates.length > 0 ? "duplicate" : void 0;
72
+ }
73
+ const NOTICE_KEYS = {
74
+ day: "newDay",
75
+ band: "newBand",
76
+ mode: "newMode",
77
+ ref: "newRef"
78
+ };
79
+ return {
80
+ startScoresheet() {
81
+ return {
82
+ workedByCall: {},
83
+ activatedRefs: {},
84
+ huntedRefs: {},
85
+ activatedQsos: 0,
86
+ huntedQsos: 0,
87
+ duplicates: 0,
88
+ dayActivatedRefs: {},
89
+ dayHuntedRefs: {},
90
+ dayActivatedQsos: 0,
91
+ dayHuntedQsos: 0,
92
+ dayDuplicates: 0
93
+ };
94
+ },
95
+ // Mutates and returns the scoresheet it is handed, which the contract
96
+ // allows and core/scoring measured the case for: copying accumulated state
97
+ // per QSO makes a full pass quadratic. The harness copies any resume
98
+ // checkpoint at its boundary, so nothing cached is ever aliased.
99
+ scoreQso({ scoresheet, qso, operation, isNewDay }) {
100
+ const sheet = scoresheet;
101
+ if (isNewDay && activatesDaily) {
102
+ sheet.dayActivatedRefs = {};
103
+ sheet.dayHuntedRefs = {};
104
+ sheet.dayActivatedQsos = 0;
105
+ sheet.dayHuntedQsos = 0;
106
+ sheet.dayDuplicates = 0;
107
+ }
108
+ const their = qso.their ?? {};
109
+ const call = str(their.call);
110
+ if (!call) return { scoresheet: sheet, score: { value: 0 } };
111
+ const activationRefs = activationRefsOf(operation);
112
+ const huntedRefs = huntedRefsOf(qso);
113
+ const activationValue = activationRefs.length > 0 ? huntedRefs.length || 1 : 0;
114
+ const huntingValue = huntedRefs.length;
115
+ const day = Math.floor(Number(qso.startAtMillis ?? 0) / DAY_IN_MILLIS);
116
+ const band = str(qso.band) ?? "";
117
+ const mode = str(qso.mode) ?? "";
118
+ const now = { day, band, mode, refs: huntedRefs };
119
+ const priors = sheet.workedByCall[call] ?? [];
120
+ const verdict = judge(priors, now);
121
+ const isDupe = verdict === "duplicate";
122
+ const freshRefOnly = verdict === "ref" && !freshRefRescuesActivation;
123
+ const activationCredit = freshRefOnly ? 0 : activationValue;
124
+ const score = { value: isDupe ? 0 : activationCredit + huntingValue };
125
+ if (isDupe) {
126
+ score.dupe = true;
127
+ score.alerts = ["duplicate"];
128
+ } else if (verdict) {
129
+ score.notices = [NOTICE_KEYS[verdict]];
130
+ }
131
+ const packed = packContact(day, band, mode, huntedRefs);
132
+ if (priors.length > 0) priors.push(packed);
133
+ else sheet.workedByCall[call] = [packed];
134
+ if (isDupe) {
135
+ sheet.duplicates += 1;
136
+ sheet.dayDuplicates += 1;
137
+ return { scoresheet: sheet, score };
138
+ }
139
+ for (const ref of activationRefs) {
140
+ sheet.activatedRefs[ref] = (sheet.activatedRefs[ref] ?? 0) + activationCredit;
141
+ sheet.dayActivatedRefs[ref] = (sheet.dayActivatedRefs[ref] ?? 0) + activationCredit;
142
+ }
143
+ sheet.activatedQsos += activationCredit;
144
+ sheet.dayActivatedQsos += activationCredit;
145
+ for (const ref of huntedRefs) {
146
+ sheet.huntedRefs[ref] = (sheet.huntedRefs[ref] ?? 0) + 1;
147
+ sheet.dayHuntedRefs[ref] = (sheet.dayHuntedRefs[ref] ?? 0) + 1;
148
+ }
149
+ sheet.huntedQsos += huntingValue;
150
+ sheet.dayHuntedQsos += huntingValue;
151
+ return { scoresheet: sheet, score };
152
+ },
153
+ summarizeScore({ scoresheet, scope }, ctx) {
154
+ const t = tFor(ctx);
155
+ const perDay = scope === "day" && activatesDaily;
156
+ const tallies = {};
157
+ const activatedRefs = perDay ? scoresheet.dayActivatedRefs : scoresheet.activatedRefs;
158
+ const huntedRefs = perDay ? scoresheet.dayHuntedRefs : scoresheet.huntedRefs;
159
+ const huntedQsos = perDay ? scoresheet.dayHuntedQsos : scoresheet.huntedQsos;
160
+ if (tracksActivation && Object.keys(activatedRefs).length > 0) {
161
+ tallies.activation = summarizeActivation(
162
+ activatedRefs,
163
+ perDay ? scoresheet.dayActivatedQsos : scoresheet.activatedQsos,
164
+ perDay ? scoresheet.dayDuplicates : scoresheet.duplicates,
165
+ huntedQsos,
166
+ scope,
167
+ thresholdFor(Object.keys(activatedRefs)),
168
+ rules,
169
+ t,
170
+ ctx
171
+ );
172
+ } else if (Object.keys(huntedRefs).length > 0) {
173
+ tallies.hunting = summarizeHunting(huntedRefs, huntedQsos, scope, rules, t, ctx);
174
+ }
175
+ return tallies;
176
+ }
177
+ };
178
+ }
179
+ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope, qsosToActivate, rules, t, ctx) {
180
+ const refKeys = Object.keys(activatedRefs).sort();
181
+ const lowest = Math.min(...refKeys.map((key) => activatedRefs[key]));
182
+ const activated = lowest >= qsosToActivate;
183
+ let summary;
184
+ if (!activated) summary = `${fmtInteger(lowest)}/${fmtInteger(qsosToActivate)}`;
185
+ else if (refKeys.length < 6) summary = `${fmtInteger(lowest)} ${"\u2713".repeat(refKeys.length)}`;
186
+ else summary = `${fmtInteger(lowest)} \u2713 x ${fmtInteger(refKeys.length)}`;
187
+ const detail = refKeys.map(
188
+ (key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
189
+ );
190
+ const parts = [
191
+ qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
192
+ ];
193
+ if (huntedQsos > 0) {
194
+ const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
195
+ parts.push(
196
+ huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
197
+ );
198
+ }
199
+ return {
200
+ key: "activation",
201
+ for: scope,
202
+ icon: rules.icon,
203
+ total: qsos,
204
+ activated,
205
+ activatedRefs,
206
+ duplicates,
207
+ label: `${rules.label}: ${parts.join(", ")}`,
208
+ summary,
209
+ longSummary: detail.join("\n")
210
+ };
211
+ }
212
+ function summarizeHunting(huntedRefs, qsos, scope, rules, t, ctx) {
213
+ const count = Object.keys(huntedRefs).length;
214
+ const noun = count === 1 ? resolveRuleText(rules.refNoun, ctx, t("reference")) : resolveRuleText(rules.refNounPlural, ctx, t("referencesPlural"));
215
+ return {
216
+ key: "hunting",
217
+ for: scope,
218
+ icon: rules.icon,
219
+ total: count,
220
+ huntedRefs,
221
+ huntedQsos: qsos,
222
+ label: `${rules.label}: ${count === 1 ? t("huntedOne", { noun }) : t("huntedMany", { noun, formatted: fmtInteger(count) })}`
223
+ };
224
+ }
225
+ export {
226
+ activityScorer
227
+ };
@@ -0,0 +1,42 @@
1
+ function installAppClock(global = globalThis) {
2
+ const RealDate = global.Date;
3
+ const realNow = () => RealDate.now();
4
+ let anchorReal = 0;
5
+ let anchorVirtual = 0;
6
+ let speed = 1;
7
+ const appNow = () => {
8
+ if (anchorReal === 0) return realNow();
9
+ return Math.round(anchorVirtual + (realNow() - anchorReal) * speed);
10
+ };
11
+ const appNowStatic = () => appNow();
12
+ const AppDate = new Proxy(RealDate, {
13
+ construct(target, args, newTarget) {
14
+ return Reflect.construct(target, args.length === 0 ? [appNow()] : args, newTarget);
15
+ },
16
+ // `Date()` without `new` — spec behaviour is "the current time as a
17
+ // string", so it follows the app clock the same way construction does.
18
+ apply() {
19
+ return new RealDate(appNow()).toString();
20
+ },
21
+ get(target, prop, receiver) {
22
+ if (prop === "now") return appNowStatic;
23
+ return Reflect.get(target, prop, receiver);
24
+ }
25
+ });
26
+ global.Date = AppDate;
27
+ if (global.Date !== AppDate) {
28
+ throw new Error("appClock: the sandbox Date could not be replaced; extensions would read real time");
29
+ }
30
+ return {
31
+ realNow,
32
+ appNow,
33
+ setClockTravel(anchorRealMillis, anchorVirtualMillis, nextSpeed) {
34
+ anchorReal = anchorRealMillis;
35
+ anchorVirtual = anchorVirtualMillis;
36
+ speed = nextSpeed;
37
+ }
38
+ };
39
+ }
40
+ export {
41
+ installAppClock
42
+ };
package/dist/dxcc.js ADDED
@@ -0,0 +1,25 @@
1
+ import { parseCallsign } from "@ham2k/lib-callsigns";
2
+ import { annotateFromCountryFile, useBuiltinCountryFile } from "@ham2k/lib-country-files";
3
+ import { DXCC_BY_CODE } from "@ham2k/lib-dxcc-data";
4
+ useBuiltinCountryFile();
5
+ function annotateCallAgainstCountryFile(call) {
6
+ return annotateFromCountryFile(parseCallsign(call));
7
+ }
8
+ function entityPrefixForCall(call) {
9
+ if (!call) return void 0;
10
+ return annotateCallAgainstCountryFile(call).entityPrefix;
11
+ }
12
+ function flagForDxccCode(dxccCode) {
13
+ return dxccCode == null ? void 0 : DXCC_BY_CODE[dxccCode]?.flag;
14
+ }
15
+ function regionAndCountryForDxccCode(dxccCode) {
16
+ if (dxccCode == null) return {};
17
+ const entity = DXCC_BY_CODE[dxccCode];
18
+ return { ituRegion: entity?.ituRegion, countryCode: entity?.countryCode };
19
+ }
20
+ export {
21
+ annotateCallAgainstCountryFile,
22
+ entityPrefixForCall,
23
+ flagForDxccCode,
24
+ regionAndCountryForDxccCode
25
+ };
@@ -0,0 +1,57 @@
1
+ import { dateValues, logValues } from "./templateContext.js";
2
+ import { oneLine, renderTemplate } from "./templates.js";
3
+ import { isTestOperation } from "./testOperation.js";
4
+ const NAME_TEMPLATES = {
5
+ // Date first: chronological order, and it reads as a sentence. Whitespace
6
+ // does collapse here (see [oneLine]), so an absent part just closes up.
7
+ NormalRefName: "{{ op.date }} {{ log.station }} at {{ log.ref }} {{ log.modifier }}",
8
+ NormalActivityName: "{{ op.date }} {{ log.station }} for {{ log.activity }} {{ log.modifier }}",
9
+ NormalName: "{{ op.date }} {{ log.station }} {{ log.modifier }}",
10
+ // Callsign first: these sort together per station, which is the order that
11
+ // matters once a season's uploads share one folder. `dash` preserves case
12
+ // for identifiers (`N0CALL/P` → `N0CALL-P`, `US-1234` unchanged) while
13
+ // free text goes through `downcase | dash`.
14
+ // Each part is slugged into a variable and the SLUG is what's tested: a
15
+ // part that is punctuation only (`!!!`) is not blank but slugs to nothing,
16
+ // and testing the raw value would leave the separator behind — a double
17
+ // dash where the old code's filtered join left none.
18
+ CompactRefName: "{% assign s = log.station | dash %}{% assign m = log.modifier | downcase | dash %}{% if s != blank %}{{ s }}@{% endif %}{{ log.ref | dash }}-{{ op.dateCompact }}{% if m != blank %}-{{ m }}{% endif %}",
19
+ CompactActivityName: "{% assign s = log.station | dash %}{% assign a = log.activity | downcase | dash %}{% assign m = log.modifier | downcase | dash %}{% if s != blank %}{{ s }}-{% endif %}{% if a != blank %}{{ a }}-{% endif %}{{ op.dateCompact }}{% if m != blank %}-{{ m }}{% endif %}",
20
+ CompactName: "{% assign s = log.station | dash %}{% assign m = log.modifier | downcase | dash %}{% if s != blank %}{{ s }}-{% endif %}{{ op.dateCompact }}{% if m != blank %}-{{ m }}{% endif %}"
21
+ };
22
+ function sanitize(name) {
23
+ return name.replace(/[/\\:*?"<>|]+/g, "-").replace(/\s+/g, " ").trim();
24
+ }
25
+ function exportFilename(parts) {
26
+ const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
27
+ const extension = parts.extension.replace(/^\./, "");
28
+ const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
29
+ const modifier = [
30
+ (parts.modifier ?? "").trim(),
31
+ isTestOperation(parts.stationCall) ? "Testing" : ""
32
+ ].filter((part) => part !== "").join(" ");
33
+ const stem = oneLine(
34
+ renderTemplate(NAME_TEMPLATES[shape], {
35
+ op: dateValues(millis),
36
+ log: logValues({
37
+ station: String(parts.stationCall ?? "").trim(),
38
+ ref: parts.ref,
39
+ activity: parts.activity,
40
+ modifier,
41
+ extension,
42
+ compact: parts.compact
43
+ })
44
+ }, shape)
45
+ );
46
+ return `${sanitize(stem) || "export"}.${extension}`;
47
+ }
48
+ function startMillisOf(operation, qsos = []) {
49
+ const earliest = qsos.map((qso) => Number(qso.startAtMillis ?? 0)).filter((millis) => millis > 0).sort((a, b) => a - b)[0];
50
+ if (earliest) return earliest;
51
+ const operationStart = Number(operation.startAtMillisMin ?? operation.startAtMillis ?? 0);
52
+ return operationStart > 0 ? operationStart : void 0;
53
+ }
54
+ export {
55
+ exportFilename,
56
+ startMillisOf
57
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "adifForRef": "ADIF for {{program}} {{ref}}",
3
+ "adifForHunter": "{{program}} Hunter Log"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "adifForRef": "ADIF para {{program}} {{ref}}",
3
+ "adifForHunter": "Registro de Cazador {{program}}"
4
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "activationQsoOne": "1 activation QSO",
3
+ "activationQsoMany": "{{formatted}} activation QSOs",
4
+ "p2pQsoOne": "1 {{label}} QSO",
5
+ "p2pQsoMany": "{{formatted}} {{label}} QSOs",
6
+ "ref2ref": "ref2ref",
7
+ "reference": "reference",
8
+ "referencesPlural": "references",
9
+ "huntedOne": "1 {{noun}} hunted",
10
+ "huntedMany": "{{formatted}} {{noun}} hunted"
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "activationQsoOne": "1 QSO de activación",
3
+ "activationQsoMany": "{{formatted}} QSOs de activación",
4
+ "p2pQsoOne": "1 QSO {{label}}",
5
+ "p2pQsoMany": "{{formatted}} QSOs {{label}}",
6
+ "ref2ref": "ref2ref",
7
+ "reference": "referencia",
8
+ "referencesPlural": "referencias",
9
+ "huntedOne": "1 {{noun}} cazado",
10
+ "huntedMany": "{{formatted}} {{noun}} cazados"
11
+ }