From b6c96a3f6d630e66285c275cba6f810c18fda87b Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Sun, 15 Feb 2026 18:33:33 +0000 Subject: [PATCH 1/2] Initial changes to support QRZ.com upload --- src/extensions/data/qrz/QRZExtension.js | 109 +++++++++++++++++++++ src/store/apis/apiQRZ/apiQRZ.js | 122 +++++++++++++++++++++--- src/tools/qsonToADIF.js | 4 +- 3 files changed, 222 insertions(+), 13 deletions(-) diff --git a/src/extensions/data/qrz/QRZExtension.js b/src/extensions/data/qrz/QRZExtension.js index b4824b534..82edcc623 100644 --- a/src/extensions/data/qrz/QRZExtension.js +++ b/src/extensions/data/qrz/QRZExtension.js @@ -8,6 +8,10 @@ import GLOBAL from '../../../GLOBAL' import { apiQRZ } from '../../../store/apis/apiQRZ' +import { hashCode } from '../../../tools/hashCode' +import { setOperationData } from '../../../store/operations' +import { adifFieldsForOneQSO, adifRow } from '../../../tools/qsonToADIF' + export const Info = { key: 'qrz', icon: 'web', @@ -24,6 +28,7 @@ const Extension = { onActivation: ({ registerHook }) => { registerHook('lookup', { hook: LookupHook, priority: 99 }) registerHook('account', { hook: AccountHook }) + registerHook('qsl', { hook: QSLHook }) } } export default Extension @@ -75,3 +80,107 @@ const AccountHook = { fetchSpots: async ({ online, settings, dispatch }) => { } } + +function qrzAdifRow(qso, operation) { + return adifRow(adifFieldsForOneQSO({qso, common: operation, privateData: true, templates: {}})) +} + +const QSLHook = { + ...Info, + serviceName: 'QRZ.com Logbook', + canSendOutgoingQSLs: ({ operation, qsos, settings }) => { + console.log("canSend", operation, settings) + const activatorCallsign = operation.stationCall || settings.operatorCall + const apiKey = settings?.accounts?.qrz?.logbooks?.find(log => log.callsign === activatorCallsign)?.apiKey + return !!apiKey + }, + sendOutgoingQSLs: async ({ operation, qsos, settings, dispatch }) => { + const activatorCallsign = operation.stationCall || settings.operatorCall + const apiKey = settings?.accounts?.qrz?.logbooks?.find(log => log.callsign === activatorCallsign)?.apiKey + console.log(settings.accounts?.qrz) + if (!apiKey) { + dispatch(setOperationData({ + uuid: operation.uuid, + qsl: { ...operation?.qsl, [Info.key]: { + ...operation?.qsl?.[Info.key], + error: `No logbook for callsign ${activatorCallsign}` + }} + })) + return + } + + const qsl = operation?.qsl?.[Info.key] + const qslUploadIDs = {...qsl?.uploadIDs || {}} + + const uploadQSOs = [] + const qsosToDelete = [] + let qsosHash = 0 + qsos.filter(q => !q.deleted).forEach(qso => { + const qsoAdifRow = qrzAdifRow(qso, operation) + const qsoHash = hashCode(qsoAdifRow) + qsosHash = hashCode(qsoAdifRow, qsosHash) + if (qslUploadIDs[qso.uuid]?.hash && qslUploadIDs[qso.uuid]?.hash === qsoHash) return + if (qslUploadIDs[qso.uuid]?.id) qsosToDelete.push(qslUploadIDs[qso.uuid].id) + uploadQSOs.push({ + qsoUUID: qso.uuid, + qsoHash, + qsoAdifRow + }) + }) + + Object.keys(qslUploadIDs).forEach(qsoUUID => { + if (!qsos.find(q => !q.deleted && q.uuid === qsoUUID)) { + if (qslUploadIDs[qsoUUID]?.id) qsosToDelete.push(qslUploadIDs[qsoUUID].id) + delete qslUploadIDs[qsoUUID] + } + }) + + if (qsosToDelete.length > 0) { + const apiDeletePromise = await dispatch(apiQRZ.endpoints.logbookDelete.initiate({apiKey, logIds: qsosToDelete}, {forceRefetch: true})) + apiDeletePromise.unsubscribe && apiDeletePromise.unsubscribe() + } + + const errors = [] + + for (const uploadQSO of uploadQSOs) { + const {qsoAdifRow, qsoHash, qsoUUID} = uploadQSO + // TODO: don't await here for each + const apiPromise = await dispatch(apiQRZ.endpoints.logbookInsert.initiate({apiKey, adif: qsoAdifRow}, { forceRefetch: true })) + await Promise.all(dispatch(apiQRZ.util.getRunningQueriesThunk())) + apiResults = await dispatch((_dispatch, getState) => apiQRZ.endpoints.logbookInsert.select({apiKey, adif: qsoAdifRow})(getState())) + apiPromise.unsubscribe && apiPromise.unsubscribe() + + if (!apiResults?.error) { + qslUploadIDs[qsoUUID] = { + id: apiResults?.data?.logId, + hash: qsoHash + } + } else { + console.log('Error uploading QSO to QRZ.com', apiResults.error) + errors.push(apiResults?.error?.data?.REASON || 'Unknown error') + } + } + dispatch(setOperationData({ + uuid: operation.uuid, + qsl: { ...operation?.qsl, [Info.key]: { + lastUpdated: errors.length > 0 ? operation?.qsl?.[Info.key]?.lastUpdated : Date.now(), + qsosHash, + uploadIDs: qslUploadIDs, + error: errors?.[0] || null // Just return the first error if there are multiple, to keep it simple. + }} + })) + }, + qslStatusForOperation: ({ operation, qsos }) => { + error = operation?.qsl?.[Info.key]?.error + if (!operation?.qsl?.[Info.key]) return { status: 'neverSent', error } + + // QSO modified in way that concerns service? + const qslHash = operation.qsl?.[Info.key]?.qsosHash + const newHash = qsos.filter(q => !q.deleted).reduce((hash, q) => hashCode(qrzAdifRow(q, operation), hash), 0) + if (qslHash !== newHash) { + return { status:'needsUpdate', error } + } + + return { status: 'upToDate', error } + } +} diff --git a/src/store/apis/apiQRZ/apiQRZ.js b/src/store/apis/apiQRZ/apiQRZ.js index dc1013ba8..b0467c0b7 100644 --- a/src/store/apis/apiQRZ/apiQRZ.js +++ b/src/store/apis/apiQRZ/apiQRZ.js @@ -21,7 +21,7 @@ import { setAccountInfo } from '../../settings' const DEBUG = false -const BASE_URL = 'https://xmldata.qrz.com/' +const BASE_URL = 'https://' const API_TIMEOUT = 3000 // 3 seconds @@ -34,20 +34,28 @@ function defaultParams (api) { } const baseQueryWithSettings = fetchBaseQuery({ - baseUrl: `${BASE_URL}/xml/current`, + baseUrl: `${BASE_URL}`, timeout: API_TIMEOUT, prepareHeaders: (headers, { getState, endpoint }) => { headers.set('User-Agent', `ham2k-polo-${packageJson.version}`) }, responseHandler: async (response) => { - if (response.status === 200) { + if (response.ok) { + const contentType = response.headers.get('content-type') || '' const body = await response.text() - const parser = new XMLParser() - const xml = parser.parse(body) + let data + if (contentType.includes('xml')) { + const parser = new XMLParser() + data = parser.parse(body) + } else if (contentType.includes('plain')) { + const parsed = new URLSearchParams(body) + data = Object.fromEntries(parsed.entries()) + } if (DEBUG) console.log(`QRZApi ${response.url} ${response.status}`) if (DEBUG) console.log('-- url', response.url) - if (DEBUG) console.log('-- response', xml) - return xml + if (DEBUG) console.log('-- content-type', contentType) + if (DEBUG) console.log('-- response', data) + return data || body } else { return {} } @@ -67,7 +75,7 @@ const baseQueryWithReauth = async (args, api, extraOptions) => { const { login, password } = api.getState().settings?.accounts?.qrz ?? {} if (DEBUG) console.log('baseQueryWithReauth second call') result = await baseQueryWithSettings({ - url: '', + url: 'xmldata.qrz.com/xml/current/', params: { ...defaultParams(api), s: undefined, @@ -96,7 +104,6 @@ const baseQueryWithReauth = async (args, api, extraOptions) => { export const apiQRZ = createApi({ reducerPath: 'apiQRZ', - baseQuery: baseQueryWithReauth, endpoints: builder => ({ // Lookup a callsign @@ -108,8 +115,8 @@ export const apiQRZ = createApi({ const { call } = args if (!call || call.length < 3) return { data: {} } - const response = await baseQuery({ - url: '', + const response = await baseQueryWithReauth({ + url: 'xmldata.qrz.com/xml/current/', params: { ...defaultParams(api), callsign: call @@ -165,6 +172,99 @@ export const apiQRZ = createApi({ return response } } + }), + logbookStatus: builder.query({ + keepUnusedDataFor: 60 * 60 * 12, // 12 hours + queryFn: async (args, api, extraOptions, baseQuery) => { + const { apiKey } = args + const formData = new FormData() + formData.append('KEY', apiKey) + formData.append('ACTION', 'STATUS') + const response = await baseQueryWithSettings({ + url: 'logbook.qrz.com/api', + method: 'POST', + body: formData + }, api, extraOptions) + if (response.data) { + const data = response.data + if (data?.RESULT !== 'OK') { + return { ...response, error: data, data: undefined } + } + return { + ...response, + error: undefined, + data: { + callsign: data.CALLSIGN, + name: data.BOOK_NAME, + startDate: data.START_DATE ? new Date(`${data.START_DATE}T00:00:00Z`) : null, + endDate: data.END_DATE ? new Date(`${data.END_DATE}T00:00:00Z`) : null, + } + } + } else { + return response + } + } + }), + logbookInsert: builder.query({ + queryFn: async (args, api, extraOptions, baseQuery) => { + const { apiKey, adif } = args + const formData = new FormData() + formData.append('KEY', apiKey) + formData.append('ACTION', 'INSERT') + formData.append('ADIF', adif) + formData.append('OPTION', 'REPLACE') + const response = await baseQueryWithSettings({ + url: 'logbook.qrz.com/api', + method: 'POST', + body: formData + }, api, extraOptions) + if (response.data) { + const data = response.data + if (!(data?.RESULT == 'OK' || data?.RESULT == 'REPLACE')) { + return { ...response, error: data, data: undefined } + } + return { + ...response, + error: undefined, + data: { + result: data.RESULT, // "OK" or "REPLACE" + logId: data.LOGID + } + } + } else { + return response + } + } + }), + logbookDelete: builder.query({ + queryFn: async (args, api, extraOptions, baseQuery) => { + const { apiKey, logIds } = args + const formData = new FormData() + formData.append('KEY', apiKey) + formData.append('ACTION', 'DELETE') + formData.append('LOGIDS', logIds.join(',')) + const response = await baseQueryWithSettings({ + url: 'logbook.qrz.com/api', + method: 'POST', + body: formData + }, api, extraOptions) + if (response.data) { + const data = response.data + if (!(data?.RESULT === 'OK' || data?.RESULT === 'PARTIAL')) { + return { ...response, error: data, data: undefined } + } + return { + ...response, + error: undefined, + data: { + result: data.RESULT, // "OK" or "PARTIAL" + logIds: data?.LOGIDS ? data.LOGIDS.split(',') : [], + } + } + } else { + return response + } + } }) }) }) diff --git a/src/tools/qsonToADIF.js b/src/tools/qsonToADIF.js index e58a4cf64..82bcf4c5f 100644 --- a/src/tools/qsonToADIF.js +++ b/src/tools/qsonToADIF.js @@ -178,7 +178,7 @@ function modeToADIF(mode, freq, qsoInfo) { } } -function adifFieldsForOneQSO({ qso, operation, common, privateData, templates, timeOffset }) { +export function adifFieldsForOneQSO({ qso, operation, common, privateData, templates, timeOffset }) { timeOffset = timeOffset ?? 0 const fields = [ { CALL: qso.their.call }, @@ -246,7 +246,7 @@ function adifFieldsForOneQSO({ qso, operation, common, privateData, templates, t return fields } -function adifRow(fields) { +export function adifRow(fields) { return fields .filter(field => field[1] !== false) .map(field => adifField(Object.keys(field)[0], Object.values(field)[0])) From cc235e922d2fab3509804aa98ea4fc8c5ecfe4b6 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Sun, 15 Feb 2026 20:58:11 +0000 Subject: [PATCH 2/2] Send QRZ.com uploads asynchronously --- src/extensions/data/qrz/QRZExtension.js | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/extensions/data/qrz/QRZExtension.js b/src/extensions/data/qrz/QRZExtension.js index 82edcc623..cb7b3e81b 100644 --- a/src/extensions/data/qrz/QRZExtension.js +++ b/src/extensions/data/qrz/QRZExtension.js @@ -141,25 +141,25 @@ const QSLHook = { } const errors = [] - - for (const uploadQSO of uploadQSOs) { - const {qsoAdifRow, qsoHash, qsoUUID} = uploadQSO - // TODO: don't await here for each - const apiPromise = await dispatch(apiQRZ.endpoints.logbookInsert.initiate({apiKey, adif: qsoAdifRow}, { forceRefetch: true })) - await Promise.all(dispatch(apiQRZ.util.getRunningQueriesThunk())) - apiResults = await dispatch((_dispatch, getState) => apiQRZ.endpoints.logbookInsert.select({apiKey, adif: qsoAdifRow})(getState())) - apiPromise.unsubscribe && apiPromise.unsubscribe() - - if (!apiResults?.error) { - qslUploadIDs[qsoUUID] = { - id: apiResults?.data?.logId, - hash: qsoHash + const promises = uploadQSOs.map((uploadQSO) => { + const { qsoAdifRow, qsoHash, qsoUUID } = uploadQSO; + + return dispatch(apiQRZ.endpoints.logbookInsert.initiate({ apiKey, adif: qsoAdifRow }, { forceRefetch: true })) + .then((result) => { + if (!result.error) { + qslUploadIDs[qsoUUID] = { + id: result.data?.logId, + hash: qsoHash, + }; + } else { + console.log('Error uploading QSO to QRZ.com', result.error); + errors.push(result.error?.data?.REASON || 'Unknown error'); } - } else { - console.log('Error uploading QSO to QRZ.com', apiResults.error) - errors.push(apiResults?.error?.data?.REASON || 'Unknown error') - } - } + result.unsubscribe && result.unsubscribe(); + }); + }); + await Promise.all(promises) + dispatch(setOperationData({ uuid: operation.uuid, qsl: { ...operation?.qsl, [Info.key]: {