Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/extensions/data/qrz/QRZExtension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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 = []
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');
}
result.unsubscribe && result.unsubscribe();
});
});
await Promise.all(promises)

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 }
}
}
122 changes: 111 additions & 11 deletions src/store/apis/apiQRZ/apiQRZ.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {}
}
Expand All @@ -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,
Expand Down Expand Up @@ -96,7 +104,6 @@ const baseQueryWithReauth = async (args, api, extraOptions) => {

export const apiQRZ = createApi({
reducerPath: 'apiQRZ',
baseQuery: baseQueryWithReauth,
endpoints: builder => ({

// Lookup a callsign
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
})
})
})
Expand Down
4 changes: 2 additions & 2 deletions src/tools/qsonToADIF.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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]))
Expand Down