@ham2k/extension-sdk 1.12.2 → 1.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,44 @@
1
1
  import { exportTypeDefinition } from "./exportSettings.js";
2
2
  import { exportFilename, startMillisOf } from "./exportNames.js";
3
- import { segmentsWith } from "./segments.js";
3
+ import { operationForQso, segmentsWith } from "./segments.js";
4
+ import { utcDate } from "./templateContext.js";
4
5
  import { createCachedTranslator } from "./i18n.js";
5
6
  import en from "./i18n/activityExports.en.json";
6
7
  import es from "./i18n/activityExports.es.json";
7
8
  const tFor = createCachedTranslator({ en: { translation: en }, es: { translation: es } });
8
9
  const SEPARATOR = ":";
10
+ const DAY_MARK = "@";
11
+ const DAY_LENGTH = "YYYY-MM-DD".length;
12
+ function byUtcDay(qsos) {
13
+ const timed = /* @__PURE__ */ new Map();
14
+ const timeless = [];
15
+ for (const qso of qsos) {
16
+ const millis = Number(qso.startAtMillis ?? 0);
17
+ if (!(millis > 0)) {
18
+ timeless.push(qso);
19
+ continue;
20
+ }
21
+ const day = utcDate(millis);
22
+ const list = timed.get(day);
23
+ if (list) list.push(qso);
24
+ else timed.set(day, [qso]);
25
+ }
26
+ const days = [...timed.keys()].filter((day) => timed.get(day).some((qso) => !qso.deleted)).sort();
27
+ return new Map(days.map((day, i) => [day, i === 0 ? [...timeless, ...timed.get(day)] : timed.get(day)]));
28
+ }
29
+ function activeQsos(operation, qsos, segments, activationType, ref) {
30
+ if (!segments || segments.length === 0) return qsos;
31
+ const refsAt = (qso) => refsOfType(operationForQso(operation, qso, segments), activationType);
32
+ const segmentRefs = segments.map((segment) => refsOfType(segment.operation, activationType).map((r) => r.ref));
33
+ const known = new Set(segmentRefs.flat());
34
+ if (known.has(ref)) {
35
+ const own = qsos.filter((qso) => refsAt(qso).some((r) => r.ref === ref));
36
+ const movedHere = known.size > 1 && segmentRefs.slice(1).some((refs) => refs.includes(ref));
37
+ if (own.length > 0 || movedHere) return own;
38
+ }
39
+ const active = qsos.filter((qso) => refsAt(qso).length > 0);
40
+ return active.length > 0 ? active : qsos;
41
+ }
9
42
  function refsOfType(container, type) {
10
43
  const refs = container.refs ?? [];
11
44
  return refs.filter((r) => r && r.type === type && typeof r.ref === "string" && r.ref.length > 0).map((r) => ({ ref: r.ref, name: typeof r.name === "string" ? r.name : void 0 }));
@@ -34,10 +67,10 @@ function activityExportHook(rules) {
34
67
  }
35
68
  ];
36
69
  },
37
- async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
70
+ async suggestExportOptions({ operation, qsos, segments, compactFilenames }, ctx) {
38
71
  const refs = refsOfType(operation, rules.activationType);
39
72
  const t = tFor(ctx);
40
- return refs.map((ref) => ({
73
+ const whole = (ref) => ({
41
74
  exportType: `${rules.activationType}-adif`,
42
75
  templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
43
76
  exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
@@ -57,15 +90,44 @@ function activityExportHook(rules) {
57
90
  // being activated.
58
91
  refType: rules.activationType,
59
92
  ref: ref.ref
60
- }));
93
+ });
94
+ const daysOf = (ref) => byUtcDay(activeQsos(operation, qsos ?? [], segments, rules.activationType, ref));
95
+ const split = rules.splitByUtcDay ? refs.map((ref) => ({ ref, days: daysOf(ref.ref) })) : [];
96
+ if (split.some(({ days }) => days.size > 1)) {
97
+ return split.flatMap(
98
+ ({ ref, days }) => days.size === 0 ? [whole(ref)] : [...days].map(([day, dayQsos]) => ({
99
+ exportType: `${rules.activationType}-adif`,
100
+ // The day rides as the filename template's modifier: a template
101
+ // is dated by the whole log's first contact, and two files that
102
+ // differed only in their contents would overwrite each other.
103
+ templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label, modifier: day },
104
+ exportKey: `${prefix}${DAY_MARK}${day}${SEPARATOR}${ref.ref}`,
105
+ format: "adif",
106
+ label: t("adifForRefOnDay", { program: rules.label, ref: ref.ref, day }),
107
+ // Dated by the day's own first contact, which the file is.
108
+ filename: filenameFor(operation, dayQsos, ref.ref, compactFilenames),
109
+ // The day's own count: the operator checking that a day reached
110
+ // its threshold needs that day's number, not the whole log's.
111
+ qsoCount: dayQsos.filter((qso) => !qso.deleted).length,
112
+ icon: rules.icon,
113
+ selectedByDefault: true,
114
+ refType: rules.activationType,
115
+ ref: ref.ref
116
+ }))
117
+ );
118
+ }
119
+ return refs.map(whole);
61
120
  },
