From 2cf6a47e5f3a1c014d8a53a26505ad9bf4ef5b1f Mon Sep 17 00:00:00 2001 From: Aldo Mendoza Date: Mon, 7 Oct 2024 22:21:40 -0700 Subject: [PATCH] Add support for SKCC logging --- package.json | 2 +- .../activities/skcc/SKCCExtension.jsx | 152 ++++++++++++++++++ src/extensions/activities/skcc/SKCCInfo.js | 9 ++ .../activities/skcc/SKCCMembershipData.js | 123 ++++++++++++++ src/extensions/loadExtensions.js | 2 + src/hooks/useDebounce.js | 17 ++ .../OpLoggingTab/components/LoggingPanel.jsx | 4 + .../LoggingPanel/MainExchangePanel.jsx | 2 +- src/store/db/createTables.js | 34 +++- src/tools/qsonToADIF.js | 1 + 10 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 src/extensions/activities/skcc/SKCCExtension.jsx create mode 100644 src/extensions/activities/skcc/SKCCInfo.js create mode 100644 src/extensions/activities/skcc/SKCCMembershipData.js create mode 100644 src/hooks/useDebounce.js diff --git a/package.json b/package.json index ac2c59b30..898385af7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "versionName": "October '24", "private": true, "scripts": { - "android": "react-native run-android", + "android": "react-native run-android --mode alphaDebug", "ios": "react-native run-ios", "lint": "eslint .", "start": "react-native start", diff --git a/src/extensions/activities/skcc/SKCCExtension.jsx b/src/extensions/activities/skcc/SKCCExtension.jsx new file mode 100644 index 000000000..813ac9fcf --- /dev/null +++ b/src/extensions/activities/skcc/SKCCExtension.jsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Button, Dialog, Text } from 'react-native-paper' +import { dbExecute } from '../../../store/db/db' +import { findRef } from '../../../tools/refTools' +import { Ham2kDialog } from '../../../screens/components/Ham2kDialog' +import { Info } from './SKCCInfo' +import { loadDataFile, removeDataFile } from "../../../store/dataFiles/actions/dataFileFS"; +import { registerSKCCMembershipData } from "./SKCCMembershipData"; +import { useDebounce } from "../../../hooks/useDebounce"; +import ThemedTextInput from '../../../screens/components/ThemedTextInput' + +const Extension = { + ...Info, + cateogry: 'other', + enabledByDefault: false, + alwaysEnabled: false, + onActivationDispatch: ({ registerHook }) => async (dispatch) => { + registerHook('activity', { hook: ActivityHook }) + registerHook(`ref:${Info.activationType}`, { hook: ReferenceHandler }) + registerSKCCMembershipData() + + await dispatch(loadDataFile('skcc-membership', { noticesInsteadOfFetch: true })) + }, + onDeactivationDispatch: () => async (dispatch) => { + await dispatch(removeDataFile('skcc-membership')) + } +} +export default Extension + +const ReferenceHandler = { + ...Info, + + suggestOperationTitle: () => { + return { title: 'Straight Key Century Club' } + } +} + +const ActivityHook = { + ...Info, + + mainExchangeForQSO: MainExchangeForQSO +} + +function MainExchangeForQSO({ qso, operation, themeColor, updateQSO, handleFieldChange }) { + const debouncedQso = useDebounce(qso) + const call = debouncedQso?.their?.call + const skcc = debouncedQso?.their?.skcc + const [proposedGuess, setProposedGuess] = useState() + const confirmedGuess = useRef() + + useEffect(() => { + if (!(call && call !== '') && !(skcc && skcc !== '')) { + return + } + + if (call === confirmedGuess?.current?.call && skcc === confirmedGuess?.current?.skccNr) { + return + } + + async function fetch() { + const result = call && call !== confirmedGuess?.current?.call + ? await dbExecute('SELECT * FROM skccMembers WHERE call = ?', [call]) + : await dbExecute('SELECT * FROM skccMembers WHERE skcc = ?', [skcc]) + if (!ignore) { + if (result.rows.length === 1) { + const row = result.rows.item(0) + setProposedGuess(row) + } + } + } + + let ignore = false + fetch() + return () => { + ignore = true + } + }, [call, skcc, confirmedGuess, setProposedGuess]) + + const handleLeaveAsIs = () => { + setProposedGuess(undefined) + } + const handleClearFields = () => { + confirmedGuess.current = undefined + setProposedGuess(undefined) + updateQSO({ their: { call: undefined, name: undefined, skcc: undefined, state: undefined, comment: undefined } }) + } + const handleOverwriteFields = () => { + confirmedGuess.current = proposedGuess + setProposedGuess(undefined) + const comment = `SKCC: ${proposedGuess?.skccNr} - ${proposedGuess?.name} - ${proposedGuess?.spc}` + updateQSO({ their: { call: proposedGuess?.call, name: proposedGuess?.name, skcc: proposedGuess?.skccNr, state: proposedGuess?.spc }, comment }) + } + + const fields = proposedGuess ? [ + + ] : [] + if (findRef(operation, Info.activationType)) { + fields.push( + + ) + fields.push( + + ) + } + + return fields +} + +function ConfirmClearSKCCFieldsDialog({ newFields, onLeaveAsIs, onOverwrite, onClear }) { + return ( + + Overwrite SKCC Fields + + Updating SKCC fields may overwrite already entered data for this QSOs + + + {`SKCC: ${newFields.skccNr}, Name: ${newFields.name}, SPC: ${newFields.spc}`} + + + + + + + + ) +} diff --git a/src/extensions/activities/skcc/SKCCInfo.js b/src/extensions/activities/skcc/SKCCInfo.js new file mode 100644 index 000000000..c3717b196 --- /dev/null +++ b/src/extensions/activities/skcc/SKCCInfo.js @@ -0,0 +1,9 @@ +export const Info = { + key: 'skcc', + icon: 'key-arrow-right', + name: 'Straight Key Centry Club', + shortName: 'SKCC', + infoURL: 'https://www.skccgroup.com/', + refType: 'skcc', + activationType: 'skccActivation' +} diff --git a/src/extensions/activities/skcc/SKCCMembershipData.js b/src/extensions/activities/skcc/SKCCMembershipData.js new file mode 100644 index 000000000..621bde04f --- /dev/null +++ b/src/extensions/activities/skcc/SKCCMembershipData.js @@ -0,0 +1,123 @@ +import { fmtNumber, fmtPercent } from '@ham2k/lib-format-tools' + +import { fetchAndProcessURL } from "../../../store/dataFiles/actions/dataFileFS" +import { registerDataFile } from '../../../store/dataFiles' +import { database, dbExecute } from '../../../store/db/db' + +export function registerSKCCMembershipData() { + registerDataFile({ + key: 'skcc-membership', + name: 'SKCC: Membership', + description: 'Database of all SKCC members', + infoURL: 'https://www.skccgroup.com/', + icon: 'file-key-outline', + maxAgeInDays: 2, + enabledByDefault: false, + fetch: async (args) => { + const { key, definition, options } = args + + options.onStatus && await options.onStatus({ key, definition, status: 'progress', progress: 'Downloading raw data' }) + + const url = 'https://www.skccgroup.com/membership_data/skccdata.txt' + + return await fetchAndProcessURL({ + ...args, + url, + process: async (body) => { + const lines = body.split('\n') + const headers = parseSKCCHeaders(lines.shift()) + + const db = await database() + await dbExecute('UPDATE skccMembers SET updated = 0') + + const inserts = lines + .map(line => parseSKCCRow(line, headers)) + .filter(row => row.SKCCNR && row.SKCCNR !== '') + .map(row => { + const joinDate = parseSKCCDate(row.JOINDATE) + const centDate = parseSKCCDate(row.CENTDATE) + const tribDate = parseSKCCDate(row.TRIBDATE) + const tx8Date = parseSKCCDate(row.TX8DATE) + const senDate = parseSKCCDate(row.SENDATE) + const query = ` + INSERT into skccMembers + (skcc, skccNr, call, name, qth, spc, oldCall, dxCode, joinDate, centDate, tribDate, tx8Date, senDate, dxEntity, updated) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + ON CONFLICT DO + UPDATE SET + skccNr = ?, call = ?, name = ?, qth = ?, spc = ?, oldCall = ?, dxCode = ?, joinDate = ?, centDate = ?, tribDate = ?, tx8Date = ?, senDate = ?, dxEntity = ?, updated = 1 + ` + const args = [row.SKCC, row.SKCCNR, row.CALL, row.NAME, row.QTH, row.SPC, row.OLDCALL, row.DXCODE, joinDate, centDate, tribDate, tx8Date, senDate, row.DXENTITY] + return { query, args } + }) + const totalMembers = inserts.length + + const startTime = Date.now() + let processedMembers = 0 + while (inserts.length > 0) { + await new Promise((resolve, reject) => { + db.transaction(transaction => { + inserts.splice(0, 797).forEach(({ query, args }) => { + transaction.executeSql(query, args, (_, resultSet) => { processedMembers += resultSet.rowsAffected }) + }) + }, (error) => { + reject(error) + }, () => { + resolve() + }) + }) + + options.onStatus && await options.onStatus({ + key, + definition, + status: 'progress', + progress: `Loaded \`${fmtNumber(processedMembers)}\` members.\n\n\`${fmtPercent(Math.min(processedMembers / totalMembers, 1), 'integer')}\` • ${fmtNumber((totalMembers - processedMembers) * ((Date.now() - startTime) / 1000) / processedMembers, 'oneDecimal')} seconds left.` + }) + } + + await dbExecute('DELETE FROM skccMembers WHERE updated = 0') + + return { totalMembers } + } + }) + }, + onRemove: async () => { + await dbExecute('DELETE FROM skccMembers') + } + }) +} + +function parseSKCCHeaders(row) { + if (!row) { + return [] + } + + return row.split('|') +} + +function parseSKCCRow(row, headers) { + const parts = row.split('|') + const obj = {} + + headers.forEach((column, index) => { + obj[column] = parts[index] + }) + + return obj +} + +function parseSKCCDate(date) { + if (!date) { + return + } + + const parts = date.split(' ') + if (parts.length !== 3) { + console.warn(`Unexpected SKCC date format: ${date}`) + return + } + + const [day, month, year] = parts + return [year, month, day].join('-') +} diff --git a/src/extensions/loadExtensions.js b/src/extensions/loadExtensions.js index d262366aa..dd69c7205 100644 --- a/src/extensions/loadExtensions.js +++ b/src/extensions/loadExtensions.js @@ -24,6 +24,7 @@ import CallNotesExtension from './data/call-notes/CallNotesExtension' import CallHistoryExtension from './data/call-history/CallHistoryExtension' import QRZExtension from './data/qrz/QRZExtension' import SatellitesExtension from './activities/satellites/SatellitesExtension' +import SKCCExtension from './activities/skcc/SKCCExtension' import RadioCommands from './commands/RadioCommands' import TimeCommands from './commands/TimeCommands' @@ -48,6 +49,7 @@ const loadExtensions = () => async (dispatch, getState) => { registerExtension(ECAExtension) registerExtension(ELAExtension) registerExtension(SiOTAExtentsion) + registerExtension(SKCCExtension) registerExtension(RadioCommands) registerExtension(TimeCommands) diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 000000000..5797e6229 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' + +export function useDebounce(value, delayMs = 500) { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebounced(value) + }, delayMs) + + return () => { + clearTimeout(handler) + } + }, [value, delayMs]) + + return debounced +} diff --git a/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel.jsx b/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel.jsx index 7351d7e08..39a0e806d 100644 --- a/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel.jsx +++ b/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel.jsx @@ -207,6 +207,10 @@ export default function LoggingPanel ({ style, operation, vfo, qsos, sections, a updateQSO({ startOnMillis: value, _manualTime: true }) } else if (fieldId === 'state') { updateQSO({ their: { state: value } }) + } else if (fieldId === 'name') { + updateQSO({ their: { name: value } }) + } else if (fieldId === 'skcc') { + updateQSO({ their: { skcc: value } }) } else if (fieldId === 'power') { updateQSO({ power: value }) if (qso?._isNew) dispatch(setVFO({ power: value })) diff --git a/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel/MainExchangePanel.jsx b/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel/MainExchangePanel.jsx index c2ba24fb9..6395baab8 100644 --- a/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel/MainExchangePanel.jsx +++ b/src/screens/OperationScreens/OpLoggingTab/components/LoggingPanel/MainExchangePanel.jsx @@ -134,7 +134,7 @@ export const MainExchangePanel = ({ findHooks('activity').filter(activity => activity.mainExchangeForQSO).forEach(activity => { fields = fields.concat( activity.mainExchangeForQSO( - { qso, operation, vfo, settings, styles, themeColor, onSubmitEditing, setQSO, updateQSO, onSpace: spaceHandler, refStack, focusedRef } + { qso, operation, vfo, settings, styles, themeColor, onSubmitEditing, setQSO, updateQSO, onSpace: spaceHandler, refStack, focusedRef, handleFieldChange } ) || [] ) }) diff --git a/src/store/db/createTables.js b/src/store/db/createTables.js index a59e4e290..0a8120325 100644 --- a/src/store/db/createTables.js +++ b/src/store/db/createTables.js @@ -61,7 +61,39 @@ export async function createTables (db) { await dbExecute('UPDATE version SET version = 2', [], { db }) } - if (version === 2) { + if (version < 3) { + console.log('createTables -- creating version 3') + /** + * skcc is the standalone SKCC number, e.g. 1234 + * skccNr is the SKCC number plus any (C, T, S) award earned, e.g. 1234T + */ + await dbExecute(` + CREATE TABLE IF NOT EXISTS skccMembers ( + skcc TEXT NOT NULL, + skccNr TEXT, + call TEXT, + name TEXT, + qth TEXT, + spc TEXT, + oldCall TEXT, + dxCode INTEGER, + joinDate DATE, + centDate DATE, + tribDate DATE, + tx8Date DATE, + senDate DATE, + dxEntity TEXT, + updated INTEGER, + PRIMARY KEY (skcc) + )`, [], { db }) + + await dbExecute(` + CREATE INDEX IF NOT EXISTS + idx_call ON skccMembers (call)`, [], { db }) + await dbExecute('UPDATE version SET version = 3', [], { db }) + } + + if (version === 3) { // console.log('createTables -- using version 1') } } diff --git a/src/tools/qsonToADIF.js b/src/tools/qsonToADIF.js index 5d7459303..ab6978bfd 100644 --- a/src/tools/qsonToADIF.js +++ b/src/tools/qsonToADIF.js @@ -103,6 +103,7 @@ function adifFieldsForOneQSO (qso, operation, common, timeOfffset = 0) { { STATION_CALLSIGN: qso.our.call ?? common.stationCall }, { OPERATOR: qso.our.operatorCall ?? common.operatorCall ?? qso.our.call ?? common.stationCall }, { NOTES: qso.notes }, + { COMMENT: qso.comment }, { COMMENTS: qso.notes }, { GRIDSQUARE: qso.their?.grid ?? qso.their?.guess?.grid }, { MY_GRIDSQUARE: qso?.our?.grid ?? common.grid },