Skip to content

Commit 9f06b97

Browse files
patrickrbOptio Agentclaude
authored
feat(adif): store the ADIF submode (FT4/JS8/PSK31) as the QSO mode on import (#240)
WSJT-X, JTDX and JS8Call log the operating mode of a digital QSO as an ADIF SUBMODE beneath a generic parent MODE — FT4/JS8/FST4 under MFSK, PSK31 under PSK. The importer only read MODE, so every one of those QSOs was stored as "MFSK"/"PSK". The mode filter, mode statistics and charts treat FT4/JS8/PSK31 as first-class modes (they're in the search UI's mode list), so an FT4 run imported from WSJT-X silently dropped out of the FT4 filter and skewed stats. Add resolveAdifMode(mode, submode): promote the submode when present, except the voice sidebands (USB/LSB) which stay SSB so phone QSOs keep matching the SSB filter. Normalized to uppercase to match how modes are stored/exported. Backwards compatible: existing rows are untouched; only affects newly imported records. MODE-only imports (FT8, CW, RTTY, SSB) resolve exactly as before. Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3bf9ede commit 9f06b97

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

src/lib/adif.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ function parseSingleRecord(recordString: string): AdifRecord | null {
7070
return { fields };
7171
}
7272

73+
// SSB's ADIF submodes are the sideband, not a distinct operating mode — a phone
74+
// QSO must stay "SSB" so it still matches the SSB filter and phone statistics.
75+
const VOICE_SUBMODES = new Set(['USB', 'LSB']);
76+
77+
/**
78+
* Collapse an ADIF MODE/SUBMODE pair into the single effective mode Nextlog
79+
* stores (the `contacts.mode` column is flat — there is no submode column).
80+
*
81+
* WSJT-X, JTDX and JS8Call log the meaningful mode as a SUBMODE beneath a
82+
* generic parent: FT4/JS8/FST4 under `MFSK`, PSK31 under `PSK`. Keeping only the
83+
* parent MODE collapsed all of those into "MFSK"/"PSK", which the mode filter and
84+
* mode statistics treat as first-class modes distinct from FT4/JS8/PSK31 — so an
85+
* FT4 run imported from WSJT-X silently dropped out of the FT4 filter. We promote
86+
* the submode when present, EXCEPT the voice sidebands (USB/LSB) which stay SSB.
87+
* Everything is normalized to uppercase to match how modes are stored/exported.
88+
*/
89+
export function resolveAdifMode(
90+
mode: string | undefined,
91+
submode: string | undefined,
92+
): string {
93+
const m = mode?.trim().toUpperCase() ?? '';
94+
const sub = submode?.trim().toUpperCase() ?? '';
95+
if (sub && !VOICE_SUBMODES.has(sub)) return sub;
96+
return m || 'SSB';
97+
}
98+
7399
/**
74100
* Insert one parsed ADIF record into the contacts table for the given user +
75101
* station. Returns a discriminated result describing what happened:
@@ -162,7 +188,7 @@ export async function insertAdifRecord(
162188
callsign,
163189
fields.name || null,
164190
frequency,
165-
fields.mode || fields.submode || 'SSB',
191+
resolveAdifMode(fields.mode, fields.submode),
166192
band || null,
167193
datetime,
168194
fields.rst_sent || null,

tests/adif-mode.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect } from '@playwright/test';
2+
import { resolveAdifMode, parseAdifRecords } from '@/lib/adif';
3+
4+
// Pure-function tests for the ADIF MODE/SUBMODE resolver. WSJT-X, JTDX and
5+
// JS8Call log the "digital voice" of a QSO as an ADIF SUBMODE under a generic
6+
// parent MODE (FT4 under MFSK, JS8 under MFSK, PSK31 under PSK). Storing only
7+
// the parent MODE collapses every one of those into "MFSK"/"PSK", which the
8+
// app's mode filter and mode statistics treat as distinct first-class modes —
9+
// so an FT4 run imported from WSJT-X vanished from the FT4 filter. resolveAdifMode
10+
// promotes the more-specific submode while keeping phone (SSB) intact.
11+
12+
test.describe('resolveAdifMode', () => {
13+
test('promotes a digital submode to the effective mode', () => {
14+
// WSJT-X FT4: <MODE:4>MFSK<SUBMODE:3>FT4
15+
expect(resolveAdifMode('MFSK', 'FT4')).toBe('FT4');
16+
// JS8Call: <MODE:4>MFSK<SUBMODE:3>JS8
17+
expect(resolveAdifMode('MFSK', 'JS8')).toBe('JS8');
18+
// WSJT-X FST4: <MODE:4>MFSK<SUBMODE:4>FST4
19+
expect(resolveAdifMode('MFSK', 'FST4')).toBe('FST4');
20+
// fldigi PSK31: <MODE:3>PSK<SUBMODE:5>PSK31
21+
expect(resolveAdifMode('PSK', 'PSK31')).toBe('PSK31');
22+
});
23+
24+
test('keeps the parent MODE for voice (USB/LSB) submodes', () => {
25+
// SSB phone must stay SSB so it still matches the SSB filter and phone
26+
// stats — promoting USB/LSB would split every phone QSO out of SSB.
27+
expect(resolveAdifMode('SSB', 'USB')).toBe('SSB');
28+
expect(resolveAdifMode('SSB', 'LSB')).toBe('SSB');
29+
// Case- and whitespace-insensitive.
30+
expect(resolveAdifMode('SSB', ' lsb ')).toBe('SSB');
31+
});
32+
33+
test('falls back to MODE when no submode is present', () => {
34+
expect(resolveAdifMode('FT8', undefined)).toBe('FT8');
35+
expect(resolveAdifMode('CW', '')).toBe('CW');
36+
expect(resolveAdifMode('RTTY', ' ')).toBe('RTTY');
37+
});
38+
39+
test('normalizes to uppercase and trims surrounding whitespace', () => {
40+
expect(resolveAdifMode('mfsk', 'ft4')).toBe('FT4');
41+
expect(resolveAdifMode(' cw ', undefined)).toBe('CW');
42+
});
43+
44+
test('defaults to SSB when neither mode nor submode is usable', () => {
45+
expect(resolveAdifMode(undefined, undefined)).toBe('SSB');
46+
expect(resolveAdifMode('', '')).toBe('SSB');
47+
expect(resolveAdifMode(' ', ' ')).toBe('SSB');
48+
});
49+
50+
test('resolves the mode of a parsed WSJT-X FT4 record', () => {
51+
// A verbatim WSJT-X FT4 log line: the mode lives in SUBMODE, not MODE.
52+
const adif =
53+
'<call:4>W1AW<gridsquare:4>FN31<mode:4>MFSK<submode:3>FT4' +
54+
'<rst_sent:3>+03<rst_rcvd:3>-08<qso_date:8>20240115<time_on:6>123456<eor>';
55+
56+
const [record] = parseAdifRecords(adif);
57+
58+
expect(record.fields.mode).toBe('MFSK');
59+
expect(record.fields.submode).toBe('FT4');
60+
// The importer stores the promoted submode, so it lands in the FT4 filter.
61+
expect(resolveAdifMode(record.fields.mode, record.fields.submode)).toBe('FT4');
62+
});
63+
});

0 commit comments

Comments
 (0)