Files
award/src/backend/utils/adif-parser.js
Joerg 233888c44f fix: make ADIF parser case-insensitive for EOR delimiter
Critical bug fix: ADIF parser was using case-sensitive split on '<EOR>',
but LoTW returns lowercase '<eor>' tags. This caused all 242,239 QSOs
to be parsed as a single giant record with fields overwriting each other,
resulting in only 1 QSO being imported.

Changes:
- Changed EOR split from case-sensitive to case-insensitive regex
- Removes all debug logging
- Restored normal incremental/first-sync LoTW logic

Before: 6.8MB LoTW report → 1 QSO (bug)
After: 6.8MB LoTW report → All 242K+ QSOs (fixed)

Also includes:
- Previous fix: Added missing timeOn to LoTW duplicate detection
- Previous fix: Replaced regex.exec() while loop with matchAll() for-of

Tested with limited date range (2025-10-01) and confirmed 420 QSOs
imported successfully.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 08:25:25 +01:00

146 lines
4.0 KiB
JavaScript

/**
* ADIF (Amateur Data Interchange Format) Parser
* Handles standard ADIF format from LoTW, DCL, and other sources
*
* ADIF format: <FIELD_NAME:length>value
* Example: <CALL:5>DK0MU<BAND:3>80m<QSO_DATE:8>20250621
*/
/**
* Parse ADIF data into array of QSO records
* @param {string} adifData - Raw ADIF data string
* @returns {Array<Object>} Array of parsed QSO records
*/
export function parseADIF(adifData) {
const qsos = [];
// Split by <EOR> (case-insensitive to handle <EOR>, <eor>, <Eor>, etc.)
const regex = new RegExp('<eor>', 'gi');
const records = adifData.split(regex);
for (const record of records) {
if (!record.trim()) continue;
// Skip header records
const trimmed = record.trim();
if (trimmed.startsWith('<') && !trimmed.includes('<CALL:') && !trimmed.includes('<call:')) {
continue;
}
const qso = {};
// Use matchAll for cleaner parsing (creates new iterator for each record)
const matches = record.matchAll(/<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi);
for (const match of matches) {
const [fullMatch, fieldName, lengthStr] = match;
const length = parseInt(lengthStr, 10);
const valueStart = match.index + fullMatch.length;
// Extract exactly 'length' characters from the string
const value = record.substring(valueStart, valueStart + length);
qso[fieldName.toLowerCase()] = value.trim();
}
// Only add if we have at least a callsign
if (Object.keys(qso).length > 0 && (qso.call || qso.callsign)) {
qsos.push(qso);
}
}
return qsos;
}
/**
* Parse DCL API response
* DCL returns JSON with an "adif" field containing ADIF data
* @param {Object} response - DCL API response
* @returns {Array<Object>} Array of parsed QSO records
*/
export function parseDCLResponse(response) {
if (!response || !response.adif) {
return [];
}
const adifData = response.adif;
const qsos = parseADIF(adifData);
// Map DCL-specific fields to standard names
return qsos.map(qso => ({
...qso,
dcl_qsl_rcvd: qso.dcl_qsl_rcvd,
dcl_qslrdate: qso.dcl_qslrdate,
darc_dok: qso.darc_dok,
my_darc_dok: qso.my_darc_dok,
}));
}
/**
* Normalize band name to standard format
* @param {string} band - Band name
* @returns {string|null} Normalized band name
*/
export function normalizeBand(band) {
if (!band) return null;
const bandMap = {
'160m': '160m', '1800': '160m',
'80m': '80m', '3500': '80m', '3.5mhz': '80m',
'60m': '60m', '5mhz': '60m',
'40m': '40m', '7000': '40m', '7mhz': '40m',
'30m': '30m', '10100': '30m', '10mhz': '30m',
'20m': '20m', '14000': '20m', '14mhz': '20m',
'17m': '17m', '18100': '17m', '18mhz': '17m',
'15m': '15m', '21000': '15m', '21mhz': '15m',
'12m': '12m', '24890': '12m', '24mhz': '12m',
'10m': '10m', '28000': '10m', '28mhz': '10m',
'6m': '6m', '50000': '6m', '50mhz': '6m',
'4m': '4m', '70000': '4m', '70mhz': '4m',
'2m': '2m', '144000': '2m', '144mhz': '2m',
'1.25m': '1.25m', '222000': '1.25m', '222mhz': '1.25m',
'70cm': '70cm', '432000': '70cm', '432mhz': '70cm',
'33cm': '33cm', '902000': '33cm', '902mhz': '33cm',
'23cm': '23cm', '1296000': '23cm', '1296mhz': '23cm',
};
const normalized = bandMap[band.toLowerCase()];
return normalized || band;
}
/**
* Normalize mode name to standard format
* @param {string} mode - Mode name
* @returns {string} Normalized mode name
*/
export function normalizeMode(mode) {
if (!mode) return '';
const modeMap = {
'cw': 'CW',
'ssb': 'SSB', 'lsb': 'SSB', 'usb': 'SSB',
'am': 'AM',
'fm': 'FM',
'rtty': 'RTTY',
'psk31': 'PSK31',
'psk63': 'PSK63',
'ft8': 'FT8',
'ft4': 'FT4',
'jt65': 'JT65',
'jt9': 'JT9',
'js8': 'JS8',
'mfsk': 'MFSK',
'olivia': 'OLIVIA',
'sstv': 'SSTV',
'packet': 'PACKET',
'pactor': 'PACTOR',
'winlink': 'WINLINK',
'fax': 'FAX',
'hell': 'HELL',
'tor': 'TOR',
};
const normalized = modeMap[mode.toLowerCase()];
return normalized || mode.toUpperCase();
}