@ham2k/extension-sdk 0.3.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/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-*`,
@@ -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: `${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}`,
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
- if (!exportType.startsWith(`${prefix}${SEPARATOR}`)) {
48
- 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}'`);
49
63
  }
50
- const wanted = exportType.slice(prefix.length + SEPARATOR.length);
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.key}-hunter`;
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,4 @@
1
- // @ham2k/extension-sdk 0.3.0
1
+ // @ham2k/extension-sdk 0.4.0
2
2
  export type CallInfo = {
3
3
  call: string;
4
4
  baseCall?: string;
@@ -388,11 +388,44 @@ export interface AdifImportHook {
388
388
  }[];
389
389
  }, ctx: HookContext): Promise<(AdifImportResult | null)[]>;
390
390
  }
391
+ export interface ExportSettings {
392
+ includePrivateData?: boolean;
393
+ includeLookupData?: boolean;
394
+ customTemplates?: boolean;
395
+ filenameTemplate?: string;
396
+ compactFilenameTemplate?: string;
397
+ titleTemplate?: string;
398
+ adifNotesTemplate?: string;
399
+ adifCommentTemplate?: string;
400
+ adifQslMessageTemplate?: string;
401
+ }
402
+ export interface GlobalExportSettings extends ExportSettings {
403
+ referenceFilenameTemplate?: string;
404
+ referenceCompactFilenameTemplate?: string;
405
+ referenceTitleTemplate?: string;
406
+ otherFilenameTemplate?: string;
407
+ otherCompactFilenameTemplate?: string;
408
+ otherTitleTemplate?: string;
409
+ }
410
+ export interface ExportTypeDefinition {
411
+ exportType: string;
412
+ label: string;
413
+ format: string;
414
+ activationType?: string;
415
+ templateCategory?: "reference" | "other";
416
+ defaults?: ExportSettings;
417
+ templateSample?: Record<string, JSONValue>;
418
+ }
391
419
  export interface ExportRequest {
420
+ exportSettings?: ExportSettings;
421
+ exportData?: Record<string, JSONValue>;
422
+ exportTitle?: string;
423
+ includeLookupData?: boolean;
392
424
  operation: Record<string, JSONValue>;
393
425
  qsos: Record<string, JSONValue>[];
394
426
  segments?: OperationSegmentPayload[];
395
427
  exportType?: string;
428
+ exportKey?: string;
396
429
  compactFilenames?: boolean;
397
430
  includePrivateData?: boolean;
398
431
  mainHandler?: string;
@@ -409,7 +442,10 @@ export interface ExportOptionsRequest {
409
442
  compactFilenames?: boolean;
410
443
  }
411
444
  export interface ExportOption {
445
+ qsoCount?: number;
446
+ templateData?: Record<string, JSONValue>;
412
447
  exportType: string;
448
+ exportKey?: string;
413
449
  format: string;
414
450
  label: string;
415
451
  filename?: string;
@@ -421,6 +457,7 @@ export interface ExportOption {
421
457
  refType?: string;
422
458
  }
423
459
  export interface ExportHook {
460
+ getExportTypes?(args: Record<string, never>, ctx: HookContext): Promise<ExportTypeDefinition[]>;
424
461
  suggestExportOptions?(args: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
425
462
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
426
463
  }
@@ -871,7 +908,7 @@ export interface DataFileDefinition {
871
908
  onLoadRawData?: (data: any) => void;
872
909
  onRemoveRawData?: () => Promise<void>;
873
910
  }
874
- export type FormFieldType = "text" | "multiline" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
911
+ export type FormFieldType = "text" | "multiline" | "textTemplate" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
875
912
  export interface FormFieldOption {
876
913
  label: string;
877
914
  value: any;
@@ -885,6 +922,8 @@ export interface FormField {
885
922
  label: string;
886
923
  value?: any;
887
924
  placeholder?: string;
925
+ templateContext?: "export" | "adif" | "text";
926
+ templateSample?: Record<string, JSONValue>;
888
927
  uppercase?: boolean;
889
928
  options?: FormFieldOption[] | string;
890
929
  validate?: (value: any, state: Record<string, any>) => string | null | undefined | Promise<string | null | undefined>;
@@ -934,6 +973,8 @@ export interface FormMarkdownBlock {
934
973
  title?: string;
935
974
  }
936
975
  export interface FormActionElement {
976
+ style?: "row";
977
+ icon?: string;
937
978
  type: "action";
938
979
  key: string;
939
980
  label: string;
@@ -1235,12 +1276,14 @@ export type ActivityScoresheet = {
1235
1276
  export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
1236
1277
  export interface ActivityExportRules {
1237
1278
  key: string;
1279
+ templateSample?: Record<string, JSONValue>;
1238
1280
  label: string;
1239
1281
  activationType: string;
1240
1282
  icon?: string;
1241
1283
  includeFieldsFrom?: string[];
1242
1284
  }
1243
1285
  export declare function activityExportHook(rules: ActivityExportRules): {
1286
+ getExportTypes(): Promise<ExportTypeDefinition[]>;
1244
1287
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1245
1288
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1246
1289
  };
@@ -1254,14 +1297,19 @@ export interface HuntingExportRules {
1254
1297
  includeFieldsFrom?: string[];
1255
1298
  }
1256
1299
  export declare function huntingExportHook(rules: HuntingExportRules): {
1300
+ getExportTypes(): Promise<ExportTypeDefinition[]>;
1257
1301
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1258
1302
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1259
1303
  };
1260
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }: {
1304
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
1261
1305
  operation: Record<string, JSONValue>;
1262
1306
  qsos: Record<string, JSONValue>[];
1263
1307
  segments?: OperationSegmentPayload[];
1264
1308
  includePrivateData?: boolean;
1309
+ includeLookupData?: boolean;
1310
+ exportSettings?: ExportSettings;
1311
+ exportData?: Record<string, JSONValue>;
1312
+ exportTitle?: string;
1265
1313
  mainHandler: string;
1266
1314
  includeFieldsFrom?: string[];
1267
1315
  }): Promise<string>;
@@ -1284,6 +1332,19 @@ export interface ExportNameParts {
1284
1332
  }
1285
1333
  export declare function exportFilename(parts: ExportNameParts): string;
1286
1334
  export declare function startMillisOf(operation: Record<string, JSONValue>, qsos?: Record<string, JSONValue>[]): number | undefined;
1335
+ export declare const EXPORT_DEFAULTS: ExportSettings;
1336
+ export declare const GLOBAL_EXPORT_DEFAULTS: GlobalExportSettings;
1337
+ export declare function resolveGlobalExportSettings(global?: GlobalExportSettings): GlobalExportSettings;
1338
+ export declare function exportTypeDefinition(activationType: string, format: string, label: string, defaults?: ExportSettings): ExportTypeDefinition;
1339
+ export declare function resolveExportSettings(definition: ExportTypeDefinition, global?: GlobalExportSettings, overrides?: ExportSettings): ExportSettings;
1340
+ export declare function exportQso(qso: Record<string, JSONValue>, settings: ExportSettings): Record<string, JSONValue>;
1341
+ export declare function prepareExportOption(option: ExportOption & {
1342
+ exportSettings: ExportSettings;
1343
+ }, operation: Record<string, JSONValue>, qsos: Record<string, JSONValue>[], compact: boolean): ExportOption & {
1344
+ exportSettings: ExportSettings;
1345
+ exportTitle: string;
1346
+ exportData: Record<string, JSONValue>;
1347
+ };
1287
1348
  export type RefTransform = {
1288
1349
  pattern: string;
1289
1350
  replacement: string;
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./activityScoring.js";
9
9
  export * from "./activityExports.js";
10
10
  export * from "./activityAdifImport.js";
11
11
  export * from "./exportNames.js";
12
+ export * from "./exportSettings.js";
12
13
  export * from "./referenceActivity.js";
13
14
  export * from "./refTransforms.js";
14
15
  export * from "./templates.js";
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 Settings and environment gating". Also shows this field on
74
- // the app's Common Settings quick-access panel.
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 selected. The
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: `adif`
267
- (key `adif`, one option); Cabrillo will register here too.
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
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 Settings and environment gating
375
+ ## Common Preferences and environment gating
376
376
 
377
- The app has one settings screen, **Settings**: a collapsible section per
378
- declared group and panel, led by **Common Settings**, a short list of the
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
- Settings —
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 Settings
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 Settings. Every
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 will eventually be editable in settings, so the one
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` | ✓ | dates only | ✓ | ✓ |
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. Filenames get only the date
52
- half of `op` because a filename is built from parts rather than from an
53
- operation (`exportNames.ts`).
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/exportNames.ts` `NAME_TEMPLATES` | not yet |
195
- | ADIF NOTES / COMMENT / QSLMSG | `core/adif`'s `TEXT_FIELD_TEMPLATES` | not yet |
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
- COMMENT and QSLMSG are empty, so nothing is written for them. app-polo
199
- defaults COMMENT to the QSO's notes and QSLMSG to the operation's
200
- references; both wait here until there is a settings screen to edit them in,
201
- since a default nobody can see is one nobody can turn off, and these fields
202
- travel to a program's servers.
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
- Whatever fills them must respect the private/public split: `notes` is the
205
- operator's own words and belongs only in a field the export withholds when
206
- private data is off. NOTES is such a field. COMMENT and QSLMSG are not.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",