@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.
@@ -0,0 +1,163 @@
1
+ const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
2
+ function copyCheckpoint(sheet) {
3
+ return JSON.parse(JSON.stringify(sheet));
4
+ }
5
+ function dayOf(qso) {
6
+ const millis = Number(qso.startAtMillis ?? 0);
7
+ return millis - millis % DAY_IN_MILLIS;
8
+ }
9
+ function chronological(qsos) {
10
+ return [...qsos].sort((a, b) => Number(a.startAtMillis ?? 0) - Number(b.startAtMillis ?? 0));
11
+ }
12
+ function isEvent(qso) {
13
+ return qso.band === "event";
14
+ }
15
+ function startAtMillisOf(qso) {
16
+ const at = qso.startAtMillis;
17
+ return typeof at === "number" ? at : 0;
18
+ }
19
+ function contestScorer(scorer, options = {}) {
20
+ const scope = options.scope ?? "always";
21
+ const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
22
+ function refOfScopedType(operation) {
23
+ if (!scopedRefTypes) return void 0;
24
+ const refs = operation.refs ?? [];
25
+ return refs.find((r) => typeof r?.type === "string" && scopedRefTypes.includes(r.type));
26
+ }
27
+ function fold(scoresheet, qsos, args, ctx, onQso, onDayEnd, segments) {
28
+ let sheet = scoresheet;
29
+ let currentDay;
30
+ let dayCount = 0;
31
+ const timeline = segments && segments.length > 1 ? segments : void 0;
32
+ let segmentIndex = 0;
33
+ for (const qso of qsos) {
34
+ if (isEvent(qso)) continue;
35
+ let operation = args.operation;
36
+ let ref = args.ref;
37
+ if (timeline) {
38
+ const at = startAtMillisOf(qso);
39
+ while (segmentIndex + 1 < timeline.length && timeline[segmentIndex + 1].fromMillis <= at) segmentIndex += 1;
40
+ operation = timeline[segmentIndex].operation;
41
+ if (scopedRefTypes) {
42
+ const segmentRef = refOfScopedType(operation);
43
+ if (!segmentRef) continue;
44
+ ref = segmentRef;
45
+ }
46
+ }
47
+ const day = dayOf(qso);
48
+ const isNewDay = currentDay !== day;
49
+ if (isNewDay) {
50
+ if (currentDay !== void 0) onDayEnd?.(currentDay, dayCount, sheet);
51
+ currentDay = day;
52
+ dayCount = 0;
53
+ }
54
+ dayCount += 1;
55
+ const result = scorer.scoreQso({ scoresheet: sheet, qso, operation, ref, isNewDay }, ctx);
56
+ sheet = result.scoresheet;
57
+ onQso?.(typeof qso.uuid === "string" ? qso.uuid : void 0, result.score);
58
+ }
59
+ if (currentDay !== void 0) onDayEnd?.(currentDay, dayCount, sheet);
60
+ return sheet;
61
+ }
62
+ async function liveScoresheet(args, ctx, excludeUuid) {
63
+ if (args.resumeFrom !== void 0) {
64
+ return copyCheckpoint(args.resumeFrom);
65
+ }
66
+ const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
67
+ const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
68
+ const rest = chronological(log).filter(
69
+ (q) => !excludeUuid || q.uuid !== excludeUuid
70
+ );
71
+ return fold(
72
+ scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
73
+ rest,
74
+ args,
75
+ ctx,
76
+ void 0,
77
+ void 0,
78
+ args.segments
79
+ );
80
+ }
81
+ return {
82
+ scope,
83
+ async scoreQsos(args, ctx) {
84
+ const qsos = chronological(args.qsos);
85
+ const start = args.resumeFrom !== void 0 ? copyCheckpoint(args.resumeFrom) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
86
+ const qsoScores = {};
87
+ const daySections = [];
88
+ const end = fold(
89
+ start,
90
+ qsos,
91
+ args,
92
+ ctx,
93
+ (uuid, score) => {
94
+ if (uuid) qsoScores[uuid] = score;
95
+ },
96
+ (day, count, sheet) => {
97
+ daySections.push({
98
+ day,
99
+ count,
100
+ scores: scorer.summarizeScore({ scoresheet: sheet, operation: args.operation, ref: args.ref, scope: "day" }, ctx)
101
+ });
102
+ },
103
+ args.segments
104
+ );
105
+ return {
106
+ qsoScores,
107
+ daySections,
108
+ operationSummary: scorer.summarizeScore(
109
+ { scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
110
+ ctx
111
+ ),
112
+ scoresheet: end
113
+ };
114
+ },
115
+ /// Live path: score the candidate against the log so far and report only
116
+ /// its notices/alerts. Nothing is recorded — the resulting scoresheet is
117
+ /// discarded, which is exactly why one `scoreQso` can serve both paths.
118
+ async scoreQso(args, ctx) {
119
+ const sheet = await liveScoresheet(args, ctx, args.excludeUuid);
120
+ const { score } = scorer.scoreQso(
121
+ { scoresheet: sheet, qso: args.qso, operation: args.operation, ref: args.ref, isNewDay: false },
122
+ ctx
123
+ );
124
+ return { notices: score.notices ?? [], alerts: score.alerts ?? [] };
125
+ },
126
+ /// The same live judgement as `scoreQso`, for many candidates at once —
127
+ /// the Spots Panel annotating every spot on screen. The log is folded ONCE
128
+ /// and each candidate scored against its own copy of the result, so a
129
+ /// candidate can neither see nor become a duplicate of another (both spots
130
+ /// for the same unworked call read as unworked, which is what an operator
131
+ /// scanning a band expects).
132
+ ///
133
+ /// The copy is per candidate rather than per call because
134
+ /// `ContestScorer.scoreQso` may mutate the scoresheet it is handed — the
135
+ /// contract that makes the batch fold linear. Without it the first
136
+ /// candidate's call would count as worked for every candidate after it.
137
+ async scoreCandidates(args, ctx) {
138
+ const base = await liveScoresheet(args, ctx);
139
+ const result = {};
140
+ for (const candidate of args.candidates) {
141
+ const { score } = scorer.scoreQso(
142
+ {
143
+ scoresheet: copyCheckpoint(base),
144
+ qso: candidate.qso,
145
+ operation: args.operation,
146
+ ref: args.ref,
147
+ isNewDay: false
148
+ },
149
+ ctx
150
+ );
151
+ result[candidate.key] = { notices: score.notices ?? [], alerts: score.alerts ?? [] };
152
+ }
153
+ return result;
154
+ }
155
+ };
156
+ }
157
+ function tally(key, forScope, total, extra = {}) {
158
+ return { key, for: forScope, total, ...extra };
159
+ }
160
+ export {
161
+ contestScorer,
162
+ tally
163
+ };
@@ -0,0 +1,59 @@
1
+ function isValidHookKey(category, extensionKey, resolvedKey) {
2
+ if (category !== "settingsPanel" && category !== "account") return true;
3
+ return resolvedKey === extensionKey || resolvedKey.startsWith(`${extensionKey}_`);
4
+ }
5
+ function tier1FormDefinition(hook) {
6
+ return { title: hook.title, elements: hook.fields ?? [] };
7
+ }
8
+ function settingsPanelEntriesFor(record, descriptors) {
9
+ const hook = record.hook;
10
+ if (hook.kind === "simple") {
11
+ return [
12
+ {
13
+ panelKey: record.key,
14
+ hookKey: record.key,
15
+ extensionKey: record.extensionKey,
16
+ title: hook.title ?? null,
17
+ icon: hook.icon ?? null,
18
+ order: hook.order ?? null,
19
+ kind: "simple",
20
+ sidebar: hook.sidebar ?? true,
21
+ dataFilesSection: hook.dataFilesSection ?? false
22
+ }
23
+ ];
24
+ }
25
+ if (hook.kind === "dynamic") {
26
+ if (descriptors) {
27
+ return descriptors.filter((d) => isValidHookKey("settingsPanel", record.extensionKey, d.key)).map((d) => ({
28
+ panelKey: d.key,
29
+ hookKey: record.key,
30
+ extensionKey: record.extensionKey,
31
+ title: d.title ?? null,
32
+ icon: d.icon ?? null,
33
+ order: d.order ?? null,
34
+ kind: "dynamic",
35
+ sidebar: d.sidebar ?? true,
36
+ dataFilesSection: d.dataFilesSection ?? false
37
+ }));
38
+ }
39
+ return [
40
+ {
41
+ panelKey: record.key,
42
+ hookKey: record.key,
43
+ extensionKey: record.extensionKey,
44
+ title: null,
45
+ icon: null,
46
+ order: null,
47
+ kind: "dynamic",
48
+ sidebar: true,
49
+ dataFilesSection: false
50
+ }
51
+ ];
52
+ }
53
+ return [];
54
+ }
55
+ export {
56
+ isValidHookKey,
57
+ settingsPanelEntriesFor,
58
+ tier1FormDefinition
59
+ };
@@ -0,0 +1,113 @@
1
+ function utcDate(millis) {
2
+ return new Date(millis).toISOString().slice(0, 10);
3
+ }
4
+ function utcDateCompact(millis) {
5
+ return utcDate(millis).replace(/-/g, "");
6
+ }
7
+ function utcTime(millis) {
8
+ return new Date(millis).toISOString().slice(11, 16).replace(":", "");
9
+ }
10
+ function utcIso(millis) {
11
+ return new Date(millis).toISOString();
12
+ }
13
+ function dateValues(millis) {
14
+ return {
15
+ date: utcDate(millis),
16
+ dateCompact: utcDateCompact(millis),
17
+ time: utcTime(millis),
18
+ at: utcIso(millis)
19
+ };
20
+ }
21
+ function opValues(operation, extra = {}) {
22
+ const station = String(operation.stationCall ?? "");
23
+ const startMillis = Number(operation.startAtMillisMin ?? operation.startAtMillis ?? 0);
24
+ const endMillis = Number(operation.startAtMillisMax ?? 0);
25
+ const atMillis = extra.atMillis && extra.atMillis > 0 ? extra.atMillis : startMillis;
26
+ return {
27
+ ...atMillis > 0 ? dateValues(atMillis) : { date: "", dateCompact: "", time: "", at: "" },
28
+ station,
29
+ // Deduped, like halo_core's `Operation.stationCalls`: "N0DEV, N0DEV" is a
30
+ // typo, and a template fanning out over this would otherwise repeat
31
+ // itself once per typo.
32
+ stations: [
33
+ ...new Set(
34
+ station.split(",").map((call) => call.trim().toUpperCase()).filter((call) => call !== "")
35
+ )
36
+ ],
37
+ operator: operation.operator ?? "",
38
+ // The generated, ref-derived title ("at US-1234") and the operator's own
39
+ // words, kept apart: an export that withholds private data withholds the
40
+ // second and keeps the first (see the adif hook's preamble).
41
+ title: operation.title ?? "",
42
+ userTitle: operation.userTitle ?? "",
43
+ grid: operation.grid ?? "",
44
+ refs: operation.refs ?? [],
45
+ uuid: operation.uuid ?? "",
46
+ qsoCount: extra.qsoCount ?? Number(operation.qsoCount ?? 0),
47
+ startDate: startMillis > 0 ? utcDate(startMillis) : "",
48
+ startTime: startMillis > 0 ? utcTime(startMillis) : "",
49
+ startAt: startMillis > 0 ? utcIso(startMillis) : "",
50
+ endDate: endMillis > 0 ? utcDate(endMillis) : "",
51
+ endTime: endMillis > 0 ? utcTime(endMillis) : "",
52
+ endAt: endMillis > 0 ? utcIso(endMillis) : ""
53
+ };
54
+ }
55
+ function qsoValues(qso) {
56
+ const their = qso.their ?? {};
57
+ const our = qso.our ?? {};
58
+ const startMillis = Number(qso.startAtMillis ?? 0);
59
+ return {
60
+ call: their.call ?? "",
61
+ their,
62
+ our,
63
+ band: qso.band ?? "",
64
+ mode: qso.mode ?? "",
65
+ freq: qso.freq ?? "",
66
+ // Named for what an operator calls them, not for QSON's `sent` on both
67
+ // sides — `qso.our.sent` is what WE sent, i.e. the report they received.
68
+ rstSent: our.sent ?? "",
69
+ rstRcvd: their.sent ?? "",
70
+ notes: qso.notes ?? "",
71
+ refs: qso.refs ?? [],
72
+ ...startMillis > 0 ? dateValues(startMillis) : { date: "", dateCompact: "", time: "", at: "" },
73
+ startAtMillis: startMillis
74
+ };
75
+ }
76
+ function logValues(values) {
77
+ return {
78
+ station: values.station ?? "",
79
+ ref: values.ref ?? "",
80
+ refName: values.refName ?? "",
81
+ refShortName: values.refShortName ?? "",
82
+ activity: values.activity ?? "",
83
+ handlerName: values.handlerName ?? "",
84
+ handlerShortName: values.handlerShortName ?? "",
85
+ format: values.format ?? "",
86
+ exportType: values.exportType ?? "",
87
+ modifier: values.modifier ?? "",
88
+ extension: values.extension ?? "",
89
+ compact: values.compact === true
90
+ };
91
+ }
92
+ function templateContext(args) {
93
+ const nowMillis = args.nowMillis ?? Date.now();
94
+ return {
95
+ app: { name: args.appName ?? "" },
96
+ now: utcIso(nowMillis),
97
+ ...args.operation ? { op: opValues(args.operation, { qsoCount: args.qsoCount, atMillis: args.atMillis }) } : {},
98
+ ...args.qso ? { qso: qsoValues(args.qso) } : {},
99
+ ...args.log ? { log: logValues(args.log) } : {},
100
+ ...args.config ? { config: args.config } : {}
101
+ };
102
+ }
103
+ export {
104
+ dateValues,
105
+ logValues,
106
+ opValues,
107
+ qsoValues,
108
+ templateContext,
109
+ utcDate,
110
+ utcDateCompact,
111
+ utcIso,
112
+ utcTime
113
+ };
@@ -0,0 +1,76 @@
1
+ import { Liquid } from "liquidjs";
2
+ const RENDER_LIMIT_MS = 250;
3
+ let engine;
4
+ function liquid() {
5
+ if (engine) return engine;
6
+ engine = new Liquid({
7
+ // UTC, always. Amateur radio logs are kept in UTC, and liquid's `date`
8
+ // filter otherwise formats in the HOST's zone — wrong in a way no
9
+ // tester in UTC±0 can see, exactly like `toLocaleString` with a
10
+ // `timeZone` on this engine (docs/extensions/README.md).
11
+ //
12
+ // The sign is INVERTED from what the name suggests: this follows
13
+ // `Date.getTimezoneOffset()`, i.e. minutes BEHIND UTC, so -300 would
14
+ // render UTC+5. Zero is the only value with no trap in it.
15
+ timezoneOffset: 0,
16
+ // Lenient, because most templates are written by operators rather than
17
+ // by us: an unknown variable renders empty and an unknown filter passes
18
+ // its value through, so a typo costs one wrong line instead of the whole
19
+ // document. Our own templates are covered by tests instead.
20
+ strictVariables: false,
21
+ strictFilters: false,
22
+ renderLimit: RENDER_LIMIT_MS
23
+ });
24
+ registerFilters(engine);
25
+ return engine;
26
+ }
27
+ function registerFilters(liquid2) {
28
+ liquid2.registerFilter(
29
+ "dash",
30
+ (value) => String(value ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")
31
+ );
32
+ liquid2.registerFilter("alnum", (value) => String(value ?? "").replace(/[^A-Za-z0-9]/g, ""));
33
+ }
34
+ class TemplateError extends Error {
35
+ constructor(message, options) {
36
+ super(message);
37
+ this.name = "TemplateError";
38
+ this.cause = options?.cause;
39
+ }
40
+ }
41
+ const parsed = /* @__PURE__ */ new Map();
42
+ const PARSE_CACHE_LIMIT = 64;
43
+ function renderTemplate(template, context, label) {
44
+ const source = template ?? "";
45
+ try {
46
+ let compiled = parsed.get(source);
47
+ if (!compiled) {
48
+ compiled = liquid().parse(source);
49
+ if (parsed.size >= PARSE_CACHE_LIMIT) parsed.clear();
50
+ parsed.set(source, compiled);
51
+ }
52
+ return liquid().renderSync(compiled, context);
53
+ } catch (e) {
54
+ const message = e instanceof Error ? e.message : String(e);
55
+ throw new TemplateError(label ? `${label}: ${message}` : message, { cause: e });
56
+ }
57
+ }
58
+ function oneLine(text) {
59
+ return text.replace(/\s+/g, " ").trim();
60
+ }
61
+ function triggersForTemplate(template) {
62
+ const text = (template ?? "").match(/\{\{.*?\}\}|\{%.*?%\}/gs)?.join(" ") ?? "";
63
+ const mentions = (name) => new RegExp(`\\b${name}\\b`).test(text);
64
+ const triggers = [];
65
+ if (mentions("qso")) triggers.push("qso", "lookup");
66
+ if (mentions("qsoCount")) triggers.push("qsoLogged");
67
+ if (mentions("op") || mentions("operation")) triggers.push("operation");
68
+ if (mentions("now")) triggers.push("tick:30");
69
+ return triggers;
70
+ }
71
+ export {
72
+ TemplateError,
73
+ oneLine,
74
+ renderTemplate,
75
+ triggersForTemplate
76
+ };
@@ -0,0 +1,6 @@
1
+ function isTestOperation(stationCall) {
2
+ return String(stationCall ?? "").split(",").flatMap((call) => call.trim().toUpperCase().split("/")).some((segment) => segment === "T" || segment === "TEST");
3
+ }
4
+ export {
5
+ isTestOperation
6
+ };
package/dist/types.js ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,110 @@
1
+ {
2
+ "name": "@ham2k/extension-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
+ "keywords": [
6
+ "ham2k",
7
+ "ham radio",
8
+ "amateur radio",
9
+ "extension",
10
+ "sdk",
11
+ "logging"
12
+ ],
13
+ "license": "MPL-2.0",
14
+ "author": "Sebastian Delmont <sd@ham2k.com>",
15
+ "homepage": "https://ham2k.com",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ham2k/halo.git",
19
+ "directory": "extensions/sdk"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "types": "./dist/index.d.ts",
29
+ "main": "./dist/index.js",
30
+ "//sideEffects": [
31
+ "Importing any of these modules does nothing observable, so a bundler may",
32
+ "drop whichever ones an extension never reaches. dxcc is the exception and",
33
+ "is named rather than waved through: it registers the built-in country file",
34
+ "at import time, and a bundler that dropped it would leave callsign",
35
+ "annotation quietly answering with nothing."
36
+ ],
37
+ "sideEffects": [
38
+ "./dist/dxcc.js"
39
+ ],
40
+ "files": [
41
+ "dist",
42
+ "README.md"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "scripts": {
48
+ "build": "node build.mjs"
49
+ },
50
+ "//peerDependencies": [
51
+ "The libraries the extension host carries. An extension does NOT need these",
52
+ "installed: the build preset rewrites each one the manifest declares into a",
53
+ "lookup on the host's single instance, before esbuild ever resolves it, and",
54
+ "the published .d.ts carries their types rolled in. They are here, optional,",
55
+ "for the one case that does reach the filesystem \u2014 `inline: [...]`, which",
56
+ "ships your own copy because you need a version the host does not carry."
57
+ ],
58
+ "peerDependencies": {
59
+ "@ham2k/lib-callsigns": "*",
60
+ "@ham2k/lib-country-files": "*",
61
+ "@ham2k/lib-cqmag-data": "*",
62
+ "@ham2k/lib-dxcc-data": "*",
63
+ "@ham2k/lib-format-tools": "*",
64
+ "@ham2k/lib-geo-tools": "*",
65
+ "@ham2k/lib-operation-data": "*",
66
+ "@ham2k/lib-qson-adif": "*",
67
+ "@ham2k/lib-qson-cabrillo": "*",
68
+ "@ham2k/lib-qson-tools": "*",
69
+ "i18next": "*",
70
+ "liquidjs": "*"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@ham2k/lib-callsigns": {
74
+ "optional": true
75
+ },
76
+ "@ham2k/lib-country-files": {
77
+ "optional": true
78
+ },
79
+ "@ham2k/lib-cqmag-data": {
80
+ "optional": true
81
+ },
82
+ "@ham2k/lib-dxcc-data": {
83
+ "optional": true
84
+ },
85
+ "@ham2k/lib-format-tools": {
86
+ "optional": true
87
+ },
88
+ "@ham2k/lib-geo-tools": {
89
+ "optional": true
90
+ },
91
+ "@ham2k/lib-operation-data": {
92
+ "optional": true
93
+ },
94
+ "@ham2k/lib-qson-adif": {
95
+ "optional": true
96
+ },
97
+ "@ham2k/lib-qson-cabrillo": {
98
+ "optional": true
99
+ },
100
+ "@ham2k/lib-qson-tools": {
101
+ "optional": true
102
+ },
103
+ "i18next": {
104
+ "optional": true
105
+ },
106
+ "liquidjs": {
107
+ "optional": true
108
+ }
109
+ }
110
+ }