@ham2k/extension-sdk 0.2.0 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sebastian Delmont <sd@ham2k.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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-*`,
@@ -67,7 +87,3 @@ in, so your editor works with only this package installed.
67
87
 
68
88
  Install one only to `inline` it — to ship your own copy because you need a
69
89
  version the host does not carry.
70
-
71
- ## License
72
-
73
- MPL-2.0
@@ -1,4 +1,6 @@
1
+ import { exportTypeDefinition } from "./exportSettings.js";
1
2
  import { exportFilename, startMillisOf } from "./exportNames.js";
3
+ import { segmentsWith } from "./segments.js";
2
4
  import { createCachedTranslator } from "./i18n.js";
3
5
  import en from "./i18n/activityExports.en.json";
4
6
  import es from "./i18n/activityExports.es.json";
@@ -20,11 +22,23 @@ function filenameFor(operation, qsos, ref, compact) {
20
22
  function activityExportHook(rules) {
21
23
  const prefix = `${rules.key}-adif`;
22
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
+ },
23
35
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
24
36
  const refs = refsOfType(operation, rules.activationType);
25
37
  const t = tFor(ctx);
26
38
  return refs.map((ref) => ({
27
- exportType: `${prefix}${SEPARATOR}${ref.ref}`,
39
+ exportType: `${rules.activationType}-adif`,
40
+ templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
41
+ exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
28
42
  format: "adif",
29
43
  // The FORMAT leads: a park activated on a contest weekend offers
30
44
  // several files for the same reference, and "POTA US-1234" alone said
@@ -43,21 +57,29 @@ function activityExportHook(rules) {
43
57
  },
44
58
  async generateExport(args, ctx) {
45
59
  const exportType = String(args.exportType ?? "");
46
- if (!exportType.startsWith(`${prefix}${SEPARATOR}`)) {
47
- throw new Error(`${rules.key}: unknown export type '${exportType}'`);
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}'`);
48
63
  }
49
- const wanted = exportType.slice(prefix.length + SEPARATOR.length);
64
+ const wanted = exportKey.slice(prefix.length + SEPARATOR.length);
50
65
  if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
51
66
  throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
52
67
  }
53
- const refs = (args.operation.refs ?? []).filter(
54
- (r) => r?.type !== rules.activationType || r?.ref === wanted
55
- );
56
- const operation = { ...args.operation, refs };
68
+ const claimOnly = (operation) => ({
69
+ ...operation,
70
+ refs: (operation.refs ?? []).filter(
71
+ (r) => r?.type !== rules.activationType || r?.ref === wanted
72
+ )
73
+ });
57
74
  const content = await adifForExport({
58
- operation,
75
+ operation: claimOnly(args.operation),
59
76
  qsos: args.qsos,
77
+ segments: segmentsWith(args.segments, claimOnly),
60
78
  includePrivateData: args.includePrivateData,
79
+ includeLookupData: args.includeLookupData,
80
+ exportSettings: args.exportSettings,
81
+ exportData: args.exportData,
82
+ exportTitle: args.exportTitle,
61
83
  mainHandler: rules.key,
62
84
  includeFieldsFrom: rules.includeFieldsFrom
63
85
  });
@@ -79,17 +101,22 @@ function filenameForHunt(operation, qsos, activity, compact) {
79
101
  });
80
102
  }
