@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/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
|
+
};
|
package/dist/appClock.js
ADDED
|
@@ -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,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
|
+
}
|