feat: implement DCL ADIF parser and service integration

- Add shared ADIF parser utility (src/backend/utils/adif-parser.js)
  - parseADIF(): Parse ADIF format into QSO records
  - parseDCLResponse(): Parse DCL's JSON response format
  - normalizeBand() and normalizeMode(): Standardize band/mode names

- Implement DCL service (src/backend/services/dcl.service.js)
  - fetchQSOsFromDCL(): Fetch from DCL API (ready for API availability)
  - parseDCLJSONResponse(): Parse example payload format
  - syncQSOs(): Update existing QSOs with DCL confirmations
  - Support DCL-specific fields: DCL_QSL_RCVD, DCL_QSLRDATE, DARC_DOK, MY_DARC_DOK

- Refactor LoTW service to use shared ADIF parser
  - Remove duplicate parseADIF, normalizeBand, normalizeMode functions
  - Import from shared utility for consistency

- Tested with example DCL payload
  - Successfully parses all 6 QSOs
  - Correctly extracts DCL confirmation data
  - Handles ADIF format with <EOR> delimiters

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 11:49:36 +01:00
parent 322ccafcae
commit 8a1a5804ff
4 changed files with 510 additions and 198 deletions

View File

@@ -0,0 +1,145 @@
/**
* 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> (end of record) - case sensitive as per ADIF spec
const records = adifData.split('<EOR>');
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 = {};
const regex = /<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi;
let match;
while ((match = regex.exec(record)) !== null) {
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();
// Update regex position to continue after the value
regex.lastIndex = valueStart + length;
}
// 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();
}