diff --git a/app.go b/app.go index 7a94f65..3808662 100644 --- a/app.go +++ b/app.go @@ -382,6 +382,10 @@ const ( keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload" keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode" + keyExtHamlogAPIKey = "extsvc.hamlog.api_key" + keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload" + keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode" + keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" @@ -10701,7 +10705,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir, keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, - keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote) + keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote, + keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode) if err != nil { return out } @@ -10780,6 +10785,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { AutoUpload: m[keyExtCloudlogAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]), } + out.Hamlog = extsvc.ServiceConfig{ + APIKey: m[keyExtHamlogAPIKey], + AutoUpload: m[keyExtHamlogAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]), + } return out } @@ -10848,6 +10858,16 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if cfg.Cloudlog.AutoUpload { clgAuto = "1" } + // Same reasoning as Cloudlog: HAMLOG has no per-QSO upload-status column to + // select on, so an on-close sweep would have nothing to sweep. + hamMode := modeOf(cfg.Hamlog.UploadMode) + if hamMode == string(extsvc.ModeOnClose) { + hamMode = string(extsvc.ModeImmediate) + } + hamAuto := "0" + if cfg.Hamlog.AutoUpload { + hamAuto = "1" + } delRemote := "0" if cfg.DeleteRemote { delRemote = "1" @@ -10895,6 +10915,10 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID), keyExtCloudlogAutoUpload: clgAuto, keyExtCloudlogUploadMode: clgMode, + + keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey), + keyExtHamlogAutoUpload: hamAuto, + keyExtHamlogUploadMode: hamMode, } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err @@ -20350,3 +20374,16 @@ func (a *App) countryFor(call string) string { } return "" } + +// CheckHamlogKey validates a HAMLOG.online API key and returns the callsign it +// belongs to. +// +// Its own binding rather than part of a save: the answer names the ACCOUNT, so +// an operator who pasted a club's key, or one that expired last month, learns it +// while looking at the field they just filled in — not from QSOs that quietly +// never arrive. +func (a *App) CheckHamlogKey(key string) (string, error) { + ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second) + defer cancel() + return extsvc.CheckHamlogKey(ctx, nil, key) +} diff --git a/appearance.go b/appearance.go index fa25aaa..b2f25ed 100644 --- a/appearance.go +++ b/appearance.go @@ -19,7 +19,7 @@ type RowColorRule struct { ID string `json:"id"` Color string `json:"color"` Enabled bool `json:"enabled"` - // Channels this rule looks at: qsl (paper), lotw, eqsl, qrz. + // Channels this rule looks at: qsl (paper), lotw, eqsl, qrz, hamlog. // // Empty means ALL of them — what an operator expects from a rule they have // not narrowed, and what keeps a config written before this field meaningful. @@ -27,7 +27,7 @@ type RowColorRule struct { } // The QSL channels a rule can be scoped to. -var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"} +var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz", "hamlog"} // RowColorSettings is the whole appearance block. type RowColorSettings struct { diff --git a/changelog.json b/changelog.json index b34d508..c6503ba 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "Yaesu: an ANT row in the rig panel selects the antenna jack, and the choice is remembered for the band it was made on — change band and the antenna follows. There is no page to configure: picking the antenna once on a band says it perfectly well. Rigs with a single socket never show the row.", "HAMLOG.online: QSOs can be uploaded as they are logged, like QRZ.com or Club Log. The API key is checked in the settings before the first contact — the answer names the account it belongs to, so a key pasted from another callsign or an expired one is caught at once instead of after a week of silent refusals.", "HAMLOG.online is also a confirmation source for awards, alongside LoTW, QSL, eQSL and QRZ.com — tick it under Confirmed, under Validated, or both.", - "A rig whose serial port is refused now says who is likely holding it — OmniRig stays resident and keeps the port of the rig configured in it, which is what a native backend then never gets. \"Serial port busy\" alone named nobody." + "A rig whose serial port is refused now says who is likely holding it — OmniRig stays resident and keeps the port of the rig configured in it, which is what a native backend then never gets. \"Serial port busy\" alone named nobody.", + "The row colours in Appearance can be scoped to HAMLOG.online as well, alongside paper, LoTW, eQSL and QRZ.com." ], "fr": [ "Yaesu : une ligne ANT dans le panneau du poste sélectionne la prise d'antenne, et le choix est mémorisé pour la bande sur laquelle il a été fait — on change de bande, l'antenne suit. Aucune page à configurer : choisir l'antenne une fois sur une bande le dit très bien. Les postes à une seule prise n'affichent jamais la ligne.", "HAMLOG.online : les QSO peuvent être envoyés au fil de leur enregistrement, comme QRZ.com ou Club Log. La clé API se vérifie dans les réglages avant le premier contact — la réponse nomme le compte auquel elle appartient, donc une clé d'un autre indicatif ou expirée se voit tout de suite au lieu d'après une semaine de refus silencieux.", "HAMLOG.online est aussi une source de confirmation pour les diplômes, aux côtés de LoTW, QSL, eQSL et QRZ.com — à cocher dans Confirmé, dans Validé, ou les deux.", - "Un poste dont le port série est refusé indique maintenant qui le détient vraisemblablement — OmniRig reste résident et garde le port de la radio configurée chez lui, que le backend natif n'obtient alors jamais. « Serial port busy » ne désignait personne." + "Un poste dont le port série est refusé indique maintenant qui le détient vraisemblablement — OmniRig reste résident et garde le port de la radio configurée chez lui, que le backend natif n'obtient alors jamais. « Serial port busy » ne désignait personne.", + "Les couleurs de lignes dans Apparence peuvent aussi être limitées à HAMLOG.online, aux côtés du papier, de LoTW, d'eQSL et de QRZ.com." ] }, { diff --git a/frontend/src/components/AppearancePanel.tsx b/frontend/src/components/AppearancePanel.tsx index 0c6e05f..6cee9e5 100644 --- a/frontend/src/components/AppearancePanel.tsx +++ b/frontend/src/components/AppearancePanel.tsx @@ -29,9 +29,9 @@ const LABELS: Record = { // The channels a rule can be scoped to. "worked" is the catch-all — it means // nothing on ANY channel — so narrowing it would say nothing. -const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const; +const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const; const CHANNEL_LABELS: Record = { - qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', + qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online', }; // MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index f873885..dd1acfa 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -13,7 +13,7 @@ import { ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotorPresets, SaveRotorPresets, ResetRotorPresets, - GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, + GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, GetAntGeniusSettings, SaveAntGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings, GetPSUSettings, SavePSUSettings, @@ -63,7 +63,7 @@ import { import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models'; -import { EventsOn } from '../../wailsjs/runtime/runtime'; +import { EventsOn, BrowserOpenURL } from '../../wailsjs/runtime/runtime'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, @@ -1730,7 +1730,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // HRDLog only: publish the live frequency/mode/rig on hrdlog.net. on_air?: boolean; }; - type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean }; const emptyExtCfg = (): ExtServiceCfg => ({ api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', @@ -1738,7 +1738,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan auto_upload: false, upload_mode: 'immediate', on_air: false, }); const [extSvc, setExtSvc] = useState({ - qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false, + qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false, }); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTesting, setQrzTesting] = useState(false); @@ -1872,6 +1872,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [hrdlogTesting, setHrdlogTesting] = useState(false); const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [cloudlogTesting, setCloudlogTesting] = useState(false); + const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [hamlogTesting, setHamlogTesting] = useState(false); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTesting, setEqslTesting] = useState(false); const [stationLocations, setStationLocations] = useState([]); @@ -1879,7 +1881,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // could not hold hooks at all; PanelHost lifted that restriction, and this // stays put because moving it down would reset the tab on every reopen — // a choice now, not a workaround. - const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz'); + const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz'); // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). const [potaToken, setPotaToken] = useState(''); const [potaBusy, setPotaBusy] = useState(false); @@ -5420,6 +5422,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan { k: 'eqsl', label: 'EQSL', ready: true }, { k: 'lotw', label: 'LOTW', ready: true }, { k: 'cloudlog', label: 'CLOUDLOG', ready: true }, + { k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true }, { k: 'pota', label: 'POTA', ready: true }, ]; @@ -5489,6 +5492,30 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } } + const hamlog = extSvc.hamlog ?? emptyExtCfg(); + const setHamlog = (patch: Partial) => + setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } })); + + // Checking the KEY, not the connection. + // + // HAMLOG answers KEYSTATUS with the callsign the key belongs to, which no + // other service here does — so the answer is shown as the callsign rather + // than a bare "OK". A key pasted from a club account or a second station is + // then visible immediately, which is exactly the mistake a green tick would + // have hidden. + async function testHamlog() { + setHamlogTesting(true); + setHamlogTest(null); + try { + const call = await CheckHamlogKey(hamlog.api_key ?? ''); + setHamlogTest({ ok: true, msg: call ? t('es.hamlogKeyOkCall', { call }) : t('es.hamlogKeyOk') }); + } catch (e: any) { + setHamlogTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setHamlogTesting(false); + } + } + const cloudlog = extSvc.cloudlog; const setCloudlog = (patch: Partial) => setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } })); @@ -5790,6 +5817,60 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + ) : extSvcTab === 'hamlog' ? ( +
+
+ + setHamlog({ api_key: e.target.value })} + className="text-xs" + /> +
+
+ {t('es.hamlogHint')}{' '} + +
+ +
+ + +
+ + +
+ +
+ + {hamlogTest && ( + + {hamlogTest.msg} + + )} +
+
+
) : extSvcTab === 'cloudlog' ? (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 4257379..c857f73 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -356,7 +356,7 @@ const en: Dict = { 'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.", 'cat.ubOk': 'Connected — the antenna responded with a status frame.', // External services (repeated labels) - 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign', + 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign', // External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs 'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign", 'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password', @@ -834,7 +834,7 @@ const fr: Dict = { 'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).", 'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.", 'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.", - 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', + 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', // Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA 'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif", 'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log', diff --git a/frontend/src/lib/rowColors.ts b/frontend/src/lib/rowColors.ts index 9954d9e..fa31a11 100644 --- a/frontend/src/lib/rowColors.ts +++ b/frontend/src/lib/rowColors.ts @@ -26,7 +26,7 @@ export type RowColorSettings = { // otherwise keep striping underneath whatever we do per row. const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))'; -export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const; +export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const; // The two QSO fields behind each channel. QRZ.com and Club Log call theirs an // "upload status" rather than a QSL flag, but they carry the same Y / R letters. @@ -37,6 +37,24 @@ const FIELDS: Record = { qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' }, }; +// HAMLOG.online has no column of its own: the ADIF standard names a field for +// hamlog.EU and none for hamlog.ONLINE, so its state lives in the extras — see +// internal/award/award.go, which reads the same keys for award confirmations. +// One vocabulary, two readers. +const HAMLOG_SENT = 'APP_OPSLOG_HAMLOG_SENT'; +const HAMLOG_RCVD = ['APP_OPSLOG_HAMLOG_QSL', 'APP_HAMLOG_QSL', 'APP_HAMLOGONLINE_QSL', 'HAMLOG_QSL_RCVD']; + +// hamlogState reads a row's extras. Any value that is not an explicit "no" +// counts, because their export could carry a date or a match id rather than Y. +function hamlogState(q: any, which: 'sent' | 'rcvd'): boolean { + const e = (q?.extras ?? {}) as Record; + const keys = which === 'sent' ? [HAMLOG_SENT] : HAMLOG_RCVD; + return keys.some((k) => { + const v = String(e[k] ?? '').trim(); + return v !== '' && v.toUpperCase() !== 'N' && v.toUpperCase() !== 'NO'; + }); +} + // ADIF QSL fields are single letters. Y is the only one that means "yes"; // R (requested) and Q (queued) mean it has not gone out yet — a different state, // and the one an operator looks for when deciding what to send. @@ -55,14 +73,18 @@ function ruleMatches(q: any, r: RowColorRule): boolean { const cs = chansOf(r); switch (r.id) { case 'confirmed': - return cs.some((c) => yes(q[FIELDS[c]?.rcvd])); + return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'rcvd') : yes(q[FIELDS[c]?.rcvd]))); case 'sent': - return cs.some((c) => yes(q[FIELDS[c]?.sent])); + return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'sent') : yes(q[FIELDS[c]?.sent]))); case 'to_send': - return cs.some((c) => owed(q[FIELDS[c]?.sent])); + // Nothing to request from HAMLOG: a QSO is either uploaded or it is not, + // there is no "card asked for" state to be owed. + return cs.some((c) => c !== 'hamlog' && owed(q[FIELDS[c]?.sent])); case 'worked': // The catch-all: nothing sent, nothing asked for, nothing back. - return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent])); + return !CHANNELS.some((c) => (c === 'hamlog' + ? hamlogState(q, 'rcvd') || hamlogState(q, 'sent') + : yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]))); default: return false; } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ffd50c2..71b274b 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -117,6 +117,8 @@ export function ChatAvailable():Promise; export function CheckForUpdate():Promise; +export function CheckHamlogKey(arg1:string):Promise; + export function ClearLookupCache():Promise; export function CloseAutostartPrograms():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 46d14bd..24e4cf2 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -174,6 +174,10 @@ export function CheckForUpdate() { return window['go']['main']['App']['CheckForUpdate'](); } +export function CheckHamlogKey(arg1) { + return window['go']['main']['App']['CheckHamlogKey'](arg1); +} + export function ClearLookupCache() { return window['go']['main']['App']['ClearLookupCache'](); }