@ham2k/extension-sdk 0.3.0 → 0.5.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 +1 -1
- package/README.md +20 -0
- package/dist/activityExports.js +37 -6
- package/dist/exportSettings.js +133 -0
- package/dist/index.d.ts +267 -3
- package/dist/index.js +14 -0
- package/dist/svgScene.js +0 -0
- package/docs/forms.md +30 -2
- package/docs/hooks.md +170 -6
- package/docs/settings.md +7 -6
- package/docs/templates.md +15 -15
- package/package.json +1 -1
- package/samples/README.md +1 -0
- package/samples/k2hrc-svg-scenes/README.md +24 -0
- package/samples/k2hrc-svg-scenes/build.mjs +6 -0
- package/samples/k2hrc-svg-scenes/manifest.json +31 -0
- package/samples/k2hrc-svg-scenes/src/index.ts +12 -0
- package/samples/k2hrc-svg-scenes/src/scenes.ts +128 -0
package/AGENTS.md
CHANGED
|
@@ -81,7 +81,7 @@ will actually call.
|
|
|
81
81
|
|
|
82
82
|
| Doing this | Read |
|
|
83
83
|
| --- | --- |
|
|
84
|
-
| Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel) |
|
|
84
|
+
| Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-svg-scenes` (experimental interactive SVG) |
|
|
85
85
|
| Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
|
|
86
86
|
| A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
|
|
87
87
|
| Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
|
package/README.md
CHANGED
|
@@ -53,6 +53,26 @@ offline and describe the version installed here rather than whatever the
|
|
|
53
53
|
repository says today. If an agent is writing your extension, point it at
|
|
54
54
|
`node_modules/@ham2k/extension-sdk/AGENTS.md` — `h2kext-init` does that for you.
|
|
55
55
|
|
|
56
|
+
## Export types and settings
|
|
57
|
+
|
|
58
|
+
Export hooks can register shared types with `getExportTypes()`. Use
|
|
59
|
+
`exportTypeDefinition(activationType, format, label)` for an identity shared
|
|
60
|
+
by extensions serving the same activity. `exportType` identifies the settings;
|
|
61
|
+
`exportKey` identifies an individual file, such as one park in an activation.
|
|
62
|
+
|
|
63
|
+
The `activityExportHook` and `huntingExportHook` helpers register their types
|
|
64
|
+
and forward export settings to the ADIF generator. Reference activity types
|
|
65
|
+
use `templateCategory: 'reference'`; other types use the other-activity defaults.
|
|
66
|
+
`GlobalExportSettings` provides separate normal filename, compact filename,
|
|
67
|
+
and title templates for each category, plus shared ADIF field templates.
|
|
68
|
+
|
|
69
|
+
When updating an exporter from SDK 0.3, keep a reference-specific identifier
|
|
70
|
+
in `exportKey`, register a stable `exportType`, and forward `exportSettings`,
|
|
71
|
+
`exportData`, `exportTitle`, and `includeLookupData` when delegating generation.
|
|
72
|
+
Per-type custom templates override inherited defaults only when enabled.
|
|
73
|
+
See `docs/hooks.md` §`export` for the complete contract and `docs/forms.md`
|
|
74
|
+
for the `textTemplate` form field.
|
|
75
|
+
|
|
56
76
|
## About the peer dependencies
|
|
57
77
|
|
|
58
78
|
This package lists the libraries the extension host carries — `@ham2k/lib-*`,
|
package/dist/activityExports.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { exportTypeDefinition } from "./exportSettings.js";
|
|
1
2
|
import { exportFilename, startMillisOf } from "./exportNames.js";
|
|
2
3
|
import { segmentsWith } from "./segments.js";
|
|
3
4
|
import { createCachedTranslator } from "./i18n.js";
|
|
@@ -21,11 +22,23 @@ function filenameFor(operation, qsos, ref, compact) {
|
|
|
21
22
|
function activityExportHook(rules) {
|
|
22
23
|
const prefix = `${rules.key}-adif`;
|
|
23
24
|
return {
|
|
25
|
+
async getExportTypes() {
|
|
26
|
+
return [{
|
|
27
|
+
...exportTypeDefinition(rules.activationType, "adif", rules.label),
|
|
28
|
+
templateCategory: "reference",
|
|
29
|
+
templateSample: rules.templateSample ?? {
|
|
30
|
+
log: { ref: "REF-1234", refName: "Example Reference" },
|
|
31
|
+
operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
|
|
32
|
+
}
|
|
33
|
+
}];
|
|
34
|
+
},
|
|
24
35
|
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
25
36
|
const refs = refsOfType(operation, rules.activationType);
|
|
26
37
|
const t = tFor(ctx);
|
|
27
38
|
return refs.map((ref) => ({
|
|
28
|
-
exportType: `${
|
|
39
|
+
exportType: `${rules.activationType}-adif`,
|
|
40
|
+
templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
|
|
41
|
+
exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
|
|
29
42
|
format: "adif",
|
|
30
43
|
// The FORMAT leads: a park activated on a contest weekend offers
|
|
31
44
|
// several files for the same reference, and "POTA US-1234" alone said
|
|
@@ -44,10 +57,11 @@ function activityExportHook(rules) {
|
|
|
44
57
|
},
|
|
45
58
|
async generateExport(args, ctx) {
|
|
46
59
|
const exportType = String(args.exportType ?? "");
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
const exportKey = String(args.exportKey ?? "");
|
|
61
|
+
if (exportType !== `${rules.activationType}-adif` || !exportKey.startsWith(`${prefix}${SEPARATOR}`)) {
|
|
62
|
+
throw new Error(`${rules.key}: unknown export type/key '${exportType}' / '${exportKey}'`);
|
|
49
63
|
}
|
|
50
|
-
const wanted =
|
|
64
|
+
const wanted = exportKey.slice(prefix.length + SEPARATOR.length);
|
|
51
65
|
if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
|
|
52
66
|
throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
|
|
53
67
|
}
|
|
@@ -62,6 +76,10 @@ function activityExportHook(rules) {
|
|
|
62
76
|
qsos: args.qsos,
|
|
63
77
|
segments: segmentsWith(args.segments, claimOnly),
|
|
64
78
|
includePrivateData: args.includePrivateData,
|
|
79
|
+
includeLookupData: args.includeLookupData,
|
|
80
|
+
exportSettings: args.exportSettings,
|
|
81
|
+
exportData: args.exportData,
|
|
82
|
+
exportTitle: args.exportTitle,
|
|
65
83
|
mainHandler: rules.key,
|
|
66
84
|
includeFieldsFrom: rules.includeFieldsFrom
|
|
67
85
|
});
|
|
@@ -83,17 +101,22 @@ function filenameForHunt(operation, qsos, activity, compact) {
|
|
|
83
101
|
});
|
|
84
102
|
}
|
|
85
103
|
function huntingExportHook(rules) {
|
|
86
|
-
const exportType = `${rules.
|
|
104
|
+
const exportType = `${rules.huntingType}-hunter`;
|
|
87
105
|
function huntedQsos(qsos) {
|
|
88
106
|
return qsos.filter((qso) => refsOfType(qso, rules.huntingType).length > 0);
|
|
89
107
|
}
|
|
90
108
|
return {
|
|
109
|
+
async getExportTypes() {
|
|
110
|
+
return [{ exportType, format: "adif", label: `${rules.label} Hunter Log` }];
|
|
111
|
+
},
|
|
91
112
|
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
92
113
|
if (refsOfType(operation, rules.activationType).length > 0) return [];
|
|
93
114
|
if (huntedQsos(qsos ?? []).length === 0) return [];
|
|
94
115
|
const t = tFor(ctx);
|
|
95
116
|
return [{
|
|
96
117
|
exportType,
|
|
118
|
+
qsoCount: huntedQsos(qsos ?? []).length,
|
|
119
|
+
templateData: { activity: rules.label },
|
|
97
120
|
format: "adif",
|
|
98
121
|
label: t("adifForHunter", { program: rules.label }),
|
|
99
122
|
filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
|
|
@@ -119,6 +142,10 @@ function huntingExportHook(rules) {
|
|
|
119
142
|
qsos: huntedQsos(args.qsos ?? []),
|
|
120
143
|
segments: args.segments,
|
|
121
144
|
includePrivateData: args.includePrivateData,
|
|
145
|
+
includeLookupData: args.includeLookupData,
|
|
146
|
+
exportSettings: args.exportSettings,
|
|
147
|
+
exportData: args.exportData,
|
|
148
|
+
exportTitle: args.exportTitle,
|
|
122
149
|
// The hunter file is submitted to ONE program too — see
|
|
123
150
|
// `activityExportHook.generateExport`. `rules.key` is the ACTIVITY's
|
|
124
151
|
// key ('pota'), which is what its `adifFields` hook registers under;
|
|
@@ -136,7 +163,7 @@ function huntingExportHook(rules) {
|
|
|
136
163
|
}
|
|
137
164
|
};
|
|
138
165
|
}
|
|
139
|
-
async function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }) {
|
|
166
|
+
async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
|
|
140
167
|
const { hooks } = await import("@ham2k/extension-sdk");
|
|
141
168
|
const entries = await hooks.invokeOne("export", "adif", "generateExport", {
|
|
142
169
|
operation,
|
|
@@ -146,6 +173,10 @@ async function adifForExport({ operation, qsos, segments, includePrivateData, ma
|
|
|
146
173
|
// Forwarded, or the delegate defaults to withholding and the user's
|
|
147
174
|
// "include private info" setting silently does nothing here.
|
|
148
175
|
includePrivateData,
|
|
176
|
+
includeLookupData,
|
|
177
|
+
exportSettings,
|
|
178
|
+
exportData,
|
|
179
|
+
exportTitle,
|
|
149
180
|
mainHandler,
|
|
150
181
|
includeFieldsFrom
|
|
151
182
|
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { startMillisOf } from "./exportNames.js";
|
|
2
|
+
import { templateContext } from "./templateContext.js";
|
|
3
|
+
import { oneLine, renderTemplate } from "./templates.js";
|
|
4
|
+
import { isTestOperation } from "./testOperation.js";
|
|
5
|
+
const EXPORT_DEFAULTS = {
|
|
6
|
+
includePrivateData: false,
|
|
7
|
+
includeLookupData: true,
|
|
8
|
+
customTemplates: false,
|
|
9
|
+
filenameTemplate: "{{ op.date }} {{ log.station }}{% if log.ref != blank %} at {{ log.ref }}{% elsif log.activity != blank %} for {{ log.activity }}{% endif %} {{ log.modifier }}",
|
|
10
|
+
compactFilenameTemplate: "{{ log.station | dash }}{% if log.ref != blank %}@{{ log.ref | dash }}{% elsif log.activity != blank %}-{{ log.activity | downcase | dash }}{% endif %}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
|
|
11
|
+
titleTemplate: "{{ log.station }}{% if log.ref != blank %}: {{ log.activity }} at {{ log.ref }} {{ log.refName }}{% elsif log.activity != blank %}: {{ log.activity }}{% endif %} on {{ op.date }}",
|
|
12
|
+
adifNotesTemplate: "{{ qso.notes }}",
|
|
13
|
+
adifCommentTemplate: "{{ qso.notes }}",
|
|
14
|
+
adifQslMessageTemplate: ""
|
|
15
|
+
};
|
|
16
|
+
const GLOBAL_EXPORT_DEFAULTS = {
|
|
17
|
+
...EXPORT_DEFAULTS,
|
|
18
|
+
referenceFilenameTemplate: "{{ op.date }} {{ log.station }} at {{ log.ref }} {{ log.modifier }}",
|
|
19
|
+
referenceCompactFilenameTemplate: "{{ log.station | dash }}@{{ log.ref | dash }}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
|
|
20
|
+
referenceTitleTemplate: "{{ log.station }}: {{ log.activity }} at {{ log.ref }} {{ log.refName }} on {{ op.date }}",
|
|
21
|
+
otherFilenameTemplate: "{{ op.date }} {{ log.station }}{% if log.activity != blank %} for {{ log.activity }}{% endif %} {{ log.modifier }}",
|
|
22
|
+
otherCompactFilenameTemplate: "{{ log.station | dash }}{% if log.activity != blank %}-{{ log.activity | downcase | dash }}{% endif %}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
|
|
23
|
+
otherTitleTemplate: "{{ log.station }}{% if log.activity != blank %}: {{ log.activity }}{% endif %} on {{ op.date }}"
|
|
24
|
+
};
|
|
25
|
+
function resolveGlobalExportSettings(global = {}) {
|
|
26
|
+
const resolved = { ...GLOBAL_EXPORT_DEFAULTS, ...global };
|
|
27
|
+
for (const prefix of ["reference", "other"]) {
|
|
28
|
+
for (const suffix of ["FilenameTemplate", "CompactFilenameTemplate", "TitleTemplate"]) {
|
|
29
|
+
const key = `${prefix}${suffix}`;
|
|
30
|
+
const legacy = `${suffix[0].toLowerCase()}${suffix.slice(1)}`;
|
|
31
|
+
if (global[key] === void 0 && typeof global[legacy] === "string") {
|
|
32
|
+
Object.assign(resolved, { [key]: global[legacy] });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
function exportTypeDefinition(activationType, format, label, defaults) {
|
|
39
|
+
return { exportType: `${activationType}-${format}`, activationType, format, label, defaults };
|
|
40
|
+
}
|
|
41
|
+
const templateKeys = [
|
|
42
|
+
"filenameTemplate",
|
|
43
|
+
"compactFilenameTemplate",
|
|
44
|
+
"titleTemplate",
|
|
45
|
+
"adifNotesTemplate",
|
|
46
|
+
"adifCommentTemplate",
|
|
47
|
+
"adifQslMessageTemplate"
|
|
48
|
+
];
|
|
49
|
+
function resolveExportSettings(definition, global = {}, overrides = {}) {
|
|
50
|
+
const defaults = resolveGlobalExportSettings(global);
|
|
51
|
+
const reference = definition.templateCategory === "reference";
|
|
52
|
+
const resolved = {
|
|
53
|
+
includePrivateData: defaults.includePrivateData,
|
|
54
|
+
includeLookupData: defaults.includeLookupData,
|
|
55
|
+
adifNotesTemplate: defaults.adifNotesTemplate,
|
|
56
|
+
adifCommentTemplate: defaults.adifCommentTemplate,
|
|
57
|
+
adifQslMessageTemplate: defaults.adifQslMessageTemplate,
|
|
58
|
+
filenameTemplate: reference ? defaults.referenceFilenameTemplate : defaults.otherFilenameTemplate,
|
|
59
|
+
compactFilenameTemplate: reference ? defaults.referenceCompactFilenameTemplate : defaults.otherCompactFilenameTemplate,
|
|
60
|
+
titleTemplate: reference ? defaults.referenceTitleTemplate : defaults.otherTitleTemplate,
|
|
61
|
+
...definition.defaults
|
|
62
|
+
};
|
|
63
|
+
for (const key of ["includePrivateData", "includeLookupData"]) {
|
|
64
|
+
if (typeof overrides[key] === "boolean") resolved[key] = overrides[key];
|
|
65
|
+
}
|
|
66
|
+
resolved.customTemplates = overrides.customTemplates === true;
|
|
67
|
+
if (resolved.customTemplates) {
|
|
68
|
+
for (const key of templateKeys) {
|
|
69
|
+
if (typeof overrides[key] === "string") resolved[key] = overrides[key];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (definition.format !== "adif") {
|
|
73
|
+
delete resolved.includePrivateData;
|
|
74
|
+
delete resolved.includeLookupData;
|
|
75
|
+
for (const key of templateKeys.slice(2)) delete resolved[key];
|
|
76
|
+
}
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
function exportQso(qso, settings) {
|
|
80
|
+
const copy = { ...qso };
|
|
81
|
+
for (const side of ["their", "our"]) {
|
|
82
|
+
if (copy[side] && typeof copy[side] === "object" && !Array.isArray(copy[side])) {
|
|
83
|
+
const station = { ...copy[side] };
|
|
84
|
+
if (settings.includeLookupData === false) delete station.guess;
|
|
85
|
+
if (settings.includePrivateData === false) {
|
|
86
|
+
for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete station[key];
|
|
87
|
+
if (typeof station.grid === "string") station.grid = station.grid.slice(0, 6);
|
|
88
|
+
if (station.guess && typeof station.guess === "object") {
|
|
89
|
+
const guess = { ...station.guess };
|
|
90
|
+
for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete guess[key];
|
|
91
|
+
if (typeof guess.grid === "string") guess.grid = guess.grid.slice(0, 6);
|
|
92
|
+
station.guess = guess;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
copy[side] = station;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (settings.includePrivateData === false) delete copy.notes;
|
|
99
|
+
return copy;
|
|
100
|
+
}
|
|
101
|
+
function prepareExportOption(option, operation, qsos, compact) {
|
|
102
|
+
const settings = option.exportSettings;
|
|
103
|
+
const extension = { adif: "adi", cabrillo: "log", reg1test: "edi" }[option.format] ?? option.filename?.split(".").pop() ?? "txt";
|
|
104
|
+
const data = {
|
|
105
|
+
station: String(operation.stationCall ?? ""),
|
|
106
|
+
ref: "",
|
|
107
|
+
activity: "",
|
|
108
|
+
modifier: "",
|
|
109
|
+
...option.templateData,
|
|
110
|
+
exportType: option.exportType,
|
|
111
|
+
format: option.format,
|
|
112
|
+
extension,
|
|
113
|
+
compact
|
|
114
|
+
};
|
|
115
|
+
if (isTestOperation(operation.stationCall)) data.modifier = [data.modifier, "Testing"].filter(Boolean).join(" ");
|
|
116
|
+
const context = templateContext({ operation, qsoCount: option.qsoCount ?? qsos.length, atMillis: startMillisOf(operation, qsos) ?? Date.now(), log: data });
|
|
117
|
+
const template = compact ? settings.compactFilenameTemplate : settings.filenameTemplate;
|
|
118
|
+
const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
|
|
119
|
+
const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
|
|
120
|
+
const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
|
|
121
|
+
const titleContext = settings.includePrivateData === false ? { ...context, op: { ...context.op, title: "", userTitle: "", userNotes: "", grid: String(context.op.grid ?? "").slice(0, 6) }, log: { ...context.log, modifier: "" } } : context;
|
|
122
|
+
const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
|
|
123
|
+
return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
EXPORT_DEFAULTS,
|
|
127
|
+
GLOBAL_EXPORT_DEFAULTS,
|
|
128
|
+
exportQso,
|
|
129
|
+
exportTypeDefinition,
|
|
130
|
+
prepareExportOption,
|
|
131
|
+
resolveExportSettings,
|
|
132
|
+
resolveGlobalExportSettings
|
|
133
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,121 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
1
|
+
// @ham2k/extension-sdk 0.5.0
|
|
2
|
+
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
|
+
export interface SvgScene {
|
|
4
|
+
version: 1;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
values: Record<string, number>;
|
|
8
|
+
layers: SvgSceneLayer[];
|
|
9
|
+
controls?: SvgSceneControl[];
|
|
10
|
+
}
|
|
11
|
+
/** A clamped, linear mapping. No expressions or extension code run per frame. */
|
|
12
|
+
export interface SceneBinding {
|
|
13
|
+
value: string;
|
|
14
|
+
/** Transform the source before mapping (for example selecting a display digit). */
|
|
15
|
+
scale?: number;
|
|
16
|
+
truncate?: boolean;
|
|
17
|
+
modulo?: number;
|
|
18
|
+
input: [
|
|
19
|
+
number,
|
|
20
|
+
number
|
|
21
|
+
];
|
|
22
|
+
output: [
|
|
23
|
+
number,
|
|
24
|
+
number
|
|
25
|
+
];
|
|
26
|
+
/** Optional equally spaced output samples over input; replaces the output ramp. */
|
|
27
|
+
samples?: number[];
|
|
28
|
+
}
|
|
29
|
+
export interface SvgSceneLayer {
|
|
30
|
+
id: string;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
/** Supply exactly one of svg or text. SVG must be self-contained. */
|
|
36
|
+
svg?: string;
|
|
37
|
+
/** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
|
|
38
|
+
* Size is unscaled; the host applies OS text scaling once. */
|
|
39
|
+
text?: {
|
|
40
|
+
value?: string;
|
|
41
|
+
literal?: string;
|
|
42
|
+
prefix?: string;
|
|
43
|
+
suffix?: string;
|
|
44
|
+
decimals?: number;
|
|
45
|
+
size?: number;
|
|
46
|
+
color?: string;
|
|
47
|
+
samples?: (number | string)[];
|
|
48
|
+
/** Multiply numeric text values before formatting. Defaults to 1. */
|
|
49
|
+
scale?: number;
|
|
50
|
+
/** Numeric formatting after scaling: truncate, then modulo, then zero padding. */
|
|
51
|
+
truncate?: boolean;
|
|
52
|
+
modulo?: number;
|
|
53
|
+
minIntegerDigits?: number;
|
|
54
|
+
fontFamily?: string;
|
|
55
|
+
fontWeight?: number;
|
|
56
|
+
lineHeight?: number;
|
|
57
|
+
letterSpacing?: number;
|
|
58
|
+
/** Horizontal alignment within the layer. Defaults to start. */
|
|
59
|
+
align?: "start" | "center" | "end";
|
|
60
|
+
};
|
|
61
|
+
rotation?: SceneBinding;
|
|
62
|
+
translateX?: SceneBinding;
|
|
63
|
+
translateY?: SceneBinding;
|
|
64
|
+
opacity?: SceneBinding;
|
|
65
|
+
/** Rotation origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
|
|
66
|
+
pivot?: [
|
|
67
|
+
number,
|
|
68
|
+
number
|
|
69
|
+
];
|
|
70
|
+
transitionMs?: number;
|
|
71
|
+
/** Local deterministic flicker/pulse; pauses when hidden, disabled for reduced motion. */
|
|
72
|
+
pulse?: {
|
|
73
|
+
periodMs: number;
|
|
74
|
+
minOpacity: number;
|
|
75
|
+
flicker?: boolean;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export interface SvgSceneControl {
|
|
79
|
+
id: string;
|
|
80
|
+
label: string;
|
|
81
|
+
kind: "button" | "slider" | "knob";
|
|
82
|
+
/** A button can open a native dropdown. Picking an item sends its event as the action. */
|
|
83
|
+
menu?: {
|
|
84
|
+
label: string;
|
|
85
|
+
event: string;
|
|
86
|
+
}[];
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
/** Omit event for local-only interaction (for example a chart inspection cursor). */
|
|
92
|
+
event?: string;
|
|
93
|
+
/** Send coalesced change events during dragging, plus the final commit. */
|
|
94
|
+
continuous?: boolean;
|
|
95
|
+
value?: string;
|
|
96
|
+
min?: number;
|
|
97
|
+
max?: number;
|
|
98
|
+
step?: number;
|
|
99
|
+
/** Equal-width buckets, with one bucket per step (useful for hourly charts). */
|
|
100
|
+
discrete?: boolean;
|
|
101
|
+
/** Inspect on hover. Only allowed on local-only sliders; touch/keyboard also work. */
|
|
102
|
+
hover?: boolean;
|
|
103
|
+
/** Accessible descriptions indexed by (value - min) / step. */
|
|
104
|
+
valueLabels?: string[];
|
|
105
|
+
/** Knobs use relative horizontal/upward dragging. Default: step per scene unit. */
|
|
106
|
+
sensitivity?: number;
|
|
107
|
+
}
|
|
108
|
+
export interface PanelSceneEvent {
|
|
109
|
+
controlId: string;
|
|
110
|
+
action: string;
|
|
111
|
+
phase: "activate" | "change" | "commit";
|
|
112
|
+
sequence: number;
|
|
113
|
+
value?: number;
|
|
114
|
+
}
|
|
115
|
+
/** Patch only named scene values. The host ignores stale responses. */
|
|
116
|
+
export interface PanelSceneEventResult {
|
|
117
|
+
values: Record<string, number>;
|
|
118
|
+
}
|
|
2
119
|
export type CallInfo = {
|
|
3
120
|
call: string;
|
|
4
121
|
baseCall?: string;
|
|
@@ -388,11 +505,44 @@ export interface AdifImportHook {
|
|
|
388
505
|
}[];
|
|
389
506
|
}, ctx: HookContext): Promise<(AdifImportResult | null)[]>;
|
|
390
507
|
}
|
|
508
|
+
export interface ExportSettings {
|
|
509
|
+
includePrivateData?: boolean;
|
|
510
|
+
includeLookupData?: boolean;
|
|
511
|
+
customTemplates?: boolean;
|
|
512
|
+
filenameTemplate?: string;
|
|
513
|
+
compactFilenameTemplate?: string;
|
|
514
|
+
titleTemplate?: string;
|
|
515
|
+
adifNotesTemplate?: string;
|
|
516
|
+
adifCommentTemplate?: string;
|
|
517
|
+
adifQslMessageTemplate?: string;
|
|
518
|
+
}
|
|
519
|
+
export interface GlobalExportSettings extends ExportSettings {
|
|
520
|
+
referenceFilenameTemplate?: string;
|
|
521
|
+
referenceCompactFilenameTemplate?: string;
|
|
522
|
+
referenceTitleTemplate?: string;
|
|
523
|
+
otherFilenameTemplate?: string;
|
|
524
|
+
otherCompactFilenameTemplate?: string;
|
|
525
|
+
otherTitleTemplate?: string;
|
|
526
|
+
}
|
|
527
|
+
export interface ExportTypeDefinition {
|
|
528
|
+
exportType: string;
|
|
529
|
+
label: string;
|
|
530
|
+
format: string;
|
|
531
|
+
activationType?: string;
|
|
532
|
+
templateCategory?: "reference" | "other";
|
|
533
|
+
defaults?: ExportSettings;
|
|
534
|
+
templateSample?: Record<string, JSONValue>;
|
|
535
|
+
}
|
|
391
536
|
export interface ExportRequest {
|
|
537
|
+
exportSettings?: ExportSettings;
|
|
538
|
+
exportData?: Record<string, JSONValue>;
|
|
539
|
+
exportTitle?: string;
|
|
540
|
+
includeLookupData?: boolean;
|
|
392
541
|
operation: Record<string, JSONValue>;
|
|
393
542
|
qsos: Record<string, JSONValue>[];
|
|
394
543
|
segments?: OperationSegmentPayload[];
|
|
395
544
|
exportType?: string;
|
|
545
|
+
exportKey?: string;
|
|
396
546
|
compactFilenames?: boolean;
|
|
397
547
|
includePrivateData?: boolean;
|
|
398
548
|
mainHandler?: string;
|
|
@@ -409,7 +559,10 @@ export interface ExportOptionsRequest {
|
|
|
409
559
|
compactFilenames?: boolean;
|
|
410
560
|
}
|
|
411
561
|
export interface ExportOption {
|
|
562
|
+
qsoCount?: number;
|
|
563
|
+
templateData?: Record<string, JSONValue>;
|
|
412
564
|
exportType: string;
|
|
565
|
+
exportKey?: string;
|
|
413
566
|
format: string;
|
|
414
567
|
label: string;
|
|
415
568
|
filename?: string;
|
|
@@ -421,6 +574,7 @@ export interface ExportOption {
|
|
|
421
574
|
refType?: string;
|
|
422
575
|
}
|
|
423
576
|
export interface ExportHook {
|
|
577
|
+
getExportTypes?(args: Record<string, never>, ctx: HookContext): Promise<ExportTypeDefinition[]>;
|
|
424
578
|
suggestExportOptions?(args: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
425
579
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
426
580
|
}
|
|
@@ -770,6 +924,8 @@ export interface ExtensionManifest {
|
|
|
770
924
|
domains?: string[];
|
|
771
925
|
allowUserAgentOverride?: boolean;
|
|
772
926
|
requiresLocation?: boolean;
|
|
927
|
+
requiresRadioRead?: boolean;
|
|
928
|
+
requiresRadioWrite?: boolean;
|
|
773
929
|
experiments?: string[];
|
|
774
930
|
relevance?: ExtensionRelevance;
|
|
775
931
|
translations?: Record<string, {
|
|
@@ -871,7 +1027,7 @@ export interface DataFileDefinition {
|
|
|
871
1027
|
onLoadRawData?: (data: any) => void;
|
|
872
1028
|
onRemoveRawData?: () => Promise<void>;
|
|
873
1029
|
}
|
|
874
|
-
export type FormFieldType = "text" | "multiline" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
|
|
1030
|
+
export type FormFieldType = "text" | "multiline" | "textTemplate" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
|
|
875
1031
|
export interface FormFieldOption {
|
|
876
1032
|
label: string;
|
|
877
1033
|
value: any;
|
|
@@ -885,6 +1041,8 @@ export interface FormField {
|
|
|
885
1041
|
label: string;
|
|
886
1042
|
value?: any;
|
|
887
1043
|
placeholder?: string;
|
|
1044
|
+
templateContext?: "export" | "adif" | "text";
|
|
1045
|
+
templateSample?: Record<string, JSONValue>;
|
|
888
1046
|
uppercase?: boolean;
|
|
889
1047
|
options?: FormFieldOption[] | string;
|
|
890
1048
|
validate?: (value: any, state: Record<string, any>) => string | null | undefined | Promise<string | null | undefined>;
|
|
@@ -934,6 +1092,8 @@ export interface FormMarkdownBlock {
|
|
|
934
1092
|
title?: string;
|
|
935
1093
|
}
|
|
936
1094
|
export interface FormActionElement {
|
|
1095
|
+
style?: "row";
|
|
1096
|
+
icon?: string;
|
|
937
1097
|
type: "action";
|
|
938
1098
|
key: string;
|
|
939
1099
|
label: string;
|
|
@@ -1081,7 +1241,75 @@ export interface PanelDescriptor {
|
|
|
1081
1241
|
multiple?: boolean;
|
|
1082
1242
|
form?: SettingsField[];
|
|
1083
1243
|
}
|
|
1244
|
+
/** Resolved host typography; sizes are logical pixels, not the app Font Scale. */
|
|
1245
|
+
export interface PanelTypography {
|
|
1246
|
+
fontFamily: string | null;
|
|
1247
|
+
fontFamilyFallback: string[];
|
|
1248
|
+
fontSize: number;
|
|
1249
|
+
/** OS text scaling at this exact role size (the scaler may be nonlinear). */
|
|
1250
|
+
scaledFontSize: number;
|
|
1251
|
+
fontWeight: number;
|
|
1252
|
+
lineHeight: number;
|
|
1253
|
+
letterSpacing: number;
|
|
1254
|
+
}
|
|
1255
|
+
/** Current placement environment. Never multiply coordinates by devicePixelRatio. */
|
|
1256
|
+
export interface PanelEnvironment {
|
|
1257
|
+
version: 1;
|
|
1258
|
+
width: number;
|
|
1259
|
+
height: number;
|
|
1260
|
+
safeInsets: {
|
|
1261
|
+
left: number;
|
|
1262
|
+
top: number;
|
|
1263
|
+
right: number;
|
|
1264
|
+
bottom: number;
|
|
1265
|
+
};
|
|
1266
|
+
brightness: "light" | "dark";
|
|
1267
|
+
colors: Record<"surface" | "surfaceContainer" | "onSurface" | "onSurfaceVariant" | "accent" | "primary" | "onPrimary" | "secondary" | "outline" | "outlineVariant" | "error" | "onError", string>;
|
|
1268
|
+
typography: Record<"body" | "label" | "title" | "display" | "mono", PanelTypography>;
|
|
1269
|
+
locale: string;
|
|
1270
|
+
textDirection: "ltr" | "rtl";
|
|
1271
|
+
devicePixelRatio: number;
|
|
1272
|
+
reducedMotion: boolean;
|
|
1273
|
+
highContrast: boolean;
|
|
1274
|
+
}
|
|
1275
|
+
/** A local CAT radio. Values are reported readings, never manual VFO fallbacks. */
|
|
1276
|
+
export interface PanelRadioState {
|
|
1277
|
+
id: string | null;
|
|
1278
|
+
name: string | null;
|
|
1279
|
+
status: "disconnected" | "connecting" | "connected" | "disconnecting" | "error";
|
|
1280
|
+
stale: boolean;
|
|
1281
|
+
problem: string | null;
|
|
1282
|
+
frequencyHz: number | null;
|
|
1283
|
+
mode: string | null;
|
|
1284
|
+
powerWatts: number | null;
|
|
1285
|
+
transmitting: boolean | null;
|
|
1286
|
+
meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
|
|
1287
|
+
canTune: boolean;
|
|
1288
|
+
}
|
|
1289
|
+
export interface PanelRadioTune {
|
|
1290
|
+
/** Reject if this identity no longer matches the selected radio. */
|
|
1291
|
+
id: string;
|
|
1292
|
+
/** Pin a local radio by ID; omitted means follow the designated radio. */
|
|
1293
|
+
selection?: string;
|
|
1294
|
+
frequencyHz?: number;
|
|
1295
|
+
mode?: "LSB" | "USB" | "CW" | "AM" | "FM";
|
|
1296
|
+
}
|
|
1297
|
+
export interface PanelRadioTuneResult {
|
|
1298
|
+
/** Accepted by the host; confirmation still comes from reported state. */
|
|
1299
|
+
accepted: boolean;
|
|
1300
|
+
reason?: "changed" | "disconnected" | "transmitting" | "invalid";
|
|
1301
|
+
state: PanelRadioState | null;
|
|
1302
|
+
}
|
|
1084
1303
|
export interface PanelRenderArgs {
|
|
1304
|
+
/** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
|
|
1305
|
+
environment?: PanelEnvironment;
|
|
1306
|
+
/** Display time follows developer time travel; real time is for network retry/cache budgets. */
|
|
1307
|
+
clock?: {
|
|
1308
|
+
nowMillis: number;
|
|
1309
|
+
realNowMillis: number;
|
|
1310
|
+
};
|
|
1311
|
+
/** Stable placement identity, supplied by scene-capable hosts. */
|
|
1312
|
+
instanceId?: string;
|
|
1085
1313
|
panelKey: string;
|
|
1086
1314
|
operation: Record<string, JSONValue>;
|
|
1087
1315
|
qso?: Record<string, JSONValue>;
|
|
@@ -1090,6 +1318,11 @@ export interface PanelRenderArgs {
|
|
|
1090
1318
|
reason: string;
|
|
1091
1319
|
}
|
|
1092
1320
|
export type PanelContent = {
|
|
1321
|
+
kind: "svgScene";
|
|
1322
|
+
scene: SvgScene;
|
|
1323
|
+
title?: string;
|
|
1324
|
+
triggers?: string[];
|
|
1325
|
+
} | {
|
|
1093
1326
|
kind: "markdown";
|
|
1094
1327
|
content: string;
|
|
1095
1328
|
title?: string;
|
|
@@ -1108,6 +1341,9 @@ export type PanelContent = {
|
|
|
1108
1341
|
export interface PanelHook {
|
|
1109
1342
|
getPanels(args: Record<string, never>, ctx: HookContext): Promise<PanelDescriptor[]>;
|
|
1110
1343
|
render(args: PanelRenderArgs, ctx: HookContext): Promise<PanelContent>;
|
|
1344
|
+
onEvent?(args: PanelRenderArgs & {
|
|
1345
|
+
event: PanelSceneEvent;
|
|
1346
|
+
}, ctx: HookContext): Promise<PanelSceneEventResult>;
|
|
1111
1347
|
}
|
|
1112
1348
|
/**
|
|
1113
1349
|
* The standard per-extension translator: takes the extension's i18next
|
|
@@ -1235,12 +1471,14 @@ export type ActivityScoresheet = {
|
|
|
1235
1471
|
export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
|
|
1236
1472
|
export interface ActivityExportRules {
|
|
1237
1473
|
key: string;
|
|
1474
|
+
templateSample?: Record<string, JSONValue>;
|
|
1238
1475
|
label: string;
|
|
1239
1476
|
activationType: string;
|
|
1240
1477
|
icon?: string;
|
|
1241
1478
|
includeFieldsFrom?: string[];
|
|
1242
1479
|
}
|
|
1243
1480
|
export declare function activityExportHook(rules: ActivityExportRules): {
|
|
1481
|
+
getExportTypes(): Promise<ExportTypeDefinition[]>;
|
|
1244
1482
|
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
1245
1483
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
1246
1484
|
};
|
|
@@ -1254,14 +1492,19 @@ export interface HuntingExportRules {
|
|
|
1254
1492
|
includeFieldsFrom?: string[];
|
|
1255
1493
|
}
|
|
1256
1494
|
export declare function huntingExportHook(rules: HuntingExportRules): {
|
|
1495
|
+
getExportTypes(): Promise<ExportTypeDefinition[]>;
|
|
1257
1496
|
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
1258
1497
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
1259
1498
|
};
|
|
1260
|
-
export declare function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }: {
|
|
1499
|
+
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
|
|
1261
1500
|
operation: Record<string, JSONValue>;
|
|
1262
1501
|
qsos: Record<string, JSONValue>[];
|
|
1263
1502
|
segments?: OperationSegmentPayload[];
|
|
1264
1503
|
includePrivateData?: boolean;
|
|
1504
|
+
includeLookupData?: boolean;
|
|
1505
|
+
exportSettings?: ExportSettings;
|
|
1506
|
+
exportData?: Record<string, JSONValue>;
|
|
1507
|
+
exportTitle?: string;
|
|
1265
1508
|
mainHandler: string;
|
|
1266
1509
|
includeFieldsFrom?: string[];
|
|
1267
1510
|
}): Promise<string>;
|
|
@@ -1284,6 +1527,19 @@ export interface ExportNameParts {
|
|
|
1284
1527
|
}
|
|
1285
1528
|
export declare function exportFilename(parts: ExportNameParts): string;
|
|
1286
1529
|
export declare function startMillisOf(operation: Record<string, JSONValue>, qsos?: Record<string, JSONValue>[]): number | undefined;
|
|
1530
|
+
export declare const EXPORT_DEFAULTS: ExportSettings;
|
|
1531
|
+
export declare const GLOBAL_EXPORT_DEFAULTS: GlobalExportSettings;
|
|
1532
|
+
export declare function resolveGlobalExportSettings(global?: GlobalExportSettings): GlobalExportSettings;
|
|
1533
|
+
export declare function exportTypeDefinition(activationType: string, format: string, label: string, defaults?: ExportSettings): ExportTypeDefinition;
|
|
1534
|
+
export declare function resolveExportSettings(definition: ExportTypeDefinition, global?: GlobalExportSettings, overrides?: ExportSettings): ExportSettings;
|
|
1535
|
+
export declare function exportQso(qso: Record<string, JSONValue>, settings: ExportSettings): Record<string, JSONValue>;
|
|
1536
|
+
export declare function prepareExportOption(option: ExportOption & {
|
|
1537
|
+
exportSettings: ExportSettings;
|
|
1538
|
+
}, operation: Record<string, JSONValue>, qsos: Record<string, JSONValue>[], compact: boolean): ExportOption & {
|
|
1539
|
+
exportSettings: ExportSettings;
|
|
1540
|
+
exportTitle: string;
|
|
1541
|
+
exportData: Record<string, JSONValue>;
|
|
1542
|
+
};
|
|
1287
1543
|
export type RefTransform = {
|
|
1288
1544
|
pattern: string;
|
|
1289
1545
|
replacement: string;
|
|
@@ -1397,6 +1653,14 @@ export declare const hooks: {
|
|
|
1397
1653
|
}[]>;
|
|
1398
1654
|
};
|
|
1399
1655
|
export declare const host: {
|
|
1656
|
+
listRadios(): Promise<PanelRadioState[]>;
|
|
1657
|
+
readRadio(id?: string): Promise<PanelRadioState | null>;
|
|
1658
|
+
setRadioConnection(args: {
|
|
1659
|
+
id: string;
|
|
1660
|
+
selection?: string;
|
|
1661
|
+
connected: boolean;
|
|
1662
|
+
}): Promise<PanelRadioTuneResult>;
|
|
1663
|
+
tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
|
|
1400
1664
|
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
1401
1665
|
kvGet(key: string): Promise<JSONValue | null>;
|
|
1402
1666
|
getLocation(): Promise<DeviceLocation | null>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
+
export * from "./svgScene.js";
|
|
2
3
|
export * from "./i18n.js";
|
|
3
4
|
export * from "./dxcc.js";
|
|
4
5
|
export * from "./location.js";
|
|
@@ -9,6 +10,7 @@ export * from "./activityScoring.js";
|
|
|
9
10
|
export * from "./activityExports.js";
|
|
10
11
|
export * from "./activityAdifImport.js";
|
|
11
12
|
export * from "./exportNames.js";
|
|
13
|
+
export * from "./exportSettings.js";
|
|
12
14
|
export * from "./referenceActivity.js";
|
|
13
15
|
export * from "./refTransforms.js";
|
|
14
16
|
export * from "./templates.js";
|
|
@@ -59,6 +61,18 @@ const hooks = {
|
|
|
59
61
|
}
|
|
60
62
|
};
|
|
61
63
|
const host = {
|
|
64
|
+
async listRadios() {
|
|
65
|
+
return await hostCallFn()("listRadios", {});
|
|
66
|
+
},
|
|
67
|
+
async readRadio(id) {
|
|
68
|
+
return await hostCallFn()("readRadio", { ...id ? { id } : {} });
|
|
69
|
+
},
|
|
70
|
+
async setRadioConnection(args) {
|
|
71
|
+
return await hostCallFn()("setRadioConnection", { ...args });
|
|
72
|
+
},
|
|
73
|
+
async tuneRadio(args) {
|
|
74
|
+
return await hostCallFn()("tuneRadio", { ...args });
|
|
75
|
+
},
|
|
62
76
|
async fetch(url, options) {
|
|
63
77
|
return await hostCallFn()("fetch", { url, ...options });
|
|
64
78
|
},
|
package/dist/svgScene.js
ADDED
|
File without changes
|
package/docs/forms.md
CHANGED
|
@@ -14,6 +14,7 @@ All types are defined in `extensions/sdk/src/types.ts`.
|
|
|
14
14
|
export type FormFieldType =
|
|
15
15
|
| 'text'
|
|
16
16
|
| 'multiline'
|
|
17
|
+
| 'textTemplate'
|
|
17
18
|
| 'email'
|
|
18
19
|
| 'callsign'
|
|
19
20
|
| 'number'
|
|
@@ -70,8 +71,8 @@ export interface FormField {
|
|
|
70
71
|
// fields — a whole section header can be dev-mode-only too.
|
|
71
72
|
devMode?: boolean;
|
|
72
73
|
// Settings-panel-only (ignored in ad hoc forms) — see settings.md's
|
|
73
|
-
// "Common
|
|
74
|
-
// the app's Common
|
|
74
|
+
// "Common Preferences and environment gating". Also shows this field on
|
|
75
|
+
// the app's Common Preferences quick-access panel.
|
|
75
76
|
common?: boolean;
|
|
76
77
|
// Settings-panel-only. Restricts which platform(s) show this field at
|
|
77
78
|
// all: one or more platform tokens (`ios`, `android`, `macos`,
|
|
@@ -129,6 +130,8 @@ export interface FormMarkdownBlock {
|
|
|
129
130
|
// SDK already permits (see settings.md's settingsPanel note). Not used for
|
|
130
131
|
// account credentials — those have their own testCredentials on AccountHook.
|
|
131
132
|
export interface FormActionElement {
|
|
133
|
+
style?: 'row'; // icon row with a chevron; omit for a button
|
|
134
|
+
icon?: string;
|
|
132
135
|
type: 'action';
|
|
133
136
|
key: string;
|
|
134
137
|
label: string;
|
|
@@ -277,3 +280,28 @@ api.registerHook('form', {
|
|
|
277
280
|
- Any validation error returns back to Dart and is displayed underneath the field. If any fields have errors, submission is aborted.
|
|
278
281
|
3. **Submit transformation**: When all fields are successfully validated, Dart invokes any JS transformation callbacks for each field.
|
|
279
282
|
4. **Completion**: The final transformed state is resolved and returned back to the caller (or saved in the view context).
|
|
283
|
+
|
|
284
|
+
## Text templates
|
|
285
|
+
|
|
286
|
+
`fieldType: 'textTemplate'` is a string field displayed as an editor row in
|
|
287
|
+
forms and settings. It opens a multiline Liquid editor with a sample preview,
|
|
288
|
+
syntax errors, insertable attributes and flow-control examples, and a read-only
|
|
289
|
+
filter reference inside collapsed Templating Docs. Editing and preview stay
|
|
290
|
+
above the scrollable reference. Template rows also display rendered samples. Save commits
|
|
291
|
+
the draft; Cancel, Escape and dismissal discard it. `defaultValue` supplies
|
|
292
|
+
the Reset button. Disabled fields cannot open the editor.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
{
|
|
296
|
+
type: 'field', fieldType: 'textTemplate', key: 'filename',
|
|
297
|
+
label: 'File name', value: '{{ op.date }} {{ log.station }}',
|
|
298
|
+
templateContext: 'export', // 'adif' adds contact attributes; 'text' is generic
|
|
299
|
+
templateSample: { log: { activity: 'POTA', ref: 'US-1234' } },
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The preview uses the same runtime and vocabulary as exports; sample values
|
|
304
|
+
are illustrative. `templateSample` can replace values in `operation`, `qso`
|
|
305
|
+
and `log`. The app provides the rendering callback through
|
|
306
|
+
`TemplateEditorScope`, keeping the form widget independent of extensions.
|
|
307
|
+
See [templates.md](templates.md) for Liquid syntax and supported namespaces.
|
package/docs/hooks.md
CHANGED
|
@@ -248,23 +248,91 @@ fetch-only for now.
|
|
|
248
248
|
|
|
249
249
|
```ts
|
|
250
250
|
interface ExportHook {
|
|
251
|
+
getExportTypes?(args: {}, ctx): Promise<ExportTypeDefinition[]>
|
|
251
252
|
suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
|
|
252
253
|
generateExport(args: ExportRequest, ctx): Promise<ExportResult>
|
|
253
254
|
}
|
|
254
255
|
// ExportOptionsRequest: {operation, qsos, compactFilenames?}
|
|
255
|
-
// ExportOption: {exportType, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
|
|
256
|
-
// ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
|
|
256
|
+
// ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
|
|
257
|
+
// ExportRequest: {operation, qsos, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
|
|
257
258
|
// ExportResult: {filename, mimeType, content}
|
|
258
259
|
```
|
|
259
260
|
|
|
260
261
|
Two-step flow: the Exports Panel calls `suggestExportOptions` on every
|
|
261
262
|
`export` hook to build its selectable list (a hook that omits it never
|
|
262
263
|
appears in the panel), then calls `generateExport` — passing back the
|
|
263
|
-
chosen option's `exportType` — only for the options the user
|
|
264
|
-
core owns file I/O and save/share; the hook only produces content.
|
|
264
|
+
chosen option's `exportType` and `exportKey` — only for the options the user
|
|
265
|
+
selected. The core owns file I/O and save/share; the hook only produces content.
|
|
266
|
+
|
|
267
|
+
`exportType` is the stable kind (for example `potaActivation-adif`); `exportKey`
|
|
268
|
+
identifies an individual option within the hook (for example
|
|
269
|
+
`pota-adif:US-1234`). Options sharing a type must have distinct keys. Omit
|
|
270
|
+
`exportKey` for a single option of a type: selection and generation default
|
|
271
|
+
it to `exportType`. Both values round-trip unchanged when an explicit key is
|
|
272
|
+
provided; the core does not interpret them. Selection also includes the
|
|
273
|
+
hook key and station callsign, keeping different hooks and stations independent.
|
|
274
|
+
|
|
265
275
|
Exporters should compose per-QSO program fields from `adifFields` hooks (see
|
|
266
|
-
below) rather than knowing about specific activities. Implemented by
|
|
267
|
-
|
|
276
|
+
below) rather than knowing about specific activities. Implemented by the whole-log `adif` exporter, activity exports, and contest
|
|
277
|
+
ADIF/Cabrillo exporters.
|
|
278
|
+
|
|
279
|
+
**Registering types and settings.** `getExportTypes` runs without an operation,
|
|
280
|
+
so Settings can list types even before the operator has a matching log:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
async getExportTypes() {
|
|
284
|
+
return [{
|
|
285
|
+
exportType: 'potaActivation-adif',
|
|
286
|
+
activationType: 'potaActivation',
|
|
287
|
+
format: 'adif',
|
|
288
|
+
label: 'POTA',
|
|
289
|
+
defaults: { includePrivateData: false, includeLookupData: true },
|
|
290
|
+
}]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Prefer `<activationType>-<format>` to an extension-key namespace. Two
|
|
295
|
+
extensions offering that type share one settings entry. The highest-priority
|
|
296
|
+
registration supplies its definition; other exporters using it must honor
|
|
297
|
+
that contract. Distinct sponsor requirements can use a distinct type. The
|
|
298
|
+
SDK's `exportTypeDefinition(activationType, format, label, defaults?)` builds
|
|
299
|
+
the conventional definition; the activity and hunting helpers register their
|
|
300
|
+
own types. An exporter without `getExportTypes` still works, but does not
|
|
301
|
+
appear in the export settings list.
|
|
302
|
+
|
|
303
|
+
`ExportSettings` contains `includePrivateData`, `includeLookupData`,
|
|
304
|
+
`customTemplates`, `filenameTemplate`, `compactFilenameTemplate`,
|
|
305
|
+
`titleTemplate`, `adifNotesTemplate`, `adifCommentTemplate`, and
|
|
306
|
+
`adifQslMessageTemplate`. All are optional. Defaults resolve from the SDK,
|
|
307
|
+
then global preferences, then the type's declared defaults.
|
|
308
|
+
`GlobalExportSettings` adds `referenceFilenameTemplate`,
|
|
309
|
+
`referenceCompactFilenameTemplate`, `referenceTitleTemplate` and the matching
|
|
310
|
+
`other…` fields. A type with `templateCategory: 'reference'` uses the reference
|
|
311
|
+
set; other types use the other set. The activity export helper registers
|
|
312
|
+
reference types automatically. ADIF field templates remain common to both sets.
|
|
313
|
+
Saved common filename/title defaults remain fallbacks for categories without
|
|
314
|
+
an explicit setting. Per-type data
|
|
315
|
+
choices override those; per-type template overrides apply only when
|
|
316
|
+
`customTemplates` is on. Empty templates intentionally suppress text. Turning
|
|
317
|
+
custom templates off preserves the operator's edits for later. Non-ADIF
|
|
318
|
+
formats expose only the filename templates and their custom-template switch.
|
|
319
|
+
|
|
320
|
+
Type definitions may supply `templateSample: {operation, log}` for representative
|
|
321
|
+
editor examples. Each format supplies its own filename extension in the sample.
|
|
322
|
+
|
|
323
|
+
An option can supply `qsoCount` when its hook further filters the contacts
|
|
324
|
+
(for example, a hunter export). Otherwise the host derives the count from
|
|
325
|
+
activity and station scope. Filename and title templates use this count.
|
|
326
|
+
|
|
327
|
+
Options can carry `templateData: {ref, refName, activity, modifier, ...}`,
|
|
328
|
+
which populates the `log` namespace. The app renders filenames and titles
|
|
329
|
+
before showing the option, and passes the same `exportSettings`, `exportData`
|
|
330
|
+
and `exportTitle` to generation. It also passes the resolved
|
|
331
|
+
`includePrivateData` and `includeLookupData` for ADIF. Forward those fields
|
|
332
|
+
when delegating through `adifForExport`, or a program's preferences will not
|
|
333
|
+
reach the ADIF writer. Settings apply globally and per type, never per operation.
|
|
334
|
+
The whole-log ADIF defaults to including private data, but its type setting
|
|
335
|
+
can override that default.
|
|
268
336
|
|
|
269
337
|
**How a row looks.** `refType` names the activity the export covers — the
|
|
270
338
|
core slices the file's QSOs by it, and the panel takes the row's icon and
|
|
@@ -1169,6 +1237,7 @@ own identity, config form and refresh budget.
|
|
|
1169
1237
|
interface PanelHook {
|
|
1170
1238
|
getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
|
|
1171
1239
|
render(args: PanelRenderArgs, ctx): Promise<PanelContent>
|
|
1240
|
+
onEvent?(args: PanelRenderArgs & {event: PanelSceneEvent}, ctx): Promise<PanelSceneEventResult>
|
|
1172
1241
|
}
|
|
1173
1242
|
// PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
|
|
1174
1243
|
// PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
|
|
@@ -1179,6 +1248,101 @@ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
|
|
|
1179
1248
|
inside saved layouts — renaming a `key` drops the panel out of every
|
|
1180
1249
|
arrangement holding it.
|
|
1181
1250
|
|
|
1251
|
+
An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
|
|
1252
|
+
local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
|
|
1253
|
+
title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
|
|
1254
|
+
`instanceId` on render/event calls. `onEvent` handles control events and returns
|
|
1255
|
+
numeric value patches; local-only controls and animation frames make no bridge
|
|
1256
|
+
calls. Controls with `continuous: true` send `change` events during dragging and a
|
|
1257
|
+
final `commit` on release; pending movements are coalesced to the newest value.
|
|
1258
|
+
Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
|
|
1259
|
+
for local numeric formatting. The Radio Panel Example uses these for its Modern
|
|
1260
|
+
frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
|
|
1261
|
+
may hold numbers or preformatted strings; control `valueLabels` supplies accessible
|
|
1262
|
+
value descriptions. Transform/opacity bindings may also supply numeric `samples`,
|
|
1263
|
+
equally spaced over their `input` range, instead of the `output` ramp. The host
|
|
1264
|
+
interpolates and clamps these locally; chart markers and callouts can follow
|
|
1265
|
+
individual readings without bridge calls. See [the prototype contract and migration plan](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
|
|
1266
|
+
and the `k2hrc-svg-scenes` sample. This API is experimental.
|
|
1267
|
+
|
|
1268
|
+
Scene-capable hosts also supply `args.environment` on **render and event** calls:
|
|
1269
|
+
|
|
1270
|
+
- `width`, `height`, and `safeInsets` describe the current panel in logical pixels.
|
|
1271
|
+
The app's Font Scale is already applied to this coordinate space. Do not multiply
|
|
1272
|
+
coordinates by a font-scale preference or `devicePixelRatio`.
|
|
1273
|
+
- `brightness` is resolved to `light` or `dark`, including system theme changes.
|
|
1274
|
+
`colors` contains `surface`, `surfaceContainer`, `onSurface`, `onSurfaceVariant`,
|
|
1275
|
+
`accent`, `primary`, `onPrimary`, `secondary`, `outline`, `outlineVariant`,
|
|
1276
|
+
`error`, and `onError`, all `#RRGGBB` strings.
|
|
1277
|
+
- `typography` contains `body`, `label`, `title`, `display`, and `mono` roles, each
|
|
1278
|
+
with `fontFamily`, fallbacks, `fontSize`, `scaledFontSize`, `fontWeight`,
|
|
1279
|
+
`lineHeight`, and `letterSpacing`. Family can be null for a platform default.
|
|
1280
|
+
`scaledFontSize` describes OS accessibility scaling **at that role's size**;
|
|
1281
|
+
do not assume one linear factor for all sizes. Use it when reserving label space.
|
|
1282
|
+
Supply the unscaled `fontSize` as scene text `size`: the host scales text once.
|
|
1283
|
+
- `locale`, `textDirection`, `devicePixelRatio`, `reducedMotion`, and `highContrast`
|
|
1284
|
+
describe the resolved presentation context. Font names refer to host fonts;
|
|
1285
|
+
they are not URLs or permission to load remote fonts.
|
|
1286
|
+
|
|
1287
|
+
For a panel showing an `svgScene`, environment changes automatically request a
|
|
1288
|
+
render through the existing throttle, even with no triggers declared. Hidden
|
|
1289
|
+
panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
|
|
1290
|
+
an old environment cannot overwrite a newer one. Markdown and HTML panels are
|
|
1291
|
+
themed and laid out by the host, so an environment change does not re-render
|
|
1292
|
+
them; their next render carries the current environment.
|
|
1293
|
+
`args.clock.nowMillis` follows the app clock; `realNowMillis` is for network retry
|
|
1294
|
+
and cache budgets, so developer time travel does not hammer an external API.
|
|
1295
|
+
Both `environment` and `clock` are optional in the SDK for older-host detection.
|
|
1296
|
+
|
|
1297
|
+
Use scene **text layers** for typography; SVG `<text>` is not a portable text path
|
|
1298
|
+
through the renderer. Text supports either `literal` or bound `value`, plus
|
|
1299
|
+
`fontFamily`, `fontWeight`, `lineHeight`, and `letterSpacing`. Text participates
|
|
1300
|
+
in native directionality, accessibility scaling and semantics.
|
|
1301
|
+
Button controls can supply `menu: [{label, event}]` to open a native dropdown.
|
|
1302
|
+
Selecting an item emits its `event` as the action with the original control ID;
|
|
1303
|
+
dismissing the menu emits nothing.
|
|
1304
|
+
|
|
1305
|
+
The `svg-weather`, `svg-solar`, and `svg-radio` reference panels live in
|
|
1306
|
+
[ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
|
|
1307
|
+
They use public host APIs and ship as `.h2kext` packages, not app assets.
|
|
1308
|
+
Install the packages, then add their panels through Edit Layout. Their original
|
|
1309
|
+
extension and panel keys are retained so existing placements and settings survive.
|
|
1310
|
+
See that repository’s dashboard README for building against the unreleased SDK.
|
|
1311
|
+
|
|
1312
|
+
The radio host calls are manifest capabilities, refused unless declared — the
|
|
1313
|
+
same shape as `requiresLocation`, and shown to the operator at install:
|
|
1314
|
+
`"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
|
|
1315
|
+
`"requiresRadioWrite": true` allows `host.tuneRadio()` and
|
|
1316
|
+
`host.setRadioConnection()`, and implies read, since a command answers with the
|
|
1317
|
+
radio's state. An undeclared call rejects rather than answering null.
|
|
1318
|
+
|
|
1319
|
+
**Radio Panel Example** is the CAT reference extension. `host.listRadios()` lists configured
|
|
1320
|
+
local transceivers. `host.readRadio(id?)` returns
|
|
1321
|
+
a **local** radio's reported frequency (Hz), mode, connection state,
|
|
1322
|
+
power, TX state and fresh telemetry, or null on hosts without this API.
|
|
1323
|
+
`meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
|
|
1324
|
+
times a second; it expires after two seconds without a reading. The Flex driver receives the selected slice’s LEVEL
|
|
1325
|
+
meter through its native UDP channel. The reference panel draws a segmented
|
|
1326
|
+
receive meter and leaves it unlit when unavailable, stale, or transmitting. It does
|
|
1327
|
+
not substitute the app's manually entered VFO for a radio reading.
|
|
1328
|
+
`host.tuneRadio({id, frequencyHz?, mode?})` targets that same radio through the
|
|
1329
|
+
existing VFO/CAT service. Supported mode requests are LSB, USB, CW, AM and FM.
|
|
1330
|
+
The host rejects a changed designation, disconnected/stale radio, transmission
|
|
1331
|
+
in progress or malformed command, and spaces accepted tuning requests by at
|
|
1332
|
+
least 250 ms. Omitting a selection follows the designated logging radio; an explicit `selection`
|
|
1333
|
+
pins tuning to that local radio without changing the logging designation.
|
|
1334
|
+
An accepted result means requested, not confirmed; only subsequent
|
|
1335
|
+
reported state confirms it. Drivers retain their own protocol/echo handling.
|
|
1336
|
+
`host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
|
|
1337
|
+
connection using the same selection and identity checks. Acceptance starts the
|
|
1338
|
+
operation; follow reported connection state for completion. It does not switch
|
|
1339
|
+
the hardware's mains power or initiate transmission. The reference panel samples
|
|
1340
|
+
state once per second while visible; knob motion and LCD digit updates run locally,
|
|
1341
|
+
and tuning is sent continuously during dragging, paced by the CAT adapter. The
|
|
1342
|
+
main readout follows the requested value immediately; confirmation status distinguishes pending requests from the radio
|
|
1343
|
+
reporting the requested frequency. Radio-specific hardware verification remains
|
|
1344
|
+
necessary before considering the experimental control API stable.
|
|
1345
|
+
|
|
1182
1346
|
**Content is a document, not widgets** — the same UI-catalog seam every
|
|
1183
1347
|
other declarative hook sits behind. Prefer `markdown`: it renders with the
|
|
1184
1348
|
app's own typography, theme, font scale and density, and costs no web view.
|
package/docs/settings.md
CHANGED
|
@@ -372,17 +372,18 @@ Aliases are for names an operator would plausibly *reach for* — not a thesauru
|
|
|
372
372
|
Every alias is one more thing that can collide with another setting's real name,
|
|
373
373
|
and a collision costs a disambiguation prompt on a query that used to be exact.
|
|
374
374
|
|
|
375
|
-
## Common
|
|
375
|
+
## Common Preferences and environment gating
|
|
376
376
|
|
|
377
|
-
The app has one settings screen, **Settings
|
|
378
|
-
|
|
377
|
+
The app has one settings screen, **Settings**, whose **Application
|
|
378
|
+
Preferences** panel has a collapsible section per declared group and panel,
|
|
379
|
+
led by **Common Preferences**, a short list of the
|
|
379
380
|
handful of settings most users ever touch. Which fields land on Common
|
|
380
|
-
|
|
381
|
+
Preferences —
|
|
381
382
|
and which platforms show a field at all — is declared per-element, not
|
|
382
383
|
maintained as a separate list somewhere else in the app. Any field, link, or
|
|
383
384
|
action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
384
385
|
|
|
385
|
-
- **`common: true`** — also show this element in the Common
|
|
386
|
+
- **`common: true`** — also show this element in the Common Preferences
|
|
386
387
|
section, on top of its own group's. Off by default. Not the same thing as
|
|
387
388
|
`FormFieldOption.common`/`uncommon` above — that's a `multiselect`
|
|
388
389
|
option's own disclosure tier (upfront vs. behind "Show more"), a
|
|
@@ -395,7 +396,7 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
|
|
|
395
396
|
- **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
|
|
396
397
|
restrict which platform(s) show this element at all. This is a platform
|
|
397
398
|
gate, not a common-vs-everything one: an excluded field disappears from every
|
|
398
|
-
settings surface on that platform, not just Common
|
|
399
|
+
settings surface on that platform, not just Common Preferences. Every
|
|
399
400
|
token unprefixed is a whitelist (show ONLY there); every token
|
|
400
401
|
`-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
|
|
401
402
|
forms in one attribute is invalid and treated as if `environment` were
|
package/docs/templates.md
CHANGED
|
@@ -19,7 +19,7 @@ const text = renderTemplate("{{ op.station }} — {{ op.qsoCount }} QSOs", templ
|
|
|
19
19
|
|
|
20
20
|
A template is DATA. Liquid parses and interprets; Handlebars compiles
|
|
21
21
|
through `new Function`. These templates are written by operators, copied
|
|
22
|
-
between operators, and
|
|
22
|
+
between operators, and are editable in settings, so the one
|
|
23
23
|
that cannot become code is the one to have. Liquid also has comparisons
|
|
24
24
|
(`{% if op.qsoCount > 100 %}`) and loops built in, where Handlebars needs a
|
|
25
25
|
registered helper for `>` and Mustache cannot compare at all.
|
|
@@ -42,15 +42,15 @@ the namespaces are deliberately polo's. The syntax around them does not:
|
|
|
42
42
|
|---|---|---|---|---|
|
|
43
43
|
| `app` | ✓ | — | ✓ | ✓ |
|
|
44
44
|
| `now` | ✓ | ✓ | ✓ | ✓ |
|
|
45
|
-
| `op` | ✓ |
|
|
45
|
+
| `op` | ✓ | ✓ | ✓ | ✓ |
|
|
46
46
|
| `qso` | when the placement's triggers ask for it | — | ✓ | the draft contact, as far as it is typed |
|
|
47
47
|
| `config` | ✓ | — | — | — |
|
|
48
48
|
| `log` | — | ✓ | ✓ | — |
|
|
49
49
|
|
|
50
50
|
A namespace a surface has nothing for is **absent**, not blank — which is
|
|
51
|
-
what makes `{% if qso %}` an honest question.
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
what makes `{% if qso %}` an honest question. Registered exports get the operation plus the selected file’s `log` values.
|
|
52
|
+
The low-level `exportFilename(parts)` helper still supplies only dates and
|
|
53
|
+
filename parts.
|
|
54
54
|
|
|
55
55
|
### `app`
|
|
56
56
|
`app.name` — the platform-appropriate name ("Ham2K Logger" on desktop,
|
|
@@ -191,16 +191,16 @@ will jump rather than count. Show HH:MM.
|
|
|
191
191
|
| what | where | editable |
|
|
192
192
|
|---|---|---|
|
|
193
193
|
| Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
|
|
194
|
-
| Export filenames | `sdk/src/
|
|
195
|
-
| ADIF NOTES / COMMENT / QSLMSG | `core/adif`
|
|
194
|
+
| Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
|
|
195
|
+
| ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
|
|
196
196
|
| 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 Messages… dialog |
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
defaults
|
|
200
|
-
references
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
NOTES and COMMENT default to QSO notes and are withheld when private data
|
|
199
|
+
is off. QSLMSG defaults to empty for program exports, and to the operation’s
|
|
200
|
+
references for the whole-log ADIF. Custom QSL messages may use contact values.
|
|
201
|
+
Templates receive no withheld private or lookup values. File titles obey the
|
|
202
|
+
private-data choice; filename templates are labels for the operator’s files.
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
The `textTemplate` form field provides sample previews and an insertable
|
|
205
|
+
reference; see [forms.md](forms.md). Invalid Liquid reports an error rather
|
|
206
|
+
than silently replacing the operator’s template with a default.
|
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-llota` | An award program: references, an offline list, activation scoring |
|
|
11
11
|
| `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
|
|
12
12
|
| `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
|
|
13
|
+
| `k2hrc-svg-scenes` | Experimental SVG controls and animations, with simulated radio and weather panels |
|
|
13
14
|
|
|
14
15
|
## Running one
|
|
15
16
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SVG Scene Prototypes
|
|
2
|
+
|
|
3
|
+
Experimental radio front face and weather inspection chart, using the public
|
|
4
|
+
`svgScene` / `PanelHook.onEvent` contracts. Requires a host with SVG scene support.
|
|
5
|
+
All data is simulated; this extension does not fetch weather or control a radio.
|
|
6
|
+
|
|
7
|
+
The radio keeps requested frequency separate from reported frequency, simulates
|
|
8
|
+
2-second CAT acknowledgments, and has buttons for external tuning, disconnection,
|
|
9
|
+
and changing acknowledgment delay. Drag the knob horizontally/upward, use the band
|
|
10
|
+
slider, or focus either control and use arrows (Shift for larger steps) / the wheel.
|
|
11
|
+
|
|
12
|
+
The weather chart demonstrates local-only cursor and numeric sample inspection.
|
|
13
|
+
Drag, click, or use arrows; no interaction events cross the extension bridge.
|
|
14
|
+
|
|
15
|
+
In this repository:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node build.mjs
|
|
19
|
+
node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-svg-scenes.h2kext
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For the standalone Flutter preview and migration plan, see
|
|
23
|
+
`docs/design/svg-scenes.md` in the logger repository. This is an API experiment,
|
|
24
|
+
not a replacement for the production weather/solar panels yet.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "k2hrc-svg-scenes",
|
|
3
|
+
"name": "SVG Scene Prototypes",
|
|
4
|
+
"shortName": "SVG Scenes",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "Experimental radio front face and weather chart; simulated data only",
|
|
7
|
+
"category": "dashboard",
|
|
8
|
+
"icon": "radio-tower",
|
|
9
|
+
"accentColor": "#4C6EF5",
|
|
10
|
+
"api": 1,
|
|
11
|
+
"keywords": [
|
|
12
|
+
"svg",
|
|
13
|
+
"panel",
|
|
14
|
+
"sample",
|
|
15
|
+
"weather",
|
|
16
|
+
"radio"
|
|
17
|
+
],
|
|
18
|
+
"hooks": [
|
|
19
|
+
"panel"
|
|
20
|
+
],
|
|
21
|
+
"sharedDependencies": {
|
|
22
|
+
"@ham2k/lib-callsigns": "^1.0.0",
|
|
23
|
+
"@ham2k/lib-country-files": "^1.0.0",
|
|
24
|
+
"@ham2k/lib-dxcc-data": "^1.0.0",
|
|
25
|
+
"@ham2k/lib-format-tools": "^1.0.0",
|
|
26
|
+
"@ham2k/lib-geo-tools": "^1.0.0",
|
|
27
|
+
"@ham2k/lib-operation-data": "^1.0.0",
|
|
28
|
+
"i18next": "^23.0.0",
|
|
29
|
+
"liquidjs": "^10.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { defineExtension } from '@ham2k/extension-sdk'
|
|
4
|
+
import manifest from '../manifest.json' with { type: 'json' }
|
|
5
|
+
import { createScenePanels } from './scenes.ts'
|
|
6
|
+
|
|
7
|
+
defineExtension({
|
|
8
|
+
...manifest,
|
|
9
|
+
onActivation({ registerHook }) {
|
|
10
|
+
registerHook('panel', { key: manifest.key, hook: createScenePanels() })
|
|
11
|
+
},
|
|
12
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
|
|
4
|
+
|
|
5
|
+
const svg = (body: string, width = 640, height = 360) =>
|
|
6
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
7
|
+
const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
|
|
8
|
+
const label = (x: number, y: number, text: string, size = 16, color = '#9aaec6') =>
|
|
9
|
+
`<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
|
|
10
|
+
const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, '#e7edf4')}`
|
|
11
|
+
const textLayer = (id: string, value: string, x: number, y: number, width: number,
|
|
12
|
+
size: number, prefix = '', suffix = '', decimals = 0): SvgSceneLayer =>
|
|
13
|
+
({ id, x, y, width, height: size + 12, text: { value, prefix, suffix, decimals, size, color: '#a9efcf' } })
|
|
14
|
+
|
|
15
|
+
type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
|
|
16
|
+
/** Inject time for deterministic tests; no timers or per-frame extension work. */
|
|
17
|
+
export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
|
|
18
|
+
const radios = new Map<string, RadioState>()
|
|
19
|
+
function state(id: string): RadioState {
|
|
20
|
+
let s = radios.get(id)
|
|
21
|
+
if (!s) {
|
|
22
|
+
// Bounded session-only simulator state; no storage or CAT commands.
|
|
23
|
+
if (radios.size >= 64) radios.delete(radios.keys().next().value!)
|
|
24
|
+
s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
|
|
25
|
+
radios.set(id, s)
|
|
26
|
+
}
|
|
27
|
+
if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
|
|
28
|
+
return s
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async getPanels() {
|
|
32
|
+
return [
|
|
33
|
+
{ key: 'radio', title: 'Radio Front Face · Simulation', icon: 'radio-tower', multiple: true, on: ['tick:1'] },
|
|
34
|
+
{ key: 'weather', title: 'Weather Chart · Simulation', icon: 'weather-partly-cloudy', multiple: true },
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
async render(args): Promise<PanelContent> {
|
|
38
|
+
if (args.panelKey === 'weather') {
|
|
39
|
+
const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
|
|
40
|
+
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(' ')
|
|
41
|
+
return { kind: 'svgScene', scene: {
|
|
42
|
+
version: 1, width: 640, height: 360, values: { hour: 0 },
|
|
43
|
+
layers: [
|
|
44
|
+
{ id: 'chart', x: 0, y: 0, width: 640, height: 360, svg: svg(`
|
|
45
|
+
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
46
|
+
${label(30, 38, 'FORECAST / SIMULATED DATA', 18, '#e7edf4')}
|
|
47
|
+
${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join('')}
|
|
48
|
+
<polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
|
|
49
|
+
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, '+' + i + 'h', 12)}`).join('')}
|
|
50
|
+
${label(30, 334, 'Drag the chart to inspect · Arrow keys to step', 14)}
|
|
51
|
+
`) },
|
|
52
|
+
{ id: 'cursor', x: 59, y: 100, width: 2, height: 172,
|
|
53
|
+
svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
|
|
54
|
+
translateX: bind('hour', [0, 7], [0, 518]) },
|
|
55
|
+
textLayer('hour', 'hour', 30, 52, 250, 24, 'In ', ' hours'),
|
|
56
|
+
{ ...textLayer('temperature', 'hour', 370, 52, 240, 24, '', ' °C'),
|
|
57
|
+
text: { value: 'hour', samples: temperatures, suffix: ' °C', size: 24, color: '#a9efcf' } },
|
|
58
|
+
],
|
|
59
|
+
controls: [{ id: 'inspect', kind: 'slider', label: 'Forecast hour', x: 60, y: 100, width: 518, height: 185,
|
|
60
|
+
value: 'hour', min: 0, max: 7, step: 1 }],
|
|
61
|
+
} }
|
|
62
|
+
}
|
|
63
|
+
const s = state(args.instanceId ?? args.panelKey)
|
|
64
|
+
return { kind: 'svgScene', scene: {
|
|
65
|
+
version: 1, width: 640, height: 360,
|
|
66
|
+
values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
|
|
67
|
+
layers: [
|
|
68
|
+
{ id: 'face', x: 0, y: 0, width: 640, height: 360, svg: svg(`
|
|
69
|
+
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
70
|
+
${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
|
|
71
|
+
<rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
|
|
72
|
+
${label(42, 78, 'REPORTED VFO', 12)}${label(42, 143, 'REQUESTED', 12)}
|
|
73
|
+
<path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
|
|
74
|
+
${label(423, 170, '14.0', 12)}${label(565, 170, '14.35', 12)}
|
|
75
|
+
${label(424, 197, 'VFO POSITION', 12)}
|
|
76
|
+
<path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
|
|
77
|
+
${label(42, 268, 'Band position / kHz', 14)}
|
|
78
|
+
${label(548, 256, 'LINK', 12)}
|
|
79
|
+
${button(28, 'Connect / disconnect')}${button(234, 'External tune +5 kHz')}${button(440, 'Toggle 2 s / 0 s delay')}
|
|
80
|
+
`) },
|
|
81
|
+
textLayer('reported', 'reported', 42, 78, 320, 34, '', ' kHz', 1),
|
|
82
|
+
textLayer('requested', 'requested', 168, 121, 210, 22, '', '', 1),
|
|
83
|
+
{ id: 'needle', x: 503, y: 72, width: 8, height: 80, pivot: [0.5, 1], transitionMs: 450,
|
|
84
|
+
svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
|
|
85
|
+
rotation: bind('reported', [14000, 14350], [-80, 80]) },
|
|
86
|
+
{ id: 'knob', x: 400, y: 204, width: 72, height: 72,
|
|
87
|
+
svg: svg('<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>', 72, 72),
|
|
88
|
+
rotation: bind('requested', [14000, 14350], [-150, 150]) },
|
|
89
|
+
{ id: 'handle', x: 36, y: 224, width: 12, height: 24,
|
|
90
|
+
svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
|
|
91
|
+
translateX: bind('requested', [14000, 14350], [0, 290]) },
|
|
92
|
+
{ id: 'led', x: 548, y: 208, width: 40, height: 40,
|
|
93
|
+
svg: svg('<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>', 40, 40),
|
|
94
|
+
opacity: bind('connected', [0, 1], [0.08, 1]), pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true } },
|
|
95
|
+
],
|
|
96
|
+
controls: [
|
|
97
|
+
{ id: 'tune', label: 'Tuning knob, kHz', kind: 'knob', x: 398, y: 202, width: 76, height: 76,
|
|
98
|
+
value: 'requested', min: 14000, max: 14350, step: 0.1, sensitivity: 0.1, event: 'tune' },
|
|
99
|
+
{ id: 'band', label: 'Band position, kHz', kind: 'slider', x: 42, y: 215, width: 290, height: 42,
|
|
100
|
+
value: 'requested', min: 14000, max: 14350, step: 0.1, event: 'tune' },
|
|
101
|
+
...(['connect', 'external', 'delay'] as const).map((event, i) => ({
|
|
102
|
+
id: event, label: ['Toggle connection', 'Simulate external tuning', 'Toggle CAT delay'][i],
|
|
103
|
+
kind: 'button' as const, x: 28 + i * 206, y: 298, width: 172, height: 38, event,
|
|
104
|
+
})),
|
|
105
|
+
],
|
|
106
|
+
} }
|
|
107
|
+
},
|
|
108
|
+
async onEvent(args) {
|
|
109
|
+
const s = state(args.instanceId ?? args.panelKey)
|
|
110
|
+
const event = args.event
|
|
111
|
+
if (event.action === 'connect') { s.connected = 1 - s.connected; s.due = 0; s.requested = s.reported }
|
|
112
|
+
if (event.action === 'delay') s.slow = 1 - s.slow
|
|
113
|
+
if (event.action === 'external') {
|
|
114
|
+
if (!s.connected) throw new Error('Radio disconnected')
|
|
115
|
+
s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
|
|
116
|
+
s.requested = s.reported; s.due = 0
|
|
117
|
+
}
|
|
118
|
+
if (event.action === 'tune') {
|
|
119
|
+
if (!s.connected) throw new Error('Radio disconnected')
|
|
120
|
+
if (typeof event.value !== 'number' || !Number.isFinite(event.value)) throw new Error('Invalid frequency')
|
|
121
|
+
s.requested = Math.max(14000, Math.min(14350, event.value))
|
|
122
|
+
s.due = now() + (s.slow ? 2000 : 0)
|
|
123
|
+
if (!s.slow) { s.reported = s.requested; s.due = 0 }
|
|
124
|
+
}
|
|
125
|
+
return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|