81
103
  function huntingExportHook(rules) {
82
- const exportType = `${rules.key}-hunter`;
104
+ const exportType = `${rules.huntingType}-hunter`;
83
105
  function huntedQsos(qsos) {
84
106
  return qsos.filter((qso) => refsOfType(qso, rules.huntingType).length > 0);
85
107
  }
86
108
  return {
109
+ async getExportTypes() {
110
+ return [{ exportType, format: "adif", label: `${rules.label} Hunter Log` }];
111
+ },
87
112
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
88
113
  if (refsOfType(operation, rules.activationType).length > 0) return [];
89
114
  if (huntedQsos(qsos ?? []).length === 0) return [];
90
115
  const t = tFor(ctx);
91
116
  return [{
92
117
  exportType,
118
+ qsoCount: huntedQsos(qsos ?? []).length,
119
+ templateData: { activity: rules.label },
93
120
  format: "adif",
94
121
  label: t("adifForHunter", { program: rules.label }),
95
122
  filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
@@ -113,7 +140,12 @@ function huntingExportHook(rules) {
113
140
  const content = await adifForExport({
114
141
  operation: args.operation,
115
142
  qsos: huntedQsos(args.qsos ?? []),
143
+ segments: args.segments,
116
144
  includePrivateData: args.includePrivateData,
145
+ includeLookupData: args.includeLookupData,
146
+ exportSettings: args.exportSettings,
147
+ exportData: args.exportData,
148
+ exportTitle: args.exportTitle,
117
149
  // The hunter file is submitted to ONE program too — see
118
150
  // `activityExportHook.generateExport`. `rules.key` is the ACTIVITY's
119
151
  // key ('pota'), which is what its `adifFields` hook registers under;
@@ -131,15 +163,20 @@ function huntingExportHook(rules) {
131
163
  }
132
164
  };
133
165
  }
134
- async function adifForExport({ operation, qsos, includePrivateData, mainHandler, includeFieldsFrom }) {
166
+ async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
135
167
  const { hooks } = await import("@ham2k/extension-sdk");
136
168
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
137
169
  operation,
138
170
  qsos,
171
+ segments,
139
172
  exportType: "adif",
140
173
  // Forwarded, or the delegate defaults to withholding and the user's
141
174
  // "include private info" setting silently does nothing here.
142
175
  includePrivateData,
176
+ includeLookupData,
177
+ exportSettings,
178
+ exportData,
179
+ exportTitle,
143
180
  mainHandler,
144
181
  includeFieldsFrom
145
182
  });
@@ -127,6 +127,8 @@ function activityScorer(rules) {
127
127
  score.alerts = ["duplicate"];
128
128
  } else if (verdict) {
129
129
  score.notices = [NOTICE_KEYS[verdict]];
130
+ } else if (priors.length > 0 && score.value > 0 && freshRefRescuesActivation) {
131
+ score.dupe = false;
130
132
  }
131
133
  const packed = packContact(day, band, mode, huntedRefs);
132
134
  if (priors.length > 0) priors.push(packed);
package/dist/dxcc.js CHANGED
@@ -2,8 +2,8 @@ import { parseCallsign } from "@ham2k/lib-callsigns";
2
2
  import { annotateFromCountryFile, useBuiltinCountryFile } from "@ham2k/lib-country-files";
3
3
  import { DXCC_BY_CODE } from "@ham2k/lib-dxcc-data";
4
4
  useBuiltinCountryFile();
5
- function annotateCallAgainstCountryFile(call) {
6
- return annotateFromCountryFile(parseCallsign(call));
5
+ function annotateCallAgainstCountryFile(call, options) {
6
+ return annotateFromCountryFile(parseCallsign(call), options);
7
7
  }
8
8
  function entityPrefixForCall(call) {
9
9
  if (!call) return void 0;
@@ -17,8 +17,21 @@ function regionAndCountryForDxccCode(dxccCode) {
17
17
  const entity = DXCC_BY_CODE[dxccCode];
18
18
  return { ituRegion: entity?.ituRegion, countryCode: entity?.countryCode };
19
19
  }
20
+ function countryPrefixForCall(call) {
21
+ if (!call) return void 0;
22
+ const annotated = annotateCallAgainstCountryFile(call);
23
+ const { countryCode } = regionAndCountryForDxccCode(annotated.dxccCode);
24
+ return countryCode ? countryCode.toUpperCase() : annotated.entityPrefix;
25
+ }
26
+ function entityForCall(call) {
27
+ if (!call) return {};
28
+ const { dxccCode, continent, entityName } = annotateCallAgainstCountryFile(call);
29
+ return { dxccCode, continent, entityName };
30
+ }
20
31
  export {
21
32
  annotateCallAgainstCountryFile,
33
+ countryPrefixForCall,
34
+ entityForCall,
22
35
  entityPrefixForCall,
23
36
  flagForDxccCode,
24
37
  regionAndCountryForDxccCode
@@ -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
+ };