@ham2k/extension-sdk 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -9
- package/README.md +6 -6
- package/dist/activityExports.js +38 -23
- package/dist/activityScoring.js +3 -9
- package/dist/base64.js +38 -0
- package/dist/copyOnWrite.js +71 -0
- package/dist/exportNames.js +16 -15
- package/dist/exportSettings.js +11 -1
- package/dist/index.d.ts +39 -3
- package/dist/index.js +85 -0
- package/dist/scoring.js +57 -34
- package/docs/distribution.md +11 -8
- package/docs/hooks.md +53 -21
- package/docs/templates.md +1 -1
- package/package.json +1 -1
- package/samples/k2hrc-cqww/build.mjs +3 -3
- package/samples/k2hrc-cqww/src/index.ts +6 -15
- package/samples/k2hrc-hamqth/build.mjs +3 -3
- package/samples/k2hrc-hamqth/src/index.ts +4 -8
- package/samples/k2hrc-llota/build.mjs +3 -3
- package/samples/k2hrc-llota/src/index.ts +4 -7
- package/samples/k2hrc-radio/build.mjs +3 -3
- package/samples/k2hrc-radio/src/index.ts +1 -5
- package/samples/k2hrc-svg-scenes/build.mjs +3 -3
- package/samples/k2hrc-svg-scenes/manifest.json +2 -10
- package/samples/k2hrc-svg-scenes/src/index.ts +4 -4
- package/samples/k2hrc-svg-scenes/src/scenes.ts +195 -80
package/AGENTS.md
CHANGED
|
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
|
|
|
34
34
|
## The smallest whole extension
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
|
-
import { defineExtension } from
|
|
38
|
-
import type { PanelHook } from
|
|
37
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
38
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
39
39
|
|
|
40
|
-
import manifest from
|
|
40
|
+
import manifest from "../manifest.json"
|
|
41
41
|
|
|
42
42
|
const HelloPanel: PanelHook = {
|
|
43
43
|
async getPanels() {
|
|
44
|
-
return [{ key:
|
|
44
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
45
45
|
},
|
|
46
46
|
async render() {
|
|
47
|
-
return { kind:
|
|
47
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
defineExtension({
|
|
52
52
|
...manifest,
|
|
53
53
|
onActivation({ registerHook }) {
|
|
54
|
-
registerHook(
|
|
54
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
55
55
|
},
|
|
56
56
|
})
|
|
57
57
|
```
|
|
@@ -100,9 +100,10 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
|
|
|
100
100
|
evaluating one script. Reach the outside world through `host`:
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
|
-
import { host } from
|
|
103
|
+
import { host } from "@ham2k/extension-sdk"
|
|
104
104
|
|
|
105
105
|
await host.fetch(url) // https only, and only domains your manifest lists
|
|
106
|
+
host.webSocket(url) // wss:// to hosts your manifest's webSockets lists
|
|
106
107
|
await host.kvGet('key') // storage, namespaced to your extension
|
|
107
108
|
await host.kvSet('key', value)
|
|
108
109
|
await host.showForm(form) // ask the user something
|
|
@@ -114,7 +115,7 @@ host.log('…') // the app's console
|
|
|
114
115
|
|
|
115
116
|
## Traps worth knowing before you write anything
|
|
116
117
|
|
|
117
|
-
- **A panel returns content, not widgets.** `{ kind:
|
|
118
|
+
- **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
|
|
118
119
|
its siblings. There is no way to reach the app's UI toolkit, by design.
|
|
119
120
|
- **`host.fetch` only reaches domains your manifest's `domains` lists**, over
|
|
120
121
|
https. An undeclared host is refused at the call, not at build.
|
|
@@ -122,7 +123,7 @@ host.log('…') // the app's console
|
|
|
122
123
|
carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
|
|
123
124
|
`sharedDependencies` rewrites your imports into lookups on the app's single
|
|
124
125
|
copy, which is worth roughly thirty times the bundle size. Don't install them.
|
|
125
|
-
`inline: [
|
|
126
|
+
`inline: ["liquidjs"]` in your build ships your own copy, for when you need a
|
|
126
127
|
version the app does not carry. A scaffolded manifest already declares the
|
|
127
128
|
eight this SDK's own modules reach — importing anything from it pulls them
|
|
128
129
|
in — so deleting one from the manifest breaks a build that was working.
|
package/README.md
CHANGED
|
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
|
-
import { defineExtension } from
|
|
13
|
-
import type { PanelHook } from
|
|
12
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
13
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
14
14
|
|
|
15
|
-
import manifest from
|
|
15
|
+
import manifest from "../manifest.json"
|
|
16
16
|
|
|
17
17
|
const HelloPanel: PanelHook = {
|
|
18
18
|
async getPanels() {
|
|
19
|
-
return [{ key:
|
|
19
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
20
20
|
},
|
|
21
21
|
async render() {
|
|
22
|
-
return { kind:
|
|
22
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
23
23
|
},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
defineExtension({
|
|
27
27
|
...manifest,
|
|
28
28
|
onActivation({ registerHook }) {
|
|
29
|
-
registerHook(
|
|
29
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
30
30
|
},
|
|
31
31
|
})
|
|
32
32
|
```
|
package/dist/activityExports.js
CHANGED
|
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
|
|
|
23
23
|
const prefix = `${rules.key}-adif`;
|
|
24
24
|
return {
|
|
25
25
|
async getExportTypes() {
|
|
26
|
-
return [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
...exportTypeDefinition(rules.activationType, "adif", rules.label),
|
|
29
|
+
templateCategory: "reference",
|
|
30
|
+
templateSample: rules.templateSample ?? {
|
|
31
|
+
log: { ref: "REF-1234", refName: "Example Reference" },
|
|
32
|
+
operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
|
|
33
|
+
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
];
|
|
34
36
|
},
|
|
35
37
|
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
36
38
|
const refs = refsOfType(operation, rules.activationType);
|
|
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
|
|
|
113
115
|
if (refsOfType(operation, rules.activationType).length > 0) return [];
|
|
114
116
|
if (huntedQsos(qsos ?? []).length === 0) return [];
|
|
115
117
|
const t = tFor(ctx);
|
|
116
|
-
return [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
exportType,
|
|
121
|
+
qsoCount: huntedQsos(qsos ?? []).length,
|
|
122
|
+
templateData: { activity: rules.label },
|
|
123
|
+
format: "adif",
|
|
124
|
+
label: t("adifForHunter", { program: rules.label }),
|
|
125
|
+
filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
|
|
126
|
+
icon: rules.icon,
|
|
127
|
+
color: rules.color,
|
|
128
|
+
selectedByDefault: true
|
|
129
|
+
// No `refType`: hunted refs live on QSOs, not on operation segments,
|
|
130
|
+
// so this isn't the segment-scoped slice `activityExportHook` uses —
|
|
131
|
+
// it spans the whole log. (See `HuntingExportRules.color` for why the
|
|
132
|
+
// accent has to be threaded explicitly instead.)
|
|
133
|
+
}
|
|
134
|
+
];
|
|
131
135
|
},
|
|
132
136
|
async generateExport(args, ctx) {
|
|
133
137
|
const requested = String(args.exportType ?? "");
|
|
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
|
|
|
163
167
|
}
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
|
-
async function adifForExport({
|
|
170
|
+
async function adifForExport({
|
|
171
|
+
operation,
|
|
172
|
+
qsos,
|
|
173
|
+
segments,
|
|
174
|
+
includePrivateData,
|
|
175
|
+
includeLookupData,
|
|
176
|
+
exportSettings,
|
|
177
|
+
exportData,
|
|
178
|
+
exportTitle,
|
|
179
|
+
mainHandler,
|
|
180
|
+
includeFieldsFrom
|
|
181
|
+
}) {
|
|
167
182
|
const { hooks } = await import("@ham2k/extension-sdk");
|
|
168
183
|
const entries = await hooks.invokeOne("export", "adif", "generateExport", {
|
|
169
184
|
operation,
|
package/dist/activityScoring.js
CHANGED
|
@@ -62,9 +62,7 @@ function activityScorer(rules) {
|
|
|
62
62
|
if (candidates.length === 0) notice ?? (notice = "mode");
|
|
63
63
|
}
|
|
64
64
|
if (unique.has("ref")) {
|
|
65
|
-
candidates = candidates.filter(
|
|
66
|
-
(c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
|
|
67
|
-
);
|
|
65
|
+
candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
|
|
68
66
|
if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
|
|
69
67
|
}
|
|
70
68
|
if (notice) return notice;
|
|
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
|
|
|
189
187
|
const detail = refKeys.map(
|
|
190
188
|
(key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
|
|
191
189
|
);
|
|
192
|
-
const parts = [
|
|
193
|
-
qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
|
|
194
|
-
];
|
|
190
|
+
const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
|
|
195
191
|
if (huntedQsos > 0) {
|
|
196
192
|
const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
|
|
197
|
-
parts.push(
|
|
198
|
-
huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
|
|
199
|
-
);
|
|
193
|
+
parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
|
|
200
194
|
}
|
|
201
195
|
return {
|
|
202
196
|
key: "activation",
|
package/dist/base64.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
2
|
+
const VALUES = new Map([...ALPHABET].map((c, i) => [c, i]));
|
|
3
|
+
function bytesToBase64(bytes) {
|
|
4
|
+
let out = "";
|
|
5
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
6
|
+
const a = bytes[i];
|
|
7
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
8
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
9
|
+
const n = a << 16 | b << 8 | c;
|
|
10
|
+
out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
|
|
11
|
+
out += i + 1 < bytes.length ? ALPHABET[n >> 6 & 63] : "=";
|
|
12
|
+
out += i + 2 < bytes.length ? ALPHABET[n & 63] : "=";
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function base64ToBytes(text) {
|
|
17
|
+
if (text.length % 4 !== 0) throw new Error("invalid base64");
|
|
18
|
+
const padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
|
|
19
|
+
const bytes = new Uint8Array(text.length / 4 * 3 - padding);
|
|
20
|
+
let o = 0;
|
|
21
|
+
for (let i = 0; i < text.length; i += 4) {
|
|
22
|
+
let n = 0;
|
|
23
|
+
for (let j = 0; j < 4; j++) {
|
|
24
|
+
const ch = text[i + j];
|
|
25
|
+
const value = ch === "=" && i + 4 === text.length && j >= 4 - padding ? 0 : VALUES.get(ch);
|
|
26
|
+
if (value === void 0) throw new Error("invalid base64");
|
|
27
|
+
n = n << 6 | value;
|
|
28
|
+
}
|
|
29
|
+
if (o < bytes.length) bytes[o++] = n >> 16 & 255;
|
|
30
|
+
if (o < bytes.length) bytes[o++] = n >> 8 & 255;
|
|
31
|
+
if (o < bytes.length) bytes[o++] = n & 255;
|
|
32
|
+
}
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
base64ToBytes,
|
|
37
|
+
bytesToBase64
|
|
38
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function copyOnWrite(base) {
|
|
2
|
+
return isContainer(base) ? wrap(base) : base;
|
|
3
|
+
}
|
|
4
|
+
function isContainer(value) {
|
|
5
|
+
return typeof value === "object" && value !== null;
|
|
6
|
+
}
|
|
7
|
+
function wrap(target) {
|
|
8
|
+
const own = /* @__PURE__ */ new Map();
|
|
9
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
10
|
+
return new Proxy(target, {
|
|
11
|
+
get(t, key) {
|
|
12
|
+
if (own.has(key)) return own.get(key);
|
|
13
|
+
if (deleted.has(key)) return void 0;
|
|
14
|
+
const value = Reflect.get(t, key);
|
|
15
|
+
if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
|
|
16
|
+
const view = wrap(value);
|
|
17
|
+
own.set(key, view);
|
|
18
|
+
return view;
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
},
|
|
22
|
+
set(_t, key, value) {
|
|
23
|
+
own.set(key, value);
|
|
24
|
+
deleted.delete(key);
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
has(t, key) {
|
|
28
|
+
if (deleted.has(key)) return false;
|
|
29
|
+
return own.has(key) || key in t;
|
|
30
|
+
},
|
|
31
|
+
deleteProperty(t, key) {
|
|
32
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
|
|
33
|
+
if (descriptor && !descriptor.configurable) return false;
|
|
34
|
+
own.delete(key);
|
|
35
|
+
if (descriptor) deleted.add(key);
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
ownKeys(t) {
|
|
39
|
+
const keys = Reflect.ownKeys(t).filter((key) => !deleted.has(key));
|
|
40
|
+
for (const key of own.keys()) {
|
|
41
|
+
if (!keys.includes(key)) keys.push(key);
|
|
42
|
+
}
|
|
43
|
+
return keys;
|
|
44
|
+
},
|
|
45
|
+
getOwnPropertyDescriptor(t, key) {
|
|
46
|
+
if (deleted.has(key)) return void 0;
|
|
47
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
|
|
48
|
+
if (!own.has(key)) return descriptor;
|
|
49
|
+
return descriptor ? { ...descriptor, value: own.get(key) } : { value: own.get(key), writable: true, enumerable: true, configurable: true };
|
|
50
|
+
},
|
|
51
|
+
// The other ways to write. Without these traps `Object.defineProperty`
|
|
52
|
+
// and `Object.freeze` fall through to the base — the held checkpoint —
|
|
53
|
+
// and one such call would enter a candidate into every later verdict, or
|
|
54
|
+
// lock the sheet for the rest of the session.
|
|
55
|
+
defineProperty(_t, key, descriptor) {
|
|
56
|
+
if ("get" in descriptor || "set" in descriptor) return false;
|
|
57
|
+
own.set(key, descriptor.value);
|
|
58
|
+
deleted.delete(key);
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
preventExtensions() {
|
|
62
|
+
return false;
|
|
63
|
+
},
|
|
64
|
+
setPrototypeOf() {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
copyOnWrite
|
|
71
|
+
};
|
package/dist/exportNames.js
CHANGED
|
@@ -26,22 +26,23 @@ function exportFilename(parts) {
|
|
|
26
26
|
const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
|
|
27
27
|
const extension = parts.extension.replace(/^\./, "");
|
|
28
28
|
const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
|
|
29
|
-
const modifier = [
|
|
30
|
-
(parts.modifier ?? "").trim(),
|
|
31
|
-
isTestOperation(parts.stationCall) ? "Testing" : ""
|
|
32
|
-
].filter((part) => part !== "").join(" ");
|
|
29
|
+
const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
|
|
33
30
|
const stem = oneLine(
|
|
34
|
-
renderTemplate(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
renderTemplate(
|
|
32
|
+
NAME_TEMPLATES[shape],
|
|
33
|
+
{
|
|
34
|
+
op: dateValues(millis),
|
|
35
|
+
log: logValues({
|
|
36
|
+
station: String(parts.stationCall ?? "").trim(),
|
|
37
|
+
ref: parts.ref,
|
|
38
|
+
activity: parts.activity,
|
|
39
|
+
modifier,
|
|
40
|
+
extension,
|
|
41
|
+
compact: parts.compact
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
shape
|
|
45
|
+
)
|
|
45
46
|
);
|
|
46
47
|
return `${sanitize(stem) || "export"}.${extension}`;
|
|
47
48
|
}
|
package/dist/exportSettings.js
CHANGED
|
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
|
|
|
118
118
|
const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
|
|
119
119
|
const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
|
|
120
120
|
const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
|
|
121
|
-
const titleContext = settings.includePrivateData === false ? {
|
|
121
|
+
const titleContext = settings.includePrivateData === false ? {
|
|
122
|
+
...context,
|
|
123
|
+
op: {
|
|
124
|
+
...context.op,
|
|
125
|
+
title: "",
|
|
126
|
+
userTitle: "",
|
|
127
|
+
userNotes: "",
|
|
128
|
+
grid: String(context.op.grid ?? "").slice(0, 6)
|
|
129
|
+
},
|
|
130
|
+
log: { ...context.log, modifier: "" }
|
|
131
|
+
} : context;
|
|
122
132
|
const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
|
|
123
133
|
return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
|
|
124
134
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.
|
|
1
|
+
// @ham2k/extension-sdk 0.6.0
|
|
2
2
|
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface SvgScene {
|
|
4
4
|
version: 1;
|
|
@@ -394,6 +394,7 @@ export interface LoggingControlDescriptor {
|
|
|
394
394
|
key: string;
|
|
395
395
|
label: string;
|
|
396
396
|
shortLabel?: string;
|
|
397
|
+
skipFocus?: boolean;
|
|
397
398
|
icon?: string;
|
|
398
399
|
color?: string;
|
|
399
400
|
order?: number;
|
|
@@ -634,12 +635,15 @@ export interface ScoreQsosRequest {
|
|
|
634
635
|
ref?: Record<string, JSONValue>;
|
|
635
636
|
resumeFrom?: JSONValue;
|
|
636
637
|
resumeDay?: number;
|
|
638
|
+
resumeKey?: string;
|
|
639
|
+
checkpointKey?: string;
|
|
637
640
|
}
|
|
638
641
|
export interface ScoreQsosResult {
|
|
639
642
|
qsoScores: Record<string, QsoScoreVerdict>;
|
|
640
643
|
daySections: DayScoreSummary[];
|
|
641
644
|
operationSummary: Record<string, ScoreTally>;
|
|
642
645
|
scoresheet?: JSONValue;
|
|
646
|
+
checkpointHeld?: true;
|
|
643
647
|
scoresheetDay?: number;
|
|
644
648
|
}
|
|
645
649
|
export interface ScoreQsoRequest {
|
|
@@ -651,11 +655,13 @@ export interface ScoreQsoRequest {
|
|
|
651
655
|
resumeFrom?: JSONValue;
|
|
652
656
|
resumeDay?: number;
|
|
653
657
|
qsos?: Record<string, JSONValue>[];
|
|
658
|
+
resumeKey?: string;
|
|
654
659
|
}
|
|
655
660
|
export interface QsoScoreNotices {
|
|
656
661
|
notices: string[];
|
|
657
662
|
alerts: string[];
|
|
658
663
|
dupe?: boolean;
|
|
664
|
+
checkpointHeld?: true;
|
|
659
665
|
}
|
|
660
666
|
export interface ScoreCandidatesRequest {
|
|
661
667
|
operation: Record<string, JSONValue>;
|
|
@@ -668,8 +674,11 @@ export interface ScoreCandidatesRequest {
|
|
|
668
674
|
resumeFrom?: JSONValue;
|
|
669
675
|
resumeDay?: number;
|
|
670
676
|
qsos?: Record<string, JSONValue>[];
|
|
677
|
+
resumeKey?: string;
|
|
671
678
|
}
|
|
672
|
-
export type ScoreCandidatesResult = Record<string, QsoScoreNotices
|
|
679
|
+
export type ScoreCandidatesResult = Record<string, QsoScoreNotices> & {
|
|
680
|
+
$checkpointHeld?: true;
|
|
681
|
+
};
|
|
673
682
|
export interface ScoringHook {
|
|
674
683
|
scoreQsos(args: ScoreQsosRequest, ctx: HookContext): Promise<ScoreQsosResult>;
|
|
675
684
|
scoreQso?(args: ScoreQsoRequest, ctx: HookContext): Promise<QsoScoreNotices>;
|
|
@@ -953,6 +962,7 @@ export interface ExtensionManifest {
|
|
|
953
962
|
enabledByDefault?: boolean;
|
|
954
963
|
hooks?: string[];
|
|
955
964
|
domains?: string[];
|
|
965
|
+
webSockets?: string[];
|
|
956
966
|
allowUserAgentOverride?: boolean;
|
|
957
967
|
requiresLocation?: boolean;
|
|
958
968
|
requiresRadioRead?: boolean;
|
|
@@ -997,6 +1007,29 @@ export interface FetchResponse {
|
|
|
997
1007
|
status: number;
|
|
998
1008
|
body: string;
|
|
999
1009
|
}
|
|
1010
|
+
export interface WebSocketOptions {
|
|
1011
|
+
protocols?: string[];
|
|
1012
|
+
}
|
|
1013
|
+
export interface WebSocketCloseEvent {
|
|
1014
|
+
code: number;
|
|
1015
|
+
reason: string;
|
|
1016
|
+
wasClean: boolean;
|
|
1017
|
+
}
|
|
1018
|
+
export interface ExtensionWebSocket {
|
|
1019
|
+
readonly url: string;
|
|
1020
|
+
readonly readyState: 0 | 1 | 2 | 3;
|
|
1021
|
+
readonly protocol: string;
|
|
1022
|
+
onopen: (() => void) | null;
|
|
1023
|
+
onmessage: ((event: {
|
|
1024
|
+
data: string | ArrayBuffer;
|
|
1025
|
+
}) => void) | null;
|
|
1026
|
+
onerror: ((event: {
|
|
1027
|
+
message: string;
|
|
1028
|
+
}) => void) | null;
|
|
1029
|
+
onclose: ((event: WebSocketCloseEvent) => void) | null;
|
|
1030
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
1031
|
+
close(code?: number, reason?: string): void;
|
|
1032
|
+
}
|
|
1000
1033
|
export interface DeviceLocation {
|
|
1001
1034
|
latitude: number;
|
|
1002
1035
|
longitude: number;
|
|
@@ -1461,6 +1494,8 @@ export declare function isUnexpectedSideband(mode: string | undefined | null, {
|
|
|
1461
1494
|
freqKHz?: number;
|
|
1462
1495
|
band?: string;
|
|
1463
1496
|
}): boolean;
|
|
1497
|
+
export declare const RESUME_MISSING = "resume-missing";
|
|
1498
|
+
export declare const CHECKPOINT_HELD_KEY = "$checkpointHeld";
|
|
1464
1499
|
export interface ContestScorerOptions {
|
|
1465
1500
|
scope?: ScoringScope;
|
|
1466
1501
|
}
|
|
@@ -1527,7 +1562,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
|
|
|
1527
1562
|
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
1528
1563
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
1529
1564
|
};
|
|
1530
|
-
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
|
|
1565
|
+
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
|
|
1531
1566
|
operation: Record<string, JSONValue>;
|
|
1532
1567
|
qsos: Record<string, JSONValue>[];
|
|
1533
1568
|
segments?: OperationSegmentPayload[];
|
|
@@ -1693,6 +1728,7 @@ export declare const host: {
|
|
|
1693
1728
|
}): Promise<PanelRadioTuneResult>;
|
|
1694
1729
|
tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
|
|
1695
1730
|
fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
1731
|
+
webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
|
|
1696
1732
|
kvGet(key: string): Promise<JSONValue | null>;
|
|
1697
1733
|
getLocation(): Promise<DeviceLocation | null>;
|
|
1698
1734
|
getVersionInfo(): Promise<VersionInfo>;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
1
2
|
export * from "./types.js";
|
|
2
3
|
export * from "./svgScene.js";
|
|
3
4
|
export * from "./i18n.js";
|
|
@@ -38,6 +39,85 @@ function defineExtension(def) {
|
|
|
38
39
|
}
|
|
39
40
|
});
|
|
40
41
|
}
|
|
42
|
+
const MAX_FRAME_SIZE = 1024 * 1024;
|
|
43
|
+
function utf8Length(text) {
|
|
44
|
+
let length = 0;
|
|
45
|
+
for (const ch of text) {
|
|
46
|
+
const cp = ch.codePointAt(0);
|
|
47
|
+
length += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
|
|
48
|
+
}
|
|
49
|
+
return length;
|
|
50
|
+
}
|
|
51
|
+
function openWebSocket(call, url, options) {
|
|
52
|
+
let readyState = 0;
|
|
53
|
+
let protocol = "";
|
|
54
|
+
const failed = (what) => (e) => kernel().log(`webSocket ${what} on ${url} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
55
|
+
const socket = {
|
|
56
|
+
url,
|
|
57
|
+
get readyState() {
|
|
58
|
+
return readyState;
|
|
59
|
+
},
|
|
60
|
+
get protocol() {
|
|
61
|
+
return protocol;
|
|
62
|
+
},
|
|
63
|
+
onopen: null,
|
|
64
|
+
onmessage: null,
|
|
65
|
+
onerror: null,
|
|
66
|
+
onclose: null,
|
|
67
|
+
send(data) {
|
|
68
|
+
if (readyState !== 1) throw new Error("WebSocket is not open");
|
|
69
|
+
let frame;
|
|
70
|
+
if (typeof data === "string") {
|
|
71
|
+
if (data.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
|
|
72
|
+
frame = { data };
|
|
73
|
+
} else {
|
|
74
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
75
|
+
if (bytes.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
|
|
76
|
+
frame = { binary: bytesToBase64(bytes) };
|
|
77
|
+
}
|
|
78
|
+
call("webSocketSend", { socketId, ...frame }).catch(failed("send"));
|
|
79
|
+
},
|
|
80
|
+
close(code, reason) {
|
|
81
|
+
if (code !== void 0 && (!Number.isInteger(code) || code !== 1e3 && (code < 3e3 || code > 4999))) {
|
|
82
|
+
throw new Error("close code must be 1000 or 3000-4999");
|
|
83
|
+
}
|
|
84
|
+
if (reason !== void 0 && (typeof reason !== "string" || utf8Length(reason) > 123)) {
|
|
85
|
+
throw new Error("close reason must be a string of at most 123 UTF-8 bytes");
|
|
86
|
+
}
|
|
87
|
+
if (readyState >= 2) return;
|
|
88
|
+
const before = readyState;
|
|
89
|
+
readyState = 2;
|
|
90
|
+
call("webSocketClose", { socketId, ...code !== void 0 ? { code } : {}, ...reason !== void 0 ? { reason } : {} }).catch((e) => {
|
|
91
|
+
if (readyState === 2) readyState = before;
|
|
92
|
+
failed("close")(e);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (typeof kernel().registerSocket !== "function") throw new Error("host.webSocket needs a newer version of HaLo");
|
|
97
|
+
const socketId = kernel().registerSocket((event) => {
|
|
98
|
+
switch (event.type) {
|
|
99
|
+
case "open":
|
|
100
|
+
if (readyState !== 0) return;
|
|
101
|
+
readyState = 1;
|
|
102
|
+
protocol = String(event.protocol ?? "");
|
|
103
|
+
return socket.onopen?.();
|
|
104
|
+
case "message": {
|
|
105
|
+
const data = typeof event.binary === "string" ? base64ToBytes(event.binary).buffer : String(event.data);
|
|
106
|
+
return socket.onmessage?.({ data });
|
|
107
|
+
}
|
|
108
|
+
case "error":
|
|
109
|
+
return socket.onerror?.({ message: String(event.message ?? "") });
|
|
110
|
+
case "close":
|
|
111
|
+
readyState = 3;
|
|
112
|
+
return socket.onclose?.({ code: Number(event.code), reason: String(event.reason ?? ""), wasClean: event.wasClean === true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
call("webSocketOpen", { socketId, url, ...options?.protocols ? { protocols: options.protocols } : {} }).catch((e) => {
|
|
116
|
+
kernel().socketEvent(socketId, JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
117
|
+
kernel().socketEvent(socketId, JSON.stringify({ type: "close", code: 1006, reason: "", wasClean: false }));
|
|
118
|
+
});
|
|
119
|
+
return socket;
|
|
120
|
+
}
|
|
41
121
|
const hooks = {
|
|
42
122
|
/// EVERY hook in the category — including the CALLER's own. A hook that
|
|
43
123
|
/// delegates within its own category therefore re-enters itself; use
|
|
@@ -76,6 +156,11 @@ const host = {
|
|
|
76
156
|
async fetch(url, options) {
|
|
77
157
|
return await hostCallFn()("fetch", { url, ...options });
|
|
78
158
|
},
|
|
159
|
+
/// A WebSocket to a host the manifest's `webSockets` lists — see
|
|
160
|
+
/// `ExtensionWebSocket`. Returns at once, connecting, as a browser's does.
|
|
161
|
+
webSocket(url, options) {
|
|
162
|
+
return openWebSocket(hostCallFn(), url, options);
|
|
163
|
+
},
|
|
79
164
|
async kvGet(key) {
|
|
80
165
|
return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
|
|
81
166
|
},
|