diff --git a/src/lib/adif.ts b/src/lib/adif.ts index b7f1e56..7985995 100644 --- a/src/lib/adif.ts +++ b/src/lib/adif.ts @@ -70,6 +70,32 @@ function parseSingleRecord(recordString: string): AdifRecord | null { return { fields }; } +// SSB's ADIF submodes are the sideband, not a distinct operating mode — a phone +// QSO must stay "SSB" so it still matches the SSB filter and phone statistics. +const VOICE_SUBMODES = new Set(['USB', 'LSB']); + +/** + * Collapse an ADIF MODE/SUBMODE pair into the single effective mode Nextlog + * stores (the `contacts.mode` column is flat — there is no submode column). + * + * WSJT-X, JTDX and JS8Call log the meaningful mode as a SUBMODE beneath a + * generic parent: FT4/JS8/FST4 under `MFSK`, PSK31 under `PSK`. Keeping only the + * parent MODE collapsed all of those into "MFSK"/"PSK", which the mode filter and + * mode statistics treat as first-class modes distinct from FT4/JS8/PSK31 — so an + * FT4 run imported from WSJT-X silently dropped out of the FT4 filter. We promote + * the submode when present, EXCEPT the voice sidebands (USB/LSB) which stay SSB. + * Everything is normalized to uppercase to match how modes are stored/exported. + */ +export function resolveAdifMode( + mode: string | undefined, + submode: string | undefined, +): string { + const m = mode?.trim().toUpperCase() ?? ''; + const sub = submode?.trim().toUpperCase() ?? ''; + if (sub && !VOICE_SUBMODES.has(sub)) return sub; + return m || 'SSB'; +} + /** * Insert one parsed ADIF record into the contacts table for the given user + * station. Returns a discriminated result describing what happened: @@ -162,7 +188,7 @@ export async function insertAdifRecord( callsign, fields.name || null, frequency, - fields.mode || fields.submode || 'SSB', + resolveAdifMode(fields.mode, fields.submode), band || null, datetime, fields.rst_sent || null, diff --git a/tests/adif-mode.spec.ts b/tests/adif-mode.spec.ts new file mode 100644 index 0000000..472e823 --- /dev/null +++ b/tests/adif-mode.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { resolveAdifMode, parseAdifRecords } from '@/lib/adif'; + +// Pure-function tests for the ADIF MODE/SUBMODE resolver. WSJT-X, JTDX and +// JS8Call log the "digital voice" of a QSO as an ADIF SUBMODE under a generic +// parent MODE (FT4 under MFSK, JS8 under MFSK, PSK31 under PSK). Storing only +// the parent MODE collapses every one of those into "MFSK"/"PSK", which the +// app's mode filter and mode statistics treat as distinct first-class modes — +// so an FT4 run imported from WSJT-X vanished from the FT4 filter. resolveAdifMode +// promotes the more-specific submode while keeping phone (SSB) intact. + +test.describe('resolveAdifMode', () => { + test('promotes a digital submode to the effective mode', () => { + // WSJT-X FT4: MFSKFT4 + expect(resolveAdifMode('MFSK', 'FT4')).toBe('FT4'); + // JS8Call: MFSKJS8 + expect(resolveAdifMode('MFSK', 'JS8')).toBe('JS8'); + // WSJT-X FST4: MFSKFST4 + expect(resolveAdifMode('MFSK', 'FST4')).toBe('FST4'); + // fldigi PSK31: PSKPSK31 + expect(resolveAdifMode('PSK', 'PSK31')).toBe('PSK31'); + }); + + test('keeps the parent MODE for voice (USB/LSB) submodes', () => { + // SSB phone must stay SSB so it still matches the SSB filter and phone + // stats — promoting USB/LSB would split every phone QSO out of SSB. + expect(resolveAdifMode('SSB', 'USB')).toBe('SSB'); + expect(resolveAdifMode('SSB', 'LSB')).toBe('SSB'); + // Case- and whitespace-insensitive. + expect(resolveAdifMode('SSB', ' lsb ')).toBe('SSB'); + }); + + test('falls back to MODE when no submode is present', () => { + expect(resolveAdifMode('FT8', undefined)).toBe('FT8'); + expect(resolveAdifMode('CW', '')).toBe('CW'); + expect(resolveAdifMode('RTTY', ' ')).toBe('RTTY'); + }); + + test('normalizes to uppercase and trims surrounding whitespace', () => { + expect(resolveAdifMode('mfsk', 'ft4')).toBe('FT4'); + expect(resolveAdifMode(' cw ', undefined)).toBe('CW'); + }); + + test('defaults to SSB when neither mode nor submode is usable', () => { + expect(resolveAdifMode(undefined, undefined)).toBe('SSB'); + expect(resolveAdifMode('', '')).toBe('SSB'); + expect(resolveAdifMode(' ', ' ')).toBe('SSB'); + }); + + test('resolves the mode of a parsed WSJT-X FT4 record', () => { + // A verbatim WSJT-X FT4 log line: the mode lives in SUBMODE, not MODE. + const adif = + 'W1AWFN31MFSKFT4' + + '+03-0820240115123456'; + + const [record] = parseAdifRecords(adif); + + expect(record.fields.mode).toBe('MFSK'); + expect(record.fields.submode).toBe('FT4'); + // The importer stores the promoted submode, so it lands in the FT4 filter. + expect(resolveAdifMode(record.fields.mode, record.fields.submode)).toBe('FT4'); + }); +});