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 },