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:
145
src/backend/utils/adif-parser.js
Normal file
145
src/backend/utils/adif-parser.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user