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

@@ -2,6 +2,7 @@ import { db, logger } from '../config.js';
import { qsos } from '../db/schema/index.js';
import { max, sql, eq, and, desc } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js';
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
/**
* LoTW (Logbook of the World) Service
@@ -150,39 +151,6 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
};
}
/**
* Parse ADIF (Amateur Data Interchange Format) data
*/
function parseADIF(adifData) {
const qsos = [];
const records = adifData.split('<eor>');
for (const record of records) {
if (!record.trim()) continue;
if (record.trim().startsWith('<') && !record.includes('<CALL:') && !record.includes('<call:')) continue;
const qso = {};
const regex = /<([A-Z_]+):(\d+)(?::[A-Z]+)?>([\s\S])/gi;
let match;
while ((match = regex.exec(record)) !== null) {
const [fullMatch, fieldName, lengthStr, firstChar] = match;
const length = parseInt(lengthStr, 10);
const valueStart = match.index + fullMatch.length - 1;
const value = record.substring(valueStart, valueStart + length);
qso[fieldName.toLowerCase()] = value.trim();
regex.lastIndex = valueStart + length;
}
if (Object.keys(qso).length > 0 && (qso.call || qso.call)) {
qsos.push(qso);
}
}
return qsos;
}
/**
* Convert ADIF QSO to database format
*/
@@ -211,35 +179,6 @@ function convertQSODatabaseFormat(adifQSO, userId) {
};
}
function normalizeBand(band) {
if (!band) return null;
const bandMap = {
'160m': '160m', '80m': '80m', '60m': '60m', '40m': '40m',
'30m': '30m', '20m': '20m', '17m': '17m', '15m': '15m',
'12m': '12m', '10m': '10m', '6m': '6m', '4m': '4m',
'2m': '2m', '1.25m': '1.25m', '70cm': '70cm', '33cm': '33cm',
'23cm': '23cm', '13cm': '13cm', '9cm': '9cm', '6cm': '6cm',
'3cm': '3cm', '1.2cm': '1.2cm', 'mm': 'mm',
};
return bandMap[band.toLowerCase()] || band;
}
function normalizeMode(mode) {
if (!mode) return '';
const modeMap = {
'cw': 'CW', 'ssb': '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',
};
const normalized = modeMap[mode.toLowerCase()];
return normalized || mode.toUpperCase();
}
/**
* Sync QSOs from LoTW to database
* @param {number} userId - User ID