@ham2k/extension-sdk 1.12.1 → 1.12.4
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/dist/activityExports.js +74 -8
- package/dist/activityScoring.js +10 -3
- package/dist/i18n/activityExports.en.json +1 -0
- package/dist/i18n/activityExports.es.json +1 -0
- package/dist/index.d.ts +45 -6
- package/docs/distribution.md +7 -5
- package/docs/hooks.md +34 -8
- package/package.json +1 -1
- package/samples/README.md +1 -0
- package/samples/k2hrc-dxcc/build.mjs +6 -0
- package/samples/k2hrc-dxcc/manifest.json +23 -0
- package/samples/k2hrc-dxcc/src/index.ts +104 -0
package/dist/activityExports.js
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { exportTypeDefinition } from "./exportSettings.js";
|
|
2
2
|
import { exportFilename, startMillisOf } from "./exportNames.js";
|
|
3
|
-
import { segmentsWith } from "./segments.js";
|
|
3
|
+
import { operationForQso, segmentsWith } from "./segments.js";
|
|
4
|
+
import { utcDate } from "./templateContext.js";
|
|
4
5
|
import { createCachedTranslator } from "./i18n.js";
|
|
5
6
|
import en from "./i18n/activityExports.en.json";
|
|
6
7
|
import es from "./i18n/activityExports.es.json";
|
|
7
8
|
const tFor = createCachedTranslator({ en: { translation: en }, es: { translation: es } });
|
|
8
9
|
const SEPARATOR = ":";
|
|
10
|
+
const DAY_MARK = "@";
|
|
11
|
+
const DAY_LENGTH = "YYYY-MM-DD".length;
|
|
12
|
+
function byUtcDay(qsos) {
|
|
13
|
+
const timed = /* @__PURE__ */ new Map();
|
|
14
|
+
const timeless = [];
|
|
15
|
+
for (const qso of qsos) {
|
|
16
|
+
const millis = Number(qso.startAtMillis ?? 0);
|
|
17
|
+
if (!(millis > 0)) {
|
|
18
|
+
timeless.push(qso);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const day = utcDate(millis);
|
|
22
|
+
const list = timed.get(day);
|
|
23
|
+
if (list) list.push(qso);
|
|
24
|
+
else timed.set(day, [qso]);
|
|
25
|
+
}
|
|
26
|
+
const days = [...timed.keys()].filter((day) => timed.get(day).some((qso) => !qso.deleted)).sort();
|
|
27
|
+
return new Map(days.map((day, i) => [day, i === 0 ? [...timeless, ...timed.get(day)] : timed.get(day)]));
|
|
28
|
+
}
|
|
29
|
+
function activeQsos(operation, qsos, segments, activationType, ref) {
|
|
30
|
+
if (!segments || segments.length === 0) return qsos;
|
|
31
|
+
const refsAt = (qso) => refsOfType(operationForQso(operation, qso, segments), activationType);
|
|
32
|
+
const segmentRefs = segments.map((segment) => refsOfType(segment.operation, activationType).map((r) => r.ref));
|
|
33
|
+
const known = new Set(segmentRefs.flat());
|
|
34
|
+
if (known.has(ref)) {
|
|
35
|
+
const own = qsos.filter((qso) => refsAt(qso).some((r) => r.ref === ref));
|
|
36
|
+
const movedHere = known.size > 1 && segmentRefs.slice(1).some((refs) => refs.includes(ref));
|
|
37
|
+
if (own.length > 0 || movedHere) return own;
|
|
38
|
+
}
|
|
39
|
+
const active = qsos.filter((qso) => refsAt(qso).length > 0);
|
|
40
|
+
return active.length > 0 ? active : qsos;
|
|
41
|
+
}
|
|
9
42
|
function refsOfType(container, type) {
|
|
10
43
|
const refs = container.refs ?? [];
|
|
11
44
|
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 }));
|
|
@@ -34,10 +67,10 @@ function activityExportHook(rules) {
|
|
|
34
67
|
}
|
|
35
68
|
];
|
|
36
69
|
},
|
|
37
|
-
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
70
|
+
async suggestExportOptions({ operation, qsos, segments, compactFilenames }, ctx) {
|
|
38
71
|
const refs = refsOfType(operation, rules.activationType);
|
|
39
72
|
const t = tFor(ctx);
|
|
40
|
-
|
|
73
|
+
const whole = (ref) => ({
|
|
41
74
|
exportType: `${rules.activationType}-adif`,
|
|
42
75
|
templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
|
|
43
76
|
exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
|
|
@@ -57,15 +90,44 @@ function activityExportHook(rules) {
|
|
|
57
90
|
// being activated.
|
|
58
91
|
refType: rules.activationType,
|
|
59
92
|
ref: ref.ref
|
|
60
|
-
})
|
|
93
|
+
});
|
|
94
|
+
const daysOf = (ref) => byUtcDay(activeQsos(operation, qsos ?? [], segments, rules.activationType, ref));
|
|
95
|
+
const split = rules.splitByUtcDay ? refs.map((ref) => ({ ref, days: daysOf(ref.ref) })) : [];
|
|
96
|
+
if (split.some(({ days }) => days.size > 1)) {
|
|
97
|
+
return split.flatMap(
|
|
98
|
+
({ ref, days }) => days.size === 0 ? [whole(ref)] : [...days].map(([day, dayQsos]) => ({
|
|
99
|
+
exportType: `${rules.activationType}-adif`,
|
|
100
|
+
// The day rides as the filename template's modifier: a template
|
|
101
|
+
// is dated by the whole log's first contact, and two files that
|
|
102
|
+
// differed only in their contents would overwrite each other.
|
|
103
|
+
templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label, modifier: day },
|
|
104
|
+
exportKey: `${prefix}${DAY_MARK}${day}${SEPARATOR}${ref.ref}`,
|
|
105
|
+
format: "adif",
|
|
106
|
+
label: t("adifForRefOnDay", { program: rules.label, ref: ref.ref, day }),
|
|
107
|
+
// Dated by the day's own first contact, which the file is.
|
|
108
|
+
filename: filenameFor(operation, dayQsos, ref.ref, compactFilenames),
|
|
109
|
+
// The day's own count: the operator checking that a day reached
|
|
110
|
+
// its threshold needs that day's number, not the whole log's.
|
|
111
|
+
qsoCount: dayQsos.filter((qso) => !qso.deleted).length,
|
|
112
|
+
icon: rules.icon,
|
|
113
|
+
selectedByDefault: true,
|
|
114
|
+
refType: rules.activationType,
|
|
115
|
+
ref: ref.ref
|
|
116
|
+
}))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return refs.map(whole);
|
|
61
120
|
},
|
|
62
121
|
async generateExport(args, ctx) {
|
|
63
122
|
const exportType = String(args.exportType ?? "");
|
|
64
123
|
const exportKey = String(args.exportKey ?? "");
|
|
65
|
-
|
|
124
|
+
const dayed = exportKey.startsWith(`${prefix}${DAY_MARK}`) && exportKey.charAt(prefix.length + DAY_MARK.length + DAY_LENGTH) === SEPARATOR;
|
|
125
|
+
const day = dayed ? exportKey.slice(prefix.length + DAY_MARK.length, prefix.length + DAY_MARK.length + DAY_LENGTH) : void 0;
|
|
126
|
+
const head = dayed ? prefix.length + DAY_MARK.length + DAY_LENGTH : prefix.length;
|
|
127
|
+
if (exportType !== `${rules.activationType}-adif` || !(dayed || exportKey.startsWith(`${prefix}${SEPARATOR}`))) {
|
|
66
128
|
throw new Error(`${rules.key}: unknown export type/key '${exportType}' / '${exportKey}'`);
|
|
67
129
|
}
|
|
68
|
-
const wanted = exportKey.slice(
|
|
130
|
+
const wanted = exportKey.slice(head + SEPARATOR.length);
|
|
69
131
|
if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
|
|
70
132
|
throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
|
|
71
133
|
}
|
|
@@ -75,9 +137,13 @@ function activityExportHook(rules) {
|
|
|
75
137
|
(r) => r?.type !== rules.activationType || r?.ref === wanted
|
|
76
138
|
)
|
|
77
139
|
});
|
|
140
|
+
const qsos = day === void 0 ? args.qsos : byUtcDay(args.qsos ?? []).get(day) ?? [];
|
|
141
|
+
if (day !== void 0 && !qsos.some((qso) => !qso.deleted)) {
|
|
142
|
+
throw new Error(`${rules.key}: no contacts on ${day} for ${wanted}`);
|
|
143
|
+
}
|
|
78
144
|
const content = await adifForExport({
|
|
79
145
|
operation: claimOnly(args.operation),
|
|
80
|
-
qsos
|
|
146
|
+
qsos,
|
|
81
147
|
segments: segmentsWith(args.segments, claimOnly),
|
|
82
148
|
includePrivateData: args.includePrivateData,
|
|
83
149
|
includeLookupData: args.includeLookupData,
|
|
@@ -89,7 +155,7 @@ function activityExportHook(rules) {
|
|
|
89
155
|
operatorFallback: rules.operatorFallback
|
|
90
156
|
});
|
|
91
157
|
return {
|
|
92
|
-
filename: filenameFor(args.operation,
|
|
158
|
+
filename: filenameFor(args.operation, qsos, wanted, args.compactFilenames),
|
|
93
159
|
mimeType: "text/plain",
|
|
94
160
|
content
|
|
95
161
|
};
|
package/dist/activityScoring.js
CHANGED
|
@@ -34,6 +34,7 @@ function activityScorer(rules) {
|
|
|
34
34
|
return typeof rule === "function" ? rule(activatedRefs) : rule;
|
|
35
35
|
}
|
|
36
36
|
const freshRefRescuesActivation = rules.freshRefRescuesActivation ?? true;
|
|
37
|
+
const distinctCallsPerDay = rules.distinctCallsPerDay ?? false;
|
|
37
38
|
const tracksActivation = rules.tracksActivation ?? true;
|
|
38
39
|
const tracksOperators = rules.tracksOperators ?? true;
|
|
39
40
|
const allowsMultiple = rules.allowsMultipleReferences ?? false;
|
|
@@ -125,16 +126,22 @@ function activityScorer(rules) {
|
|
|
125
126
|
const now = { day, band, mode, refs: huntedRefs };
|
|
126
127
|
const priors = sheet.workedByCall[call] ?? [];
|
|
127
128
|
const verdict = judge(priors, now);
|
|
128
|
-
|
|
129
|
+
let isDupe = verdict === "duplicate";
|
|
129
130
|
const freshRefOnly = verdict === "ref" && !freshRefRescuesActivation;
|
|
130
|
-
|
|
131
|
+
let activationCredit = freshRefOnly ? 0 : activationValue;
|
|
132
|
+
if (distinctCallsPerDay && activationCredit > 0 && !isDupe) {
|
|
133
|
+
const days = sheet.activationCallDays ?? (sheet.activationCallDays = {});
|
|
134
|
+
activationCredit = days[call] === day ? 0 : 1;
|
|
135
|
+
days[call] = day;
|
|
136
|
+
if (activationCredit + huntingValue === 0 && !verdict) isDupe = true;
|
|
137
|
+
}
|
|
131
138
|
const hasStake = activationValue + huntingValue > 0;
|
|
132
139
|
const score = { value: isDupe ? 0 : activationCredit + huntingValue };
|
|
133
140
|
if (!hasStake) {
|
|
134
141
|
} else if (isDupe) {
|
|
135
142
|
score.dupe = true;
|
|
136
143
|
score.alerts = ["duplicate"];
|
|
137
|
-
} else if (verdict) {
|
|
144
|
+
} else if (verdict && verdict !== "duplicate") {
|
|
138
145
|
score.notices = [NOTICE_KEYS[verdict]];
|
|
139
146
|
} else if (priors.length > 0 && score.value > 0 && freshRefRescuesActivation) {
|
|
140
147
|
score.dupe = false;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 1.12.
|
|
1
|
+
// @ham2k/extension-sdk 1.12.4
|
|
2
2
|
/** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface PanelScene {
|
|
4
4
|
version: 1;
|
|
@@ -175,10 +175,18 @@ export interface PanelSceneSpectrum {
|
|
|
175
175
|
display?: "both" | "pan" | "waterfall";
|
|
176
176
|
/**
|
|
177
177
|
* Which way the waterfall moves; the panadapter sits on the side it comes from. Frequency rises
|
|
178
|
-
* left to right for `down` (the default) and `up`, bottom to top for
|
|
179
|
-
*
|
|
178
|
+
* as `rising` says: by default left to right for `down` (the default) and `up`, bottom to top for
|
|
179
|
+
* `right` and top to bottom for `left` — `down` turned a quarter either way. Text stays upright;
|
|
180
|
+
* offsets and `corner`s are measured as if flowing down.
|
|
180
181
|
*/
|
|
181
182
|
flow?: "down" | "up" | "right" | "left";
|
|
183
|
+
/**
|
|
184
|
+
* Which way frequency rises, across the `flow`: `right` or `left` for `down` and `up`, `down` or
|
|
185
|
+
* `up` for `right` and `left`. Omitted, `right` for `down` and `up`, `up` for `right`, `down` for
|
|
186
|
+
* `left`. Offsets, `align` and `corner`s are still measured as if frequency rose to the right.
|
|
187
|
+
* Extension API 12; before it, `left` rose bottom to top as `right` does.
|
|
188
|
+
*/
|
|
189
|
+
rising?: "right" | "left" | "down" | "up";
|
|
182
190
|
/** The panadapter's share of the height with `both`, 0.1–0.9. Default 0.4. */
|
|
183
191
|
split?: number;
|
|
184
192
|
/** dB added to the radio's own floor. */
|
|
@@ -222,7 +230,9 @@ export interface PanelSceneSpectrum {
|
|
|
222
230
|
/**
|
|
223
231
|
* Hz shown across the control, from the middle of each sweep. A sweep wider than this is cropped
|
|
224
232
|
* to it, so a radio whose scope comes in a few widths can be shown at any narrower one — set the
|
|
225
|
-
* scope (`setRadioSpectrum`) to the narrowest that covers it.
|
|
233
|
+
* scope (`setRadioSpectrum`) to the narrowest that covers it. Where the radio's `spectrum` says
|
|
234
|
+
* `autoSpan`, the app sets the scope itself, wide enough for the widest `span` on screen.
|
|
235
|
+
* Extension API 7; `autoSpan` 12.
|
|
226
236
|
*/
|
|
227
237
|
span?: PanelSceneSpectrumNumber;
|
|
228
238
|
/** A click tunes to the nearest multiple of this many Hz. Omitted, wherever it lands. */
|
|
@@ -408,6 +418,12 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
|
|
|
408
418
|
value: string;
|
|
409
419
|
to: number;
|
|
410
420
|
};
|
|
421
|
+
/**
|
|
422
|
+
* A `button` only: a click sends, as the event's `value`, where along the button's longer side it
|
|
423
|
+
* landed — 0 at its top or left end, 1 at its bottom or right — so a scale can be clicked at a
|
|
424
|
+
* place. Keys and screen readers activate it with no value, as does an app before this. Extension API 12.
|
|
425
|
+
*/
|
|
426
|
+
tapAt?: boolean;
|
|
411
427
|
/**
|
|
412
428
|
* 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
|
|
413
429
|
* keyboard focus or screen-reader node. Anything between is only drawn fainter.
|
|
@@ -1099,6 +1115,7 @@ export interface ExportResult {
|
|
|
1099
1115
|
export interface ExportOptionsRequest {
|
|
1100
1116
|
operation: Record<string, JSONValue>;
|
|
1101
1117
|
qsos: Record<string, JSONValue>[];
|
|
1118
|
+
segments?: OperationSegmentPayload[];
|
|
1102
1119
|
compactFilenames?: boolean;
|
|
1103
1120
|
}
|
|
1104
1121
|
export interface ExportOption {
|
|
@@ -1242,6 +1259,14 @@ export interface ChasingProgram {
|
|
|
1242
1259
|
supersedes?: string[];
|
|
1243
1260
|
refLabel?: string;
|
|
1244
1261
|
imports?: ChasingImporter[];
|
|
1262
|
+
notices?: Partial<Record<ChaseNoticeVerdict, string>>;
|
|
1263
|
+
modeLabels?: Record<string, string>;
|
|
1264
|
+
translations?: Record<string, ChasingNoticeLabels>;
|
|
1265
|
+
}
|
|
1266
|
+
export type ChaseNoticeVerdict = "newRef" | "newBand" | "newMode" | "unconfirmed";
|
|
1267
|
+
export interface ChasingNoticeLabels {
|
|
1268
|
+
notices?: Partial<Record<ChaseNoticeVerdict, string>>;
|
|
1269
|
+
modeLabels?: Record<string, string>;
|
|
1245
1270
|
}
|
|
1246
1271
|
export interface ChasingImporter {
|
|
1247
1272
|
key: string;
|
|
@@ -2090,13 +2115,24 @@ export interface PanelRadioSpectrum {
|
|
|
2090
2115
|
mode: "center" | "fixed" | null;
|
|
2091
2116
|
/** Half the width shown while centered — what the radio's menu shows as ±. */
|
|
2092
2117
|
spanHz: number | null;
|
|
2093
|
-
/**
|
|
2118
|
+
/**
|
|
2119
|
+
* The edges shown: while fixed, and while centred where the radio places its scope apart from the
|
|
2120
|
+
* dial (a FlexRadio's panadapter, which a retune inside it does not move). Null where the scope
|
|
2121
|
+
* sits on the dial, `spanHz` either side of it.
|
|
2122
|
+
*/
|
|
2094
2123
|
lowHz: number | null;
|
|
2095
2124
|
highHz: number | null;
|
|
2096
2125
|
refLevelDb: number | null;
|
|
2097
2126
|
speed: PanelRadioSpectrumSpeed | null;
|
|
2098
2127
|
/** What `setRadioSpectrum` accepts. A span between two is snapped to the nearer. */
|
|
2099
2128
|
spansHz: number[];
|
|
2129
|
+
/**
|
|
2130
|
+
* The app sets the span itself — a sweep it computes, whose width changes nothing on the radio
|
|
2131
|
+
* (a FlexRadio's) — to the narrowest covering the widest `span` of the spectrum controls shown,
|
|
2132
|
+
* each cropping its own width. A panel then names its width with `span` and leaves
|
|
2133
|
+
* `setRadioSpectrum`'s `spanHz` alone, or the panels take turns. Absent before extension API 12.
|
|
2134
|
+
*/
|
|
2135
|
+
autoSpan?: boolean;
|
|
2100
2136
|
refMinDb: number;
|
|
2101
2137
|
refMaxDb: number;
|
|
2102
2138
|
refStepDb: number;
|
|
@@ -2415,6 +2451,7 @@ export interface ActivityScoringRules {
|
|
|
2415
2451
|
tracksActivation?: boolean;
|
|
2416
2452
|
tracksOperators?: boolean;
|
|
2417
2453
|
freshRefRescuesActivation?: boolean;
|
|
2454
|
+
distinctCallsPerDay?: boolean;
|
|
2418
2455
|
allowsMultipleReferences?: boolean;
|
|
2419
2456
|
uniquePer?: readonly UniqueAxis[];
|
|
2420
2457
|
activates?: "daily" | "once";
|
|
@@ -2438,6 +2475,7 @@ export type ActivityScoresheet = {
|
|
|
2438
2475
|
dayActivatedQsos: number;
|
|
2439
2476
|
dayHuntedQsos: number;
|
|
2440
2477
|
dayDuplicates: number;
|
|
2478
|
+
activationCallDays?: Record<string, number>;
|
|
2441
2479
|
};
|
|
2442
2480
|
export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
|
|
2443
2481
|
export interface ActivityExportRules {
|
|
@@ -2448,10 +2486,11 @@ export interface ActivityExportRules {
|
|
|
2448
2486
|
icon?: string;
|
|
2449
2487
|
includeFieldsFrom?: string[];
|
|
2450
2488
|
operatorFallback?: OperatorFallback;
|
|
2489
|
+
splitByUtcDay?: boolean;
|
|
2451
2490
|
}
|
|
2452
2491
|
export declare function activityExportHook(rules: ActivityExportRules): {
|
|
2453
2492
|
getExportTypes(): Promise<ExportTypeDefinition[]>;
|
|
2454
|
-
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
2493
|
+
suggestExportOptions({ operation, qsos, segments, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
2455
2494
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
2456
2495
|
};
|
|
2457
2496
|
export interface HuntingExportRules {
|
package/docs/distribution.md
CHANGED
|
@@ -417,12 +417,14 @@ can move it forward from there.
|
|
|
417
417
|
|
|
418
418
|
Which ones ship is staged. Today it is the catalog twin of every extension the
|
|
419
419
|
app used to build in (see [Retired built-ins](#retired-built-ins)), so that an
|
|
420
|
-
install moving off them has every one to hand with no network
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
420
|
+
install moving off them has every one to hand with no network, and
|
|
421
|
+
`ham2k-dxcc`, which replaced the core `dxcc` chasing program and is on for
|
|
422
|
+
everyone as that was. Once installs still on a build with the built-ins are few
|
|
423
|
+
enough, it shrinks to the nine a new install needs — `ham2k-pota`,
|
|
424
|
+
`ham2k-sota`, `ham2k-wwff`, `ham2k-dxcc` and the five lookups — and the rest
|
|
425
|
+
are fetched from the catalog when switched on
|
|
424
426
|
([HALO-743](https://cabo.ham2k.com/halo/c/743)). The app's own tests rely on
|
|
425
|
-
only those
|
|
427
|
+
only those nine, the core, and the never-shipped samples, so the shrink
|
|
426
428
|
touches none of them.
|
|
427
429
|
|
|
428
430
|
**They have to be kept current.** A pre-load is frozen into the binary, so
|
package/docs/hooks.md
CHANGED
|
@@ -413,7 +413,7 @@ interface ExportHook {
|
|
|
413
413
|
suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
|
|
414
414
|
generateExport(args: ExportRequest, ctx): Promise<ExportResult>
|
|
415
415
|
}
|
|
416
|
-
// ExportOptionsRequest: {operation, qsos, compactFilenames?}
|
|
416
|
+
// ExportOptionsRequest: {operation, qsos, segments?, compactFilenames?}
|
|
417
417
|
// ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, ref?, priority?, selectedByDefault?}
|
|
418
418
|
// ExportRequest: {operation, qsos, segments?, events?, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
|
|
419
419
|
// ExportResult: {filename, mimeType, content}
|
|
@@ -1607,7 +1607,14 @@ registerHook("chasing", { key: "dxcc", hook: {
|
|
|
1607
1607
|
without invoking anything. Raise `version` whenever the same contacts would
|
|
1608
1608
|
claim something different: the app keeps no claim from an older version.
|
|
1609
1609
|
`refLabel` is what one reference is called where a notice names it
|
|
1610
|
-
("New POTA Park"), the program's `label` when left out.
|
|
1610
|
+
("New POTA Park"), the program's `label` when left out. A program may
|
|
1611
|
+
instead word a verdict's notice itself: `notices` by verdict (`newRef`,
|
|
1612
|
+
`newBand`, `newMode`, `unconfirmed`), where `{{band}}` names the band a
|
|
1613
|
+
new band is on and `{{mode}}` the mode a new mode is in, through
|
|
1614
|
+
`modeLabels`, with both per language under `translations` (`{ es: {
|
|
1615
|
+
notices, modeLabels } }`). A verdict it leaves out, or one whose notice
|
|
1616
|
+
lacks the band or mode its words name, reads the app's way; a guess reads
|
|
1617
|
+
"Probably …" around the program's words.
|
|
1611
1618
|
`supersedes` names the scoring notices the program's verdicts stand in for
|
|
1612
1619
|
(DXCC: `newDXCC`): wherever the program judges a call or a spot, the
|
|
1613
1620
|
scorer's notice is dropped in favor of the verdict.
|
|
@@ -1662,9 +1669,12 @@ extension API 9: a bundle that registers a program declares `"api": 9`, which
|
|
|
1662
1669
|
the packer enforces, so an older app — which would install it and never ask
|
|
1663
1670
|
it anything — is never offered it.
|
|
1664
1671
|
|
|
1665
|
-
Implemented by: `dxcc` (
|
|
1666
|
-
|
|
1667
|
-
|
|
1672
|
+
Implemented by: `ham2k-dxcc` (catalog, pre-loaded; core `dxcc` until
|
|
1673
|
+
HALO-844) — Mixed, per-mode and per-band slots for the station call worked
|
|
1674
|
+
from each entity, worded "DXCC ATNO", "DXCC in CW", "DXCC in 80m"; LoTW and
|
|
1675
|
+
paper QSLs confirm, `credits.dxcc` verifies; the DXCC Challenge is a tally of
|
|
1676
|
+
its band slots. A stored goal for `dxcc` carries to it
|
|
1677
|
+
(`kRenamedChasePrograms`). `k2hrc-dxcc` is the SDK's worked example. `ham2k-pota` — one slot per park hunted, worked only; its
|
|
1668
1678
|
hunted-parks export verifies. `ham2k-was` — Mixed, per-band and per-mode
|
|
1669
1679
|
slots (plus FT8 and RTTY) for the station call worked from one 4-character
|
|
1670
1680
|
grid; LoTW and paper QSLs confirm, `WAS` credit verifies.
|
|
@@ -1916,7 +1926,7 @@ the extension. `value` names what it shows and sets.
|
|
|
1916
1926
|
|
|
1917
1927
|
| Kind | Binds to | Sends | Also takes |
|
|
1918
1928
|
|---|---|---|---|
|
|
1919
|
-
| `button` | — | `activate` | `menu` |
|
|
1929
|
+
| `button` | — | `activate` | `menu`, `tapAt` (extension API 12: a click's `value` is where along the button's longer side it landed, 0 at its top or left end to 1 at its bottom or right; a key or screen reader activates it with none) |
|
|
1920
1930
|
| `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous`, `scrollThrough` |
|
|
1921
1931
|
| `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `invertDrag`, `scrollThrough`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
|
|
1922
1932
|
| `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
|
|
@@ -2094,6 +2104,13 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
|
|
|
2094
2104
|
(`both`, `pan`, `waterfall`), `split`, and `flow` — which way the waterfall
|
|
2095
2105
|
moves (`down`, `up`, `right`, `left`), the panadapter on the side it comes
|
|
2096
2106
|
from.
|
|
2107
|
+
- `rising` (extension API 12): which way frequency increases, across the
|
|
2108
|
+
flow — `right` or `left` for `down` and `up`, `down` or `up` for `right` and
|
|
2109
|
+
`left`; one along the flow is refused. Omitted, each flow is `down` turned a
|
|
2110
|
+
quarter, never mirrored: `right` for `down` and `up`, `up` for `right`,
|
|
2111
|
+
`down` for `left` (before API 12, `left` rose up the screen too). Text stays
|
|
2112
|
+
upright, and offsets, `align` and pointer corners are measured as if
|
|
2113
|
+
frequency rose to the right.
|
|
2097
2114
|
- `floor`, `range`, `average` (0–99, default 40), `fps` (default 15),
|
|
2098
2115
|
`waterfallSpeed`, `waterfallFloor`, `waterfallRange`: numbers, or the names
|
|
2099
2116
|
of scene values the control reads on every sweep.
|
|
@@ -2111,7 +2128,10 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
|
|
|
2111
2128
|
number or a scene value. A wider sweep is cropped to it, so a radio whose
|
|
2112
2129
|
scope comes in a few fixed widths can be shown at any narrower one; set the
|
|
2113
2130
|
scope to the narrowest that covers it. Everything is drawn, labelled and
|
|
2114
|
-
clicked on the window shown.
|
|
2131
|
+
clicked on the window shown. Where the radio's `spectrum` says `autoSpan`
|
|
2132
|
+
(extension API 12), the app sets the scope itself, to the narrowest span
|
|
2133
|
+
covering the widest `span` among the spectrum controls on screen, so each
|
|
2134
|
+
panel names its width here and leaves `setRadioSpectrum`'s `spanHz` alone.
|
|
2115
2135
|
- `follow` (0–0.45): keeps the tuned frequency at least this share of the
|
|
2116
2136
|
width from either edge, by moving the radio's scope just far enough to put
|
|
2117
2137
|
it on that margin — the picture scrolls with the dial rather than jumping
|
|
@@ -2347,7 +2367,13 @@ for a radio without a scope, otherwise its settings as the radio reports them
|
|
|
2347
2367
|
(`mode`, `spanHz`, `lowHz`/`highHz`, `refLevelDb`, `speed`), what it accepts
|
|
2348
2368
|
(`spansHz`, `refMinDb`…`refStepDb`, `modes`, `speeds`) and `problem`, why it
|
|
2349
2369
|
cannot run, in the operator's language. The settings are known only while a
|
|
2350
|
-
`spectrum` control has the scope running, and null otherwise.
|
|
2370
|
+
`spectrum` control has the scope running, and null otherwise. `autoSpan`
|
|
2371
|
+
(extension API 12) is true where the app sizes the scope for its displays — a
|
|
2372
|
+
sweep it computes, whose width changes nothing on the radio (a FlexRadio's DAX
|
|
2373
|
+
IQ): it runs at the narrowest span covering the widest spectrum control's
|
|
2374
|
+
`span` on screen, each control cropping its own width from it, and narrows
|
|
2375
|
+
again as wider ones go. A panel there names its width with `span`; setting
|
|
2376
|
+
`spanHz` too has the panels take turns.
|
|
2351
2377
|
`host.setRadioSpectrum({id, selection?, mode?, spanHz?, refLevelDb?, speed?})`
|
|
2352
2378
|
changes them; a span between two the radio offers is snapped to the nearer. It
|
|
2353
2379
|
answers `stopped` when no control is running the scope: nothing is queued for
|
package/package.json
CHANGED
package/samples/README.md
CHANGED
|
@@ -10,6 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
|
|
|
10
10
|
| `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
|
|
11
11
|
| `k2hrc-llota` | An award program: references, an offline list, activation scoring |
|
|
12
12
|
| `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
|
|
13
|
+
| `k2hrc-dxcc` | A chasing program: what each contact counts toward, and how its notices read (extension API 9) |
|
|
13
14
|
| `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
|
|
14
15
|
| `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
|
|
15
16
|
| `k2hrc-panafall` | A radio's panadapter and waterfall in a scene, styled and tuned by click (extension API 7) |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "k2hrc-dxcc",
|
|
3
|
+
"name": "DXCC (sample)",
|
|
4
|
+
"shortName": "DXCC*",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "The DXCC entities a station has worked and confirmed, as a worked example of a chasing program",
|
|
7
|
+
"category": "award",
|
|
8
|
+
"icon": "earth",
|
|
9
|
+
"accentColor": "#2E7D32",
|
|
10
|
+
"api": 9,
|
|
11
|
+
"keywords": ["award", "dxcc", "chasing", "sample"],
|
|
12
|
+
"hooks": ["chasing"],
|
|
13
|
+
"sharedDependencies": {
|
|
14
|
+
"@ham2k/lib-callsigns": "^1.0.0",
|
|
15
|
+
"@ham2k/lib-country-files": "^1.0.0",
|
|
16
|
+
"@ham2k/lib-dxcc-data": "^1.0.0",
|
|
17
|
+
"@ham2k/lib-format-tools": "^1.0.0",
|
|
18
|
+
"@ham2k/lib-geo-tools": "^1.0.0",
|
|
19
|
+
"@ham2k/lib-operation-data": "^1.0.0",
|
|
20
|
+
"i18next": "^23.0.0",
|
|
21
|
+
"liquidjs": "^10.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// SAMPLE — a chasing program: what each contact counts toward.
|
|
5
|
+
//
|
|
6
|
+
// An award is a checklist, and a chasing program says which lines of it a
|
|
7
|
+
// contact fills. The app keeps the best claim for each line, ranks spots by
|
|
8
|
+
// what working the station would add, and alerts on what is new. The program
|
|
9
|
+
// never reads the log: `claimsForQsos` is a pure function of each contact,
|
|
10
|
+
// because the app also asks it about spots and about the call being typed.
|
|
11
|
+
//
|
|
12
|
+
// One contact makes several claims, one per kind of line the award keeps —
|
|
13
|
+
// here DXCC's three: Mixed (any band, any mode), its band, and its mode. A
|
|
14
|
+
// missing Mixed line is a new entity, a missing band or mode line a new band
|
|
15
|
+
// or mode, and `notices` says how each reads ("DXCC in 80m").
|
|
16
|
+
//
|
|
17
|
+
// The rules here are DXCC's, simplified: the catalog's `ham2k-dxcc` adds
|
|
18
|
+
// deleted entities, maritime mobile, satellites and the DXCC Challenge. See
|
|
19
|
+
// `docs/hooks.md` §`chasing`.
|
|
20
|
+
|
|
21
|
+
import { defineExtension, entityForCall } from "@ham2k/extension-sdk"
|
|
22
|
+
import type { ChasingHook, JSONValue, QsoClaim } from "@ham2k/extension-sdk"
|
|
23
|
+
import { parseCallsign } from "@ham2k/lib-callsigns"
|
|
24
|
+
import { ADIF_MODES_AND_SUBMODES, superModeForMode } from "@ham2k/lib-operation-data"
|
|
25
|
+
|
|
26
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
27
|
+
|
|
28
|
+
/// DXCC's three mode awards. `superModeForMode` calls anything it does not
|
|
29
|
+
/// know `DATA`, so a mode is checked against ADIF's list first.
|
|
30
|
+
const KNOWN_MODES = new Set(ADIF_MODES_AND_SUBMODES.map((mode) => mode.toUpperCase()))
|
|
31
|
+
const AWARD_MODES: Record<string, string> = { CW: "CW", PHONE: "PHONE", DATA: "DIGITAL" }
|
|
32
|
+
|
|
33
|
+
function obj(value: JSONValue | undefined): Record<string, JSONValue> {
|
|
34
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Who the claim belongs to: an opaque string the program builds. DXCC goes
|
|
38
|
+
/// to a call, for contacts made from one entity — KI2D and KI2D/P share a
|
|
39
|
+
/// checklist, KI2D/VE2 keeps its own.
|
|
40
|
+
function holderFor(station: Record<string, JSONValue>): string | undefined {
|
|
41
|
+
if (typeof station.call !== "string" || !station.call) return undefined
|
|
42
|
+
const call = station.call.trim().toUpperCase()
|
|
43
|
+
const base = parseCallsign(call).baseCall || call
|
|
44
|
+
const entity = typeof station.dxccCode === "number" ? station.dxccCode : entityForCall(call).dxccCode
|
|
45
|
+
return entity == null ? base : `${base}@${entity}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// `worked` from the contact itself, `confirmed` by a QSL the award takes.
|
|
49
|
+
function levelOf(qso: Record<string, JSONValue>): Pick<QsoClaim, "level" | "by"> {
|
|
50
|
+
const qsl = obj(qso.qsl)
|
|
51
|
+
if (obj(qsl.lotw).received === true) return { level: "confirmed", by: "lotw" }
|
|
52
|
+
if (obj(qsl.qsl).received === true) return { level: "confirmed", by: "qsl" }
|
|
53
|
+
return { level: "worked" }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function claimsFor(qso: Record<string, JSONValue>): QsoClaim[] {
|
|
57
|
+
// An event marker's calls are placeholders, not stations.
|
|
58
|
+
if (qso.band === "event") return []
|
|
59
|
+
const their = obj(qso.their)
|
|
60
|
+
const holder = holderFor(obj(qso.our))
|
|
61
|
+
if (typeof their.call !== "string" || !holder) return []
|
|
62
|
+
const { dxccCode, entityName } = entityForCall(their.call)
|
|
63
|
+
if (dxccCode == null) return []
|
|
64
|
+
|
|
65
|
+
const base = { holder, ref: String(dxccCode), ...(entityName ? { label: entityName } : {}), ...levelOf(qso) }
|
|
66
|
+
const claims: QsoClaim[] = [{ ...base, band: null, mode: null }]
|
|
67
|
+
if (typeof qso.band === "string" && qso.band) claims.push({ ...base, band: qso.band, mode: null })
|
|
68
|
+
const mode = typeof qso.mode === "string" && KNOWN_MODES.has(qso.mode.toUpperCase()) ? AWARD_MODES[superModeForMode(qso.mode.toUpperCase())] : undefined
|
|
69
|
+
if (mode) claims.push({ ...base, band: null, mode })
|
|
70
|
+
return claims
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const program: ChasingHook = {
|
|
74
|
+
// Plain data, read without calling anything. Raise `version` whenever the
|
|
75
|
+
// same contacts would claim something different.
|
|
76
|
+
program: {
|
|
77
|
+
label: "DXCC*",
|
|
78
|
+
icon: "earth",
|
|
79
|
+
version: 1,
|
|
80
|
+
editions: "lifetime",
|
|
81
|
+
notices: { newRef: "New DXCC* entity", newBand: "DXCC* on {{band}}", newMode: "DXCC* in {{mode}}" },
|
|
82
|
+
modeLabels: { PHONE: "Phone", DIGITAL: "Digital" },
|
|
83
|
+
},
|
|
84
|
+
// Keyed by each contact's uuid; one without a uuid has no key to answer under.
|
|
85
|
+
claimsForQsos: ({ qsos }) =>
|
|
86
|
+
Object.fromEntries(qsos.filter((qso) => typeof qso.uuid === "string" && qso.uuid).map((qso) => [qso.uuid as string, claimsFor(qso)])),
|
|
87
|
+
holdersFor: ({ station }) => {
|
|
88
|
+
const holder = holderFor(station)
|
|
89
|
+
return holder ? [holder] : []
|
|
90
|
+
},
|
|
91
|
+
// One tally per thing worth counting: entities, from their Mixed lines.
|
|
92
|
+
summarizeClaims: ({ slots }) => {
|
|
93
|
+
const entities = slots.filter((slot) => slot.band === null && slot.mode === null)
|
|
94
|
+
const confirmed = entities.filter((slot) => slot.level !== "worked").length
|
|
95
|
+
return { entities: { key: "entities", icon: "earth", label: `${entities.length} entities`, summary: `${confirmed} confirmed`, total: entities.length } }
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
defineExtension({
|
|
100
|
+
...manifest,
|
|
101
|
+
onActivation({ registerHook }) {
|
|
102
|
+
registerHook("chasing", { hook: program, key: manifest.key })
|
|
103
|
+
},
|
|
104
|
+
})
|