@ham2k/extension-sdk 0.5.6 → 0.6.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/AGENTS.md +10 -9
- package/README.md +6 -6
- package/dist/activityExports.js +38 -23
- package/dist/activityScoring.js +3 -9
- package/dist/base64.js +38 -0
- package/dist/copyOnWrite.js +71 -0
- package/dist/exportNames.js +16 -15
- package/dist/exportSettings.js +11 -1
- package/dist/index.d.ts +39 -3
- package/dist/index.js +85 -0
- package/dist/scoring.js +57 -34
- package/docs/distribution.md +11 -8
- package/docs/hooks.md +53 -21
- package/docs/templates.md +1 -1
- package/package.json +1 -1
- package/samples/k2hrc-cqww/build.mjs +3 -3
- package/samples/k2hrc-cqww/src/index.ts +6 -15
- package/samples/k2hrc-hamqth/build.mjs +3 -3
- package/samples/k2hrc-hamqth/src/index.ts +4 -8
- package/samples/k2hrc-llota/build.mjs +3 -3
- package/samples/k2hrc-llota/src/index.ts +4 -7
- package/samples/k2hrc-radio/build.mjs +3 -3
- package/samples/k2hrc-radio/src/index.ts +1 -5
- package/samples/k2hrc-svg-scenes/build.mjs +3 -3
- package/samples/k2hrc-svg-scenes/manifest.json +2 -10
- package/samples/k2hrc-svg-scenes/src/index.ts +4 -4
- package/samples/k2hrc-svg-scenes/src/scenes.ts +195 -80
package/dist/scoring.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { copyOnWrite } from "./copyOnWrite.js";
|
|
1
2
|
const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
|
|
3
|
+
const HELD_CHECKPOINTS = 4;
|
|
4
|
+
const RESUME_MISSING = "resume-missing";
|
|
5
|
+
const CHECKPOINT_HELD_KEY = "$checkpointHeld";
|
|
2
6
|
function copySheet(sheet) {
|
|
3
7
|
return JSON.parse(JSON.stringify(sheet));
|
|
4
8
|
}
|
|
5
|
-
function
|
|
6
|
-
return
|
|
9
|
+
function operationUuidOf(operation) {
|
|
10
|
+
return typeof operation.uuid === "string" ? operation.uuid : "";
|
|
7
11
|
}
|
|
8
12
|
function dayOf(qso) {
|
|
9
13
|
const millis = Number(qso.startAtMillis ?? 0);
|
|
@@ -25,6 +29,26 @@ function candidateAsOfNow(qso) {
|
|
|
25
29
|
function contestScorer(scorer, options = {}) {
|
|
26
30
|
const scope = options.scope ?? "always";
|
|
27
31
|
const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
|
|
32
|
+
const held = /* @__PURE__ */ new Map();
|
|
33
|
+
function hold(operationUuid, key, sheet, day) {
|
|
34
|
+
held.delete(operationUuid);
|
|
35
|
+
held.set(operationUuid, { key, sheet, day });
|
|
36
|
+
while (held.size > HELD_CHECKPOINTS) held.delete(held.keys().next().value);
|
|
37
|
+
}
|
|
38
|
+
function resolveCheckpoint(args) {
|
|
39
|
+
const operationUuid = operationUuidOf(args.operation);
|
|
40
|
+
const key = typeof args.resumeKey === "string" ? args.resumeKey : void 0;
|
|
41
|
+
if (args.resumeFrom !== void 0) {
|
|
42
|
+
const day = typeof args.resumeDay === "number" ? args.resumeDay : void 0;
|
|
43
|
+
const sheet = args.resumeFrom;
|
|
44
|
+
if (key !== void 0) hold(operationUuid, key, sheet, day);
|
|
45
|
+
return { key: key ?? "", sheet, day };
|
|
46
|
+
}
|
|
47
|
+
if (key === void 0) return void 0;
|
|
48
|
+
const entry = held.get(operationUuid);
|
|
49
|
+
if (entry === void 0 || entry.key !== key) throw new Error(`${RESUME_MISSING}: ${key}`);
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
28
52
|
function refOfScopedType(operation) {
|
|
29
53
|
if (!scopedRefTypes) return void 0;
|
|
30
54
|
const refs = operation.refs ?? [];
|
|
@@ -66,33 +90,24 @@ function contestScorer(scorer, options = {}) {
|
|
|
66
90
|
return { sheet, day: currentDay };
|
|
67
91
|
}
|
|
68
92
|
async function liveScoresheet(args, ctx, excludeUuid) {
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
const resumed = resolveCheckpoint(args);
|
|
94
|
+
if (resumed !== void 0) {
|
|
95
|
+
const sheet = copyOnWrite(resumed.sheet);
|
|
71
96
|
const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
|
|
72
|
-
if (tail.length === 0) return { sheet, day };
|
|
73
|
-
return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
|
|
97
|
+
if (tail.length === 0) return { sheet, day: resumed.day };
|
|
98
|
+
return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, resumed.day);
|
|
74
99
|
}
|
|
75
100
|
const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
|
|
76
101
|
const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
|
|
77
|
-
const rest = chronological(log).filter(
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
return fold(
|
|
81
|
-
scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
|
|
82
|
-
rest,
|
|
83
|
-
args,
|
|
84
|
-
ctx,
|
|
85
|
-
void 0,
|
|
86
|
-
void 0,
|
|
87
|
-
args.segments
|
|
88
|
-
);
|
|
102
|
+
const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
|
|
103
|
+
return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
|
|
89
104
|
}
|
|
90
105
|
return {
|
|
91
106
|
scope,
|
|
92
107
|
async scoreQsos(args, ctx) {
|
|
93
108
|
const qsos = chronological(args.qsos);
|
|
94
|
-
const resumed =
|
|
95
|
-
const start = resumed
|
|
109
|
+
const resumed = resolveCheckpoint(args);
|
|
110
|
+
const start = resumed !== void 0 ? copySheet(resumed.sheet) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
|
|
96
111
|
const qsoScores = {};
|
|
97
112
|
const daySections = [];
|
|
98
113
|
const { sheet: end, day: endDay } = fold(
|
|
@@ -113,15 +128,15 @@ function contestScorer(scorer, options = {}) {
|
|
|
113
128
|
args.segments,
|
|
114
129
|
resumed?.day
|
|
115
130
|
);
|
|
131
|
+
const checkpointHeld = typeof args.checkpointKey === "string";
|
|
132
|
+
if (checkpointHeld) hold(operationUuidOf(args.operation), args.checkpointKey, end, endDay);
|
|
116
133
|
return {
|
|
117
134
|
qsoScores,
|
|
118
135
|
daySections,
|
|
119
|
-
operationSummary: scorer.summarizeScore(
|
|
120
|
-
{ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
|
|
121
|
-
ctx
|
|
122
|
-
),
|
|
136
|
+
operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
|
|
123
137
|
scoresheet: end,
|
|
124
|
-
...endDay !== void 0 && { scoresheetDay: endDay }
|
|
138
|
+
...endDay !== void 0 && { scoresheetDay: endDay },
|
|
139
|
+
...checkpointHeld && { checkpointHeld: true }
|
|
125
140
|
};
|
|
126
141
|
},
|
|
127
142
|
/// Live path: score the candidate against the log so far and report only
|
|
@@ -129,30 +144,36 @@ function contestScorer(scorer, options = {}) {
|
|
|
129
144
|
/// discarded, which is exactly why one `scoreQso` can serve both paths.
|
|
130
145
|
async scoreQso(args, ctx) {
|
|
131
146
|
const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
|
|
132
|
-
const { score } = scorer.scoreQso(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
|
|
148
|
+
const checkpointHeld = typeof args.resumeKey === "string";
|
|
149
|
+
return {
|
|
150
|
+
notices: score.notices ?? [],
|
|
151
|
+
alerts: score.alerts ?? [],
|
|
152
|
+
...score.dupe !== void 0 && { dupe: score.dupe },
|
|
153
|
+
...checkpointHeld && { checkpointHeld: true }
|
|
154
|
+
};
|
|
137
155
|
},
|
|
138
156
|
/// The same live judgement as `scoreQso`, for many candidates at once —
|
|
139
157
|
/// the Spots Panel annotating every spot on screen. The log is folded ONCE
|
|
140
|
-
/// and each candidate scored against its own
|
|
158
|
+
/// and each candidate scored against its own view of the result, so a
|
|
141
159
|
/// candidate can neither see nor become a duplicate of another (both spots
|
|
142
160
|
/// for the same unworked call read as unworked, which is what an operator
|
|
143
161
|
/// scanning a band expects).
|
|
144
162
|
///
|
|
145
|
-
/// The
|
|
163
|
+
/// The view is per candidate rather than per call because
|
|
146
164
|
/// `ContestScorer.scoreQso` may mutate the scoresheet it is handed — the
|
|
147
165
|
/// contract that makes the batch fold linear. Without it the first
|
|
148
|
-
/// candidate's call would count as worked for every candidate after it.
|
|
166
|
+
/// candidate's call would count as worked for every candidate after it. A
|
|
167
|
+
/// view, not a copy: copying a long log's worked-call map forty times over
|
|
168
|
+
/// was the cost of the whole pass.
|
|
149
169
|
async scoreCandidates(args, ctx) {
|
|
150
170
|
const { sheet: base } = await liveScoresheet(args, ctx);
|
|
151
171
|
const result = {};
|
|
172
|
+
if (typeof args.resumeKey === "string") result[CHECKPOINT_HELD_KEY] = true;
|
|
152
173
|
for (const candidate of args.candidates) {
|
|
153
174
|
const { score } = scorer.scoreQso(
|
|
154
175
|
{
|
|
155
|
-
scoresheet:
|
|
176
|
+
scoresheet: copyOnWrite(base),
|
|
156
177
|
qso: candidateAsOfNow(candidate.qso),
|
|
157
178
|
operation: args.operation,
|
|
158
179
|
ref: args.ref,
|
|
@@ -174,6 +195,8 @@ function tally(key, forScope, total, extra = {}) {
|
|
|
174
195
|
return { key, for: forScope, total, ...extra };
|
|
175
196
|
}
|
|
176
197
|
export {
|
|
198
|
+
CHECKPOINT_HELD_KEY,
|
|
199
|
+
RESUME_MISSING,
|
|
177
200
|
contestScorer,
|
|
178
201
|
tally
|
|
179
202
|
};
|
package/docs/distribution.md
CHANGED
|
@@ -37,7 +37,7 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
37
37
|
"version": "1.2.3",
|
|
38
38
|
"description": "Notes about the stations I work",
|
|
39
39
|
"category": "dashboard",
|
|
40
|
-
"api":
|
|
40
|
+
"api": 2,
|
|
41
41
|
"domains": ["notes.example.org"],
|
|
42
42
|
"icon": "note-text"
|
|
43
43
|
}
|
|
@@ -45,7 +45,10 @@ The same manifest a built-in extension carries, plus `api`:
|
|
|
45
45
|
|
|
46
46
|
`api` is the extension API the bundle was built against. The host refuses one
|
|
47
47
|
it does not speak, so an older app meeting a newer bundle says so instead of
|
|
48
|
-
failing somewhere deep in a hook
|
|
48
|
+
failing somewhere deep in a hook — and the catalog does not list it for that
|
|
49
|
+
app at all. Declare the lowest version that has what the bundle uses, so it
|
|
50
|
+
reaches every app that can run it: 2 adds `host.webSocket`, and the packer
|
|
51
|
+
refuses a manifest declaring `webSockets` under anything less.
|
|
49
52
|
|
|
50
53
|
Three fields are **refused** in a distributed bundle, and it is worth
|
|
51
54
|
understanding why:
|
|
@@ -198,8 +201,8 @@ below — manifest, source, build script — so the rest of this section is what
|
|
|
198
201
|
change rather than what to type.
|
|
199
202
|
|
|
200
203
|
```js
|
|
201
|
-
import { build } from
|
|
202
|
-
import { buildExtension } from
|
|
204
|
+
import { build } from "esbuild"
|
|
205
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
203
206
|
|
|
204
207
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
205
208
|
```
|
|
@@ -224,7 +227,7 @@ own module state — and nothing anywhere that says so. If the second copy is
|
|
|
224
227
|
what you want, which is how you use a version the host does not carry, say so:
|
|
225
228
|
|
|
226
229
|
```js
|
|
227
|
-
await buildExtension(build, { dir: import.meta.dirname, inline: [
|
|
230
|
+
await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
|
|
228
231
|
```
|
|
229
232
|
|
|
230
233
|
`extensions/samples/hello-world` is the shortest complete example. Declaring
|
|
@@ -254,7 +257,7 @@ It reports every problem at once rather than one per run:
|
|
|
254
257
|
h2kext-pack: ./build is not ready to package:
|
|
255
258
|
manifest.json: missing required field 'name'
|
|
256
259
|
manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
|
|
257
|
-
manifest.json: missing 'api' — declare the extension API this was built against (currently
|
|
260
|
+
manifest.json: missing 'api' — declare the extension API this was built against (currently 2)
|
|
258
261
|
```
|
|
259
262
|
|
|
260
263
|
`--force-name` waives the naming convention below. It exists for Ham2K's own
|
|
@@ -318,8 +321,8 @@ needs one degrades the same way it does in a build without that value
|
|
|
318
321
|
configured.
|
|
319
322
|
|
|
320
323
|
The sandbox is otherwise the same one the built-in extensions run in — no
|
|
321
|
-
filesystem, no network beyond the `domains`
|
|
322
|
-
approved, no timers.
|
|
324
|
+
filesystem, no network beyond the `domains` and `webSockets` the manifest
|
|
325
|
+
declares and the user approved, no timers.
|
|
323
326
|
|
|
324
327
|
## Installing one
|
|
325
328
|
|
package/docs/hooks.md
CHANGED
|
@@ -499,11 +499,13 @@ interface ActivityHook {
|
|
|
499
499
|
suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
|
|
500
500
|
processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
|
|
501
501
|
}
|
|
502
|
-
// LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?,
|
|
503
|
-
// optionType?, allowsMultiple?, editable?, input}
|
|
502
|
+
// LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
|
|
503
|
+
// order?, optionType?, allowsMultiple?, editable?, input}
|
|
504
504
|
// `shortLabel` is the same name in fewer characters, for a field the logging
|
|
505
505
|
// row has squeezed below its label's width. The core measures and picks; the
|
|
506
506
|
// extension only says what the short form is.
|
|
507
|
+
// `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
|
|
508
|
+
// the arrow keys still reach it. Every shipped contest sets it on Our Serial.
|
|
507
509
|
// SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
|
|
508
510
|
// ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
|
|
509
511
|
|
|
@@ -1161,8 +1163,10 @@ standing beside your credit, on the one row that can show only one.
|
|
|
1161
1163
|
A scorer's internal *rules* stay private to its scoresheet: contests don't
|
|
1162
1164
|
share a rule vocabulary, so only notices, alerts, points and summary tallies
|
|
1163
1165
|
cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
|
|
1164
|
-
return it; copying accumulated state per QSO is quadratic
|
|
1165
|
-
copies
|
|
1166
|
+
return it; copying accumulated state per QSO is quadratic. The batch pass
|
|
1167
|
+
copies the checkpoint it resumes from, once; the live paths hand the scorer a
|
|
1168
|
+
copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
|
|
1169
|
+
writes land in the view and the held sheet is never touched.
|
|
1166
1170
|
|
|
1167
1171
|
```ts
|
|
1168
1172
|
// The wire shape the harness produces, for reference:
|
|
@@ -1171,17 +1175,19 @@ interface ScoringHook {
|
|
|
1171
1175
|
scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
|
|
1172
1176
|
scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
|
|
1173
1177
|
}
|
|
1174
|
-
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
|
|
1178
|
+
// ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
|
|
1175
1179
|
// ScoreQsosResult: {
|
|
1176
1180
|
// qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
|
|
1177
1181
|
// daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
|
|
1178
1182
|
// operationSummary: Record<tallyKey, ScoreTally>,
|
|
1179
1183
|
// scoresheet, // checkpoint to resume from
|
|
1180
1184
|
// scoresheetDay?, // the day it ends on, handed back as resumeDay
|
|
1185
|
+
// checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
|
|
1181
1186
|
// }
|
|
1182
|
-
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1187
|
+
// ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1188
|
+
// QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
|
|
1189
|
+
// ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
|
|
1190
|
+
// ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
|
|
1185
1191
|
```
|
|
1186
1192
|
|
|
1187
1193
|
All three arrive already implemented by `contestScorer()`; a scorer writes the
|
|
@@ -1192,22 +1198,48 @@ both live paths for free.
|
|
|
1192
1198
|
A candidate that carries no `startAtMillis` (the draft's time is still
|
|
1193
1199
|
automatic) reaches the scorer stamped with the current time on both live
|
|
1194
1200
|
paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
|
|
1195
|
-
candidate that states a time keeps it. On both live paths a
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1199
|
-
scores the candidate against the result. The host sends a
|
|
1200
|
-
it can prove the log is what the checkpoint folded plus
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1201
|
+
candidate that states a time keeps it. On both live paths a checkpoint, with
|
|
1202
|
+
the `qsos` logged since it, stands in for the whole-log read: the harness
|
|
1203
|
+
views the checkpoint copy-on-write, replays that tail onto the view
|
|
1204
|
+
(continuing the day `resumeDay` names, so no scorer's per-day tally restarts
|
|
1205
|
+
mid-day) and scores the candidate against the result. The host sends a
|
|
1206
|
+
checkpoint only when it can prove the log is what the checkpoint folded plus
|
|
1207
|
+
appended contacts; otherwise the harness reads the log through `ctx.getQsos`
|
|
1208
|
+
and folds it cold. The batched `scoreQsos` resumes the same way: after the
|
|
1209
|
+
first pass of a session, an append arrives as a checkpoint plus the appended
|
|
1210
|
+
contacts, and only their verdicts come back.
|
|
1211
|
+
|
|
1212
|
+
**A checkpoint is held in the runtime and named, not sent, once it is
|
|
1213
|
+
there.** A sheet the size of a long log's worked-call map crossing the bridge
|
|
1214
|
+
on every keystroke would be most of the keystroke's cost. So the host names
|
|
1215
|
+
each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
|
|
1216
|
+
the batch pass produces it, and the harness keeps the end sheet under that
|
|
1217
|
+
name — one per operation, the last few operations. A live call, or the next
|
|
1218
|
+
append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
|
|
1219
|
+
`resumeKey` is filed under it too. A name the harness does not hold — a
|
|
1220
|
+
runtime restarted since, or one operation more than it keeps — is an error
|
|
1221
|
+
(`resume-missing`), never a silent cold fold, and the host answers it by
|
|
1222
|
+
sending the sheet once more. The host names a checkpoint only to a harness
|
|
1223
|
+
that has said `checkpointHeld: true` for it; a scorer built on an SDK without
|
|
1224
|
+
the cache never says so and keeps receiving the sheet. A `scoreCandidates`
|
|
1225
|
+
answer says it under the reserved key `$checkpointHeld`, since its other
|
|
1226
|
+
entries are the caller's candidate keys — a candidate key never starts with
|
|
1227
|
+
`$`.
|
|
1228
|
+
|
|
1229
|
+
The view a scorer gets on the live paths takes every kind of write —
|
|
1230
|
+
assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
|
|
1231
|
+
that would reach the base sheet by other means: `Object.freeze` (or `seal`,
|
|
1232
|
+
`preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
|
|
1233
|
+
or carries accessors is outside the contract and throws at the first touch.
|
|
1234
|
+
|
|
1204
1235
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1205
1236
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1206
|
-
scorer and every candidate scored against
|
|
1207
|
-
can become another's duplicate
|
|
1208
|
-
|
|
1237
|
+
scorer and every candidate scored against its own copy-on-write VIEW of the
|
|
1238
|
+
result, so no candidate can become another's duplicate and no candidate pays
|
|
1239
|
+
for a copy of the log. Candidates carry no uuid — a spot is not a record — so
|
|
1240
|
+
results come back under caller-chosen keys.
|
|
1209
1241
|
|
|
1210
|
-
`
|
|
1242
|
+
`QsosRepository.forOperation` already filters deleted rows and orders by
|
|
1211
1243
|
`startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
|
|
1212
1244
|
`deleted`/`event` row bookkeeping on this side of the bridge.
|
|
1213
1245
|
`ScoringService` (`app/lib/services/scoring_service.dart`) writes
|
package/docs/templates.md
CHANGED
|
@@ -224,7 +224,7 @@ will jump rather than count. Show HH:MM.
|
|
|
224
224
|
| Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
|
|
225
225
|
| Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
|
|
226
226
|
| ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
|
|
227
|
-
| CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's
|
|
227
|
+
| CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the CW Keyer dialog (Settings → Radio, or the Station dialog's keyer row) |
|
|
228
228
|
|
|
229
229
|
NOTES and COMMENT default to QSO notes and are withheld when private data
|
|
230
230
|
is off. QSLMSG defaults to empty for program exports, and for the whole-log
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -21,14 +21,7 @@
|
|
|
21
21
|
// for the whole thing.
|
|
22
22
|
|
|
23
23
|
import { annotateCallAgainstCountryFile, contestScorer, defineExtension } from "@ham2k/extension-sdk"
|
|
24
|
-
import type {
|
|
25
|
-
ContestScorer,
|
|
26
|
-
HookContext,
|
|
27
|
-
JSONValue,
|
|
28
|
-
LoggingControlDescriptor,
|
|
29
|
-
Ref,
|
|
30
|
-
ScoreTally,
|
|
31
|
-
} from "@ham2k/extension-sdk"
|
|
24
|
+
import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref, ScoreTally } from "@ham2k/extension-sdk"
|
|
32
25
|
import { fmtInteger } from "@ham2k/lib-format-tools"
|
|
33
26
|
|
|
34
27
|
import manifest from "../manifest.json" with { type: "json" }
|
|
@@ -53,7 +46,9 @@ function refOfType(container: Record<string, JSONValue>, type: string): Record<s
|
|
|
53
46
|
|
|
54
47
|
/// "05" and "5" are the same multiplier and must not count twice.
|
|
55
48
|
function normalizeZone(value: JSONValue | undefined): string {
|
|
56
|
-
const digits = String(value ?? "")
|
|
49
|
+
const digits = String(value ?? "")
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^0+(?=\d)/, "")
|
|
57
52
|
return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
|
|
58
53
|
}
|
|
59
54
|
|
|
@@ -154,10 +149,7 @@ const CQWWScorer: ContestScorer<Scoresheet> = {
|
|
|
154
149
|
|
|
155
150
|
const ActivityHook = {
|
|
156
151
|
/// What the operator fills in when adding this contest to an operation.
|
|
157
|
-
async operationControls(
|
|
158
|
-
_args: { operation: Record<string, JSONValue> },
|
|
159
|
-
_ctx: HookContext,
|
|
160
|
-
): Promise<LoggingControlDescriptor[]> {
|
|
152
|
+
async operationControls(_args: { operation: Record<string, JSONValue> }, _ctx: HookContext): Promise<LoggingControlDescriptor[]> {
|
|
161
153
|
return [
|
|
162
154
|
{
|
|
163
155
|
key: "k2hrc-cqww/setup",
|
|
@@ -209,8 +201,7 @@ const ActivityHook = {
|
|
|
209
201
|
const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
|
|
210
202
|
const guess = (their.guess as Record<string, JSONValue>) ?? {}
|
|
211
203
|
const call = str(their.call)
|
|
212
|
-
const suggested =
|
|
213
|
-
normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
204
|
+
const suggested = normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
214
205
|
|
|
215
206
|
return [
|
|
216
207
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -46,8 +46,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
46
46
|
const password = ctx.account?.credentials?.password
|
|
47
47
|
if (!username || !password) throw new Error("No HamQTH account configured")
|
|
48
48
|
|
|
49
|
-
const response = await host.fetch(
|
|
50
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
49
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
51
50
|
const sessionId = tag(response.body, "session_id")
|
|
52
51
|
if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
|
|
53
52
|
|
|
@@ -62,8 +61,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
62
61
|
/// Note that it re-logs-in only ONCE — a second failure is a real one, and
|
|
63
62
|
/// looping on it would hammer the API behind the operator's back.
|
|
64
63
|
async function fetchCall(call: string, ctx: HookContext): Promise<string> {
|
|
65
|
-
const search = (id: string) =>
|
|
66
|
-
host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
64
|
+
const search = (id: string) => host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
67
65
|
|
|
68
66
|
const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
|
|
69
67
|
const response = await search(sessionId)
|
|
@@ -167,13 +165,11 @@ const HamQTHAccount = {
|
|
|
167
165
|
if (!username || !credentials.password) return "Enter your HamQTH username and password"
|
|
168
166
|
|
|
169
167
|
try {
|
|
170
|
-
const response = await host.fetch(
|
|
171
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
168
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
172
169
|
const sessionId = tag(response.body, "session_id")
|
|
173
170
|
if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
|
|
174
171
|
|
|
175
|
-
const check = await host.fetch(
|
|
176
|
-
`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
172
|
+
const check = await host.fetch(`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
177
173
|
const name = tag(check.body, "adr_name")
|
|
178
174
|
return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
|
|
179
175
|
} catch (e) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -19,12 +19,7 @@
|
|
|
19
19
|
// API key the program issues, and the export hooks that submit a log. See
|
|
20
20
|
// `docs/hooks.md` §`spots` and §`export`.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
activityScorer,
|
|
24
|
-
contestScorer,
|
|
25
|
-
defineExtension,
|
|
26
|
-
referenceActivity,
|
|
27
|
-
} from "@ham2k/extension-sdk"
|
|
22
|
+
import { activityScorer, contestScorer, defineExtension, referenceActivity } from "@ham2k/extension-sdk"
|
|
28
23
|
import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
|
|
29
24
|
import { locationToGrid6 } from "@ham2k/lib-geo-tools"
|
|
30
25
|
|
|
@@ -116,7 +111,9 @@ const referenceList: DataFileDefinition = {
|
|
|
116
111
|
category: "k2hrc-llota",
|
|
117
112
|
|
|
118
113
|
jsonToLookupEntry: (entry: Record<string, any>) => {
|
|
119
|
-
const ref = String(entry?.reference_code ?? "")
|
|
114
|
+
const ref = String(entry?.reference_code ?? "")
|
|
115
|
+
.trim()
|
|
116
|
+
.toUpperCase()
|
|
120
117
|
// Returning null drops the entry. A list of this size will contain a few
|
|
121
118
|
// rows that are not references, and throwing would abandon the rest.
|
|
122
119
|
if (!ref) return null
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -49,11 +49,7 @@ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
|
49
49
|
/// document that does not parse, and the sandbox is a mitigation, not a reason
|
|
50
50
|
/// to skip this.
|
|
51
51
|
function escapeHtml(value: string): string {
|
|
52
|
-
return value
|
|
53
|
-
.replace(/&/g, "&")
|
|
54
|
-
.replace(/</g, "<")
|
|
55
|
-
.replace(/>/g, ">")
|
|
56
|
-
.replace(/"/g, """)
|
|
52
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
/// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -8,16 +8,8 @@
|
|
|
8
8
|
"icon": "radio-tower",
|
|
9
9
|
"accentColor": "#4C6EF5",
|
|
10
10
|
"api": 1,
|
|
11
|
-
"keywords": [
|
|
12
|
-
|
|
13
|
-
"panel",
|
|
14
|
-
"sample",
|
|
15
|
-
"weather",
|
|
16
|
-
"radio"
|
|
17
|
-
],
|
|
18
|
-
"hooks": [
|
|
19
|
-
"panel"
|
|
20
|
-
],
|
|
11
|
+
"keywords": ["svg", "panel", "sample", "weather", "radio"],
|
|
12
|
+
"hooks": ["panel"],
|
|
21
13
|
"sharedDependencies": {
|
|
22
14
|
"@ham2k/lib-callsigns": "^1.0.0",
|
|
23
15
|
"@ham2k/lib-country-files": "^1.0.0",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import { defineExtension } from
|
|
4
|
-
import manifest from
|
|
5
|
-
import { createScenePanels } from
|
|
3
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
4
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
5
|
+
import { createScenePanels } from "./scenes.ts"
|
|
6
6
|
|
|
7
7
|
defineExtension({
|
|
8
8
|
...manifest,
|
|
9
9
|
onActivation({ registerHook }) {
|
|
10
|
-
registerHook(
|
|
10
|
+
registerHook("panel", { key: manifest.key, hook: createScenePanels() })
|
|
11
11
|
},
|
|
12
12
|
})
|