62
121
  async generateExport(args, ctx) {
63
122
  const exportType = String(args.exportType ?? "");
64
123
  const exportKey = String(args.exportKey ?? "");
65
- if (exportType !== `${rules.activationType}-adif` || !exportKey.startsWith(`${prefix}${SEPARATOR}`)) {
124
+ const dayed = exportKey.startsWith(`${prefix}${DAY_MARK}`) && exportKey.charAt(prefix.length + DAY_MARK.length + DAY_LENGTH) === SEPARATOR;
125
+ const day = dayed ? exportKey.slice(prefix.length + DAY_MARK.length, prefix.length + DAY_MARK.length + DAY_LENGTH) : void 0;
126
+ const head = dayed ? prefix.length + DAY_MARK.length + DAY_LENGTH : prefix.length;
127
+ if (exportType !== `${rules.activationType}-adif` || !(dayed || exportKey.startsWith(`${prefix}${SEPARATOR}`))) {
66
128
  throw new Error(`${rules.key}: unknown export type/key '${exportType}' / '${exportKey}'`);
67
129
  }
68
- const wanted = exportKey.slice(prefix.length + SEPARATOR.length);
130
+ const wanted = exportKey.slice(head + SEPARATOR.length);
69
131
  if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
70
132
  throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
71
133
  }
@@ -75,9 +137,13 @@ function activityExportHook(rules) {
75
137
  (r) => r?.type !== rules.activationType || r?.ref === wanted
76
138
  )
77
139
  });
140
+ const qsos = day === void 0 ? args.qsos : byUtcDay(args.qsos ?? []).get(day) ?? [];
141
+ if (day !== void 0 && !qsos.some((qso) => !qso.deleted)) {
142
+ throw new Error(`${rules.key}: no contacts on ${day} for ${wanted}`);
143
+ }
78
144
  const content = await adifForExport({
79
145
  operation: claimOnly(args.operation),
80
- qsos: args.qsos,
146
+ qsos,
81
147
  segments: segmentsWith(args.segments, claimOnly),
82
148
  includePrivateData: args.includePrivateData,
83
149
  includeLookupData: args.includeLookupData,
@@ -89,7 +155,7 @@ function activityExportHook(rules) {
89
155
  operatorFallback: rules.operatorFallback
90
156
  });
91
157
  return {
92
- filename: filenameFor(args.operation, args.qsos, wanted, args.compactFilenames),
158
+ filename: filenameFor(args.operation, qsos, wanted, args.compactFilenames),
93
159
  mimeType: "text/plain",
94
160
  content
95
161
  };
@@ -34,6 +34,7 @@ function activityScorer(rules) {
34
34
  return typeof rule === "function" ? rule(activatedRefs) : rule;
35
35
  }
36
36
  const freshRefRescuesActivation = rules.freshRefRescuesActivation ?? true;
37
+ const distinctCallsPerDay = rules.distinctCallsPerDay ?? false;
37
38
  const tracksActivation = rules.tracksActivation ?? true;
38
39
  const tracksOperators = rules.tracksOperators ?? true;
39
40
  const allowsMultiple = rules.allowsMultipleReferences ?? false;
@@ -125,16 +126,22 @@ function activityScorer(rules) {
125
126
  const now = { day, band, mode, refs: huntedRefs };
126
127
  const priors = sheet.workedByCall[call] ?? [];
127
128
  const verdict = judge(priors, now);
128
- const isDupe = verdict === "duplicate";
129
+ let isDupe = verdict === "duplicate";
129
130
  const freshRefOnly = verdict === "ref" && !freshRefRescuesActivation;
130
- const activationCredit = freshRefOnly ? 0 : activationValue;
131
+ let activationCredit = freshRefOnly ? 0 : activationValue;
132
+ if (distinctCallsPerDay && activationCredit > 0 && !isDupe) {
133
+ const days = sheet.activationCallDays ?? (sheet.activationCallDays = {});
134
+ activationCredit = days[call] === day ? 0 : 1;
135
+ days[call] = day;
136
+ if (activationCredit + huntingValue === 0 && !verdict) isDupe = true;
137
+ }
131
138
  const hasStake = activationValue + huntingValue > 0;
132
139
  const score = { value: isDupe ? 0 : activationCredit + huntingValue };
133
140
  if (!hasStake) {
134
141
  } else if (isDupe) {
135
142
  score.dupe = true;
136
143
  score.alerts = ["duplicate"];
137
- } else if (verdict) {
144
+ } else if (verdict && verdict !== "duplicate") {
138
145
  score.notices = [NOTICE_KEYS[verdict]];
139
146
  } else if (priors.length > 0 && score.value > 0 && freshRefRescuesActivation) {
140
147
  score.dupe = false;
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "adifForRef": "ADIF for {{program}} {{ref}}",
3
+ "adifForRefOnDay": "ADIF for {{program}} {{ref}} on {{day}}",
3
4
  "adifForHunter": "{{program}} Hunter Log"
4
5
  }
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "adifForRef": "ADIF para {{program}} {{ref}}",
3
+ "adifForRefOnDay": "ADIF para {{program}} {{ref}} del {{day}}",
3
4
  "adifForHunter": "Registro de Cazador {{program}}"
4
5
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 1.12.2
1
+ // @ham2k/extension-sdk 1.12.4
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -175,10 +175,18 @@ export interface PanelSceneSpectrum {
175
175
  display?: "both" | "pan" | "waterfall";
176
176
  /**
177
177
  * Which way the waterfall moves; the panadapter sits on the side it comes from. Frequency rises
178
- * left to right for `down` (the default) and `up`, bottom to top for `right` and `left`. Text
179
- * stays upright; offsets and `corner`s are measured as if flowing down.
178
+ * as `rising` says: by default left to right for `down` (the default) and `up`, bottom to top for
179
+ * `right` and top to bottom for `left` — `down` turned a quarter either way. Text stays upright;
180
+ * offsets and `corner`s are measured as if flowing down.
180
181
  */
181
182
  flow?: "down" | "up" | "right" | "left";
183
+ /**
184
+ * Which way frequency rises, across the `flow`: `right` or `left` for `down` and `up`, `down` or
185
+ * `up` for `right` and `left`. Omitted, `right` for `down` and `up`, `up` for `right`, `down` for
186
+ * `left`. Offsets, `align` and `corner`s are still measured as if frequency rose to the right.
187
+ * Extension API 12; before it, `left` rose bottom to top as `right` does.
188
+ */
189
+ rising?: "right" | "left" | "down" | "up";
182
190
  /** The panadapter's share of the height with `both`, 0.1–0.9. Default 0.4. */
183
191
  split?: number;
184
192
  /** dB added to the radio's own floor. */
@@ -222,7 +230,9 @@ export interface PanelSceneSpectrum {
222
230
  /**
223
231
  * Hz shown across the control, from the middle of each sweep. A sweep wider than this is cropped
224
232
  * to it, so a radio whose scope comes in a few widths can be shown at any narrower one — set the
225
- * scope (`setRadioSpectrum`) to the narrowest that covers it. Extension API 7.
233
+ * scope (`setRadioSpectrum`) to the narrowest that covers it. Where the radio's `spectrum` says
234
+ * `autoSpan`, the app sets the scope itself, wide enough for the widest `span` on screen.
235
+ * Extension API 7; `autoSpan` 12.
226
236
  */
227
237
  span?: PanelSceneSpectrumNumber;
228
238
  /** A click tunes to the nearest multiple of this many Hz. Omitted, wherever it lands. */
@@ -408,6 +418,12 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
408
418
  value: string;
409
419
  to: number;
410
420
  };
421
+ /**
422
+ * A `button` only: a click sends, as the event's `value`, where along the button's longer side it
423
+ * landed — 0 at its top or left end, 1 at its bottom or right — so a scale can be clicked at a
424
+ * place. Keys and screen readers activate it with no value, as does an app before this. Extension API 12.
425
+ */
426
+ tapAt?: boolean;
411
427
  /**
412
428
  * 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
413
429
  * keyboard focus or screen-reader node. Anything between is only drawn fainter.
@@ -1099,6 +1115,7 @@ export interface ExportResult {
1099
1115
  export interface ExportOptionsRequest {
1100
1116
  operation: Record<string, JSONValue>;
1101
1117
  qsos: Record<string, JSONValue>[];
1118
+ segments?: OperationSegmentPayload[];
1102
1119
  compactFilenames?: boolean;
1103
1120
  }
1104
1121
  export interface ExportOption {
@@ -2098,13 +2115,24 @@ export interface PanelRadioSpectrum {
2098
2115
  mode: "center" | "fixed" | null;
2099
2116
  /** Half the width shown while centered — what the radio's menu shows as ±. */
2100
2117
  spanHz: number | null;
2101
- /** The edges while fixed. */
2118
+ /**
2119
+ * The edges shown: while fixed, and while centred where the radio places its scope apart from the
2120
+ * dial (a FlexRadio's panadapter, which a retune inside it does not move). Null where the scope
2121
+ * sits on the dial, `spanHz` either side of it.
2122
+ */
2102
2123
  lowHz: number | null;
2103
2124
  highHz: number | null;
2104
2125
  refLevelDb: number | null;
2105
2126
  speed: PanelRadioSpectrumSpeed | null;
2106
2127
  /** What `setRadioSpectrum` accepts. A span between two is snapped to the nearer. */
2107
2128
  spansHz: number[];
2129
+ /**
2130
+ * The app sets the span itself — a sweep it computes, whose width changes nothing on the radio
2131
+ * (a FlexRadio's) — to the narrowest covering the widest `span` of the spectrum controls shown,
2132
+ * each cropping its own width. A panel then names its width with `span` and leaves
2133
+ * `setRadioSpectrum`'s `spanHz` alone, or the panels take turns. Absent before extension API 12.
2134
+ */
2135
+ autoSpan?: boolean;
2108
2136
  refMinDb: number;
2109
2137
  refMaxDb: number;
2110
2138
  refStepDb: number;
@@ -2423,6 +2451,7 @@ export interface ActivityScoringRules {
2423
2451
  tracksActivation?: boolean;
2424
2452
  tracksOperators?: boolean;
2425
2453
  freshRefRescuesActivation?: boolean;
2454
+ distinctCallsPerDay?: boolean;
2426
2455
  allowsMultipleReferences?: boolean;
2427
2456
  uniquePer?: readonly UniqueAxis[];
2428
2457
  activates?: "daily" | "once";
@@ -2446,6 +2475,7 @@ export type ActivityScoresheet = {
2446
2475
  dayActivatedQsos: number;
2447
2476
  dayHuntedQsos: number;
2448
2477
  dayDuplicates: number;
2478
+ activationCallDays?: Record<string, number>;
2449
2479
  };
2450
2480
  export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
2451
2481
  export interface ActivityExportRules {
@@ -2456,10 +2486,11 @@ export interface ActivityExportRules {
2456
2486
  icon?: string;
2457
2487
  includeFieldsFrom?: string[];
2458
2488
  operatorFallback?: OperatorFallback;
2489
+ splitByUtcDay?: boolean;
2459
2490
  }
2460
2491
  export declare function activityExportHook(rules: ActivityExportRules): {
2461
2492
  getExportTypes(): Promise<ExportTypeDefinition[]>;
2462
- suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
2493
+ suggestExportOptions({ operation, qsos, segments, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
2463
2494
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
2464
2495
  };
2465
2496
  export interface HuntingExportRules {
package/docs/hooks.md CHANGED
@@ -413,7 +413,7 @@ interface ExportHook {
413
413
  suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
414
414
  generateExport(args: ExportRequest, ctx): Promise<ExportResult>
415
415
  }
416
- // ExportOptionsRequest: {operation, qsos, compactFilenames?}
416
+ // ExportOptionsRequest: {operation, qsos, segments?, compactFilenames?}
417
417
  // ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, ref?, priority?, selectedByDefault?}
418
418
  // ExportRequest: {operation, qsos, segments?, events?, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
419
419
  // ExportResult: {filename, mimeType, content}
@@ -1926,7 +1926,7 @@ the extension. `value` names what it shows and sets.
1926
1926
 
1927
1927
  | Kind | Binds to | Sends | Also takes |
1928
1928
  |---|---|---|---|
1929
- | `button` | — | `activate` | `menu` |
1929
+ | `button` | — | `activate` | `menu`, `tapAt` (extension API 12: a click's `value` is where along the button's longer side it landed, 0 at its top or left end to 1 at its bottom or right; a key or screen reader activates it with none) |
1930
1930
  | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous`, `scrollThrough` |
1931
1931
  | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `invertDrag`, `scrollThrough`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
1932
1932
  | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
@@ -2104,6 +2104,13 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
2104
2104
  (`both`, `pan`, `waterfall`), `split`, and `flow` — which way the waterfall
2105
2105
  moves (`down`, `up`, `right`, `left`), the panadapter on the side it comes
2106
2106
  from.
2107
+ - `rising` (extension API 12): which way frequency increases, across the
2108
+ flow — `right` or `left` for `down` and `up`, `down` or `up` for `right` and
2109
+ `left`; one along the flow is refused. Omitted, each flow is `down` turned a
2110
+ quarter, never mirrored: `right` for `down` and `up`, `up` for `right`,
2111
+ `down` for `left` (before API 12, `left` rose up the screen too). Text stays
2112
+ upright, and offsets, `align` and pointer corners are measured as if
2113
+ frequency rose to the right.
2107
2114
  - `floor`, `range`, `average` (0–99, default 40), `fps` (default 15),
2108
2115
  `waterfallSpeed`, `waterfallFloor`, `waterfallRange`: numbers, or the names
2109
2116
  of scene values the control reads on every sweep.
@@ -2121,7 +2128,10 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
2121
2128
  number or a scene value. A wider sweep is cropped to it, so a radio whose
2122
2129
  scope comes in a few fixed widths can be shown at any narrower one; set the
2123
2130
  scope to the narrowest that covers it. Everything is drawn, labelled and
2124
- clicked on the window shown.
2131
+ clicked on the window shown. Where the radio's `spectrum` says `autoSpan`
2132
+ (extension API 12), the app sets the scope itself, to the narrowest span
2133
+ covering the widest `span` among the spectrum controls on screen, so each
2134
+ panel names its width here and leaves `setRadioSpectrum`'s `spanHz` alone.
2125
2135
  - `follow` (0–0.45): keeps the tuned frequency at least this share of the
2126
2136
  width from either edge, by moving the radio's scope just far enough to put
2127
2137
  it on that margin — the picture scrolls with the dial rather than jumping
@@ -2357,7 +2367,13 @@ for a radio without a scope, otherwise its settings as the radio reports them
2357
2367
  (`mode`, `spanHz`, `lowHz`/`highHz`, `refLevelDb`, `speed`), what it accepts
2358
2368
  (`spansHz`, `refMinDb`…`refStepDb`, `modes`, `speeds`) and `problem`, why it
2359
2369
  cannot run, in the operator's language. The settings are known only while a
2360
- `spectrum` control has the scope running, and null otherwise.
2370
+ `spectrum` control has the scope running, and null otherwise. `autoSpan`
2371
+ (extension API 12) is true where the app sizes the scope for its displays — a
2372
+ sweep it computes, whose width changes nothing on the radio (a FlexRadio's DAX
2373
+ IQ): it runs at the narrowest span covering the widest spectrum control's
2374
+ `span` on screen, each control cropping its own width from it, and narrows
2375
+ again as wider ones go. A panel there names its width with `span`; setting
2376
+ `spanHz` too has the panels take turns.
2361
2377
  `host.setRadioSpectrum({id, selection?, mode?, spanHz?, refLevelDb?, speed?})`
2362
2378
  changes them; a span between two the radio offers is snapped to the nearer. It
2363
2379
  answers `stopped` when no control is running the scope: nothing is queued for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "1.12.2",
3
+ "version": "1.12.4",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",