diff --git a/app.go b/app.go index d87cca0..8d589e1 100644 --- a/app.go +++ b/app.go @@ -401,6 +401,12 @@ const ( keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload" keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode" + keyExtHamqthUsername = "extsvc.hamqth.username" + keyExtHamqthPassword = "extsvc.hamqth.password" + keyExtHamqthCallsign = "extsvc.hamqth.callsign" + keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload" + keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode" + keyExtHamlogAPIKey = "extsvc.hamlog.api_key" keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload" keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode" @@ -11177,7 +11183,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote, - keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode) + keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode, + keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign, + keyExtHamqthAutoUpload, keyExtHamqthUploadMode) if err != nil { return out } @@ -11261,6 +11269,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { AutoUpload: m[keyExtHamlogAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]), } + out.HamQTH = extsvc.ServiceConfig{ + Username: m[keyExtHamqthUsername], + Password: m[keyExtHamqthPassword], + Callsign: m[keyExtHamqthCallsign], + AutoUpload: m[keyExtHamqthAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]), + } + // The callbook lookup already knows these credentials; blanks fall back to + // them so an operator who set up the lookup years ago is one checkbox away. + if out.HamQTH.Username == "" && out.HamQTH.Password == "" { + u, _ := a.settings.Get(a.ctx, keyHQUser) + p, _ := a.settings.Get(a.ctx, keyHQPassword) + out.HamQTH.Username = strings.TrimSpace(u) + out.HamQTH.Password = p + } return out } @@ -11335,6 +11358,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if hamMode == string(extsvc.ModeOnClose) { hamMode = string(extsvc.ModeImmediate) } + hqMode := modeOf(cfg.HamQTH.UploadMode) hamAuto := "0" if cfg.Hamlog.AutoUpload { hamAuto = "1" @@ -11390,6 +11414,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey), keyExtHamlogAutoUpload: hamAuto, keyExtHamlogUploadMode: hamMode, + keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username), + keyExtHamqthPassword: cfg.HamQTH.Password, + keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)), + keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload), + keyExtHamqthUploadMode: hqMode, } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err @@ -11418,6 +11447,13 @@ func (a *App) TestClublogUpload() (string, error) { return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) } +// TestHamQTHUpload checks the HamQTH credentials against the callbook login — +// authenticated, and unable to touch the log. +func (a *App) TestHamQTHUpload() (string, error) { + cfg := a.loadExternalServices().HamQTH + return extsvc.TestHamQTH(a.ctx, nil, cfg) +} + // TestHRDLogUpload validates that the HRDLog credentials are complete. func (a *App) TestHRDLogUpload() (string, error) { return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) @@ -11477,6 +11513,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) { if extsvc.Service(service) == extsvc.ServiceHamlog { return a.qso.ListMissingExtra(a.ctx, hamlogSentKey) } + if extsvc.Service(service) == extsvc.ServiceHamQTH { + return a.qso.ListMissingExtra(a.ctx, hamqthSentKey) + } col := uploadColumnFor(service) if col == "" { return nil, fmt.Errorf("unknown service %q", service) @@ -11492,7 +11531,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error { return fmt.Errorf("db not initialized") } svc := extsvc.Service(service) - if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog { + if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH { return fmt.Errorf("unknown service %q", service) } cfg := a.loadExternalServices() @@ -11716,6 +11755,43 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern } flush() } + } else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH { + // One record per request, extras-stamped services. Hamlog used to fall + // through to the QRZ branch below and got uploaded to the WRONG service + // with the QRZ key — the context menu offered what the loop never handled. + for i, id := range ids { + if i > 0 { + time.Sleep(manualUploadPace) + } + q, gerr := a.qso.GetByID(ctx, id) + call := "" + if gerr == nil { + call = q.Callsign + } + rec, ok := a.buildUploadADIF(id, "") + if !ok { + emit(call + " — skipped (no record)") + continue + } + var res extsvc.UploadResult + var err error + if svc == extsvc.ServiceHamlog { + res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec) + } else { + res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec) + } + if err == nil && res.OK { + a.markExtUploaded(svc, id, res.LogID) + uploaded++ + emit(call + " — OK") + } else { + msg := res.Message + if err != nil { + msg = err.Error() + } + emit(call + " — FAILED: " + msg) + } + } } else { // QRZ.com: one record per request (its logbook API has no batch upload), // paced for the same reason as HRDLog above. @@ -11757,6 +11833,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern label := map[extsvc.Service]string{ extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog", extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL", + extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH", }[svc] if label == "" { label = string(svc) @@ -13500,6 +13577,13 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { return false } return true + case extsvc.ServiceHamQTH: + // Same extras stamp as HAMLOG.online — ADIF names no HamQTH field. + if q.Extras != nil && strings.TrimSpace(q.Extras[hamqthSentKey]) != "" { + applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey]) + return false + } + return true case extsvc.ServiceLoTW: for _, f := range a.loadExternalServices().LoTW.UploadFlags { if strings.EqualFold(q.LOTWSent, f) { @@ -13520,6 +13604,8 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { const ( hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT" hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE" + hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT" + hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE" ) func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { @@ -13567,6 +13653,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil { err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date) } + case extsvc.ServiceHamQTH: + if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil { + err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date) + } } if err != nil { applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err) diff --git a/app_secret.go b/app_secret.go index c351fdc..63e66e1 100644 --- a/app_secret.go +++ b/app_secret.go @@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{ keyExtLoTWKeyPassword: true, keyExtLoTWWebPassword: true, keyExtHRDLogCode: true, + keyExtHamqthPassword: true, keyExtEQSLPassword: true, keyExtCloudlogAPIKey: true, // The web-publish config is one JSON blob and the FTP password lives inside diff --git a/changelog.json b/changelog.json index e10768c..fe67649 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,14 @@ "version": "0.27.6", "date": "", "en": [ - "Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it." + "Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.", + "HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).", + "Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online." ], "fr": [ - "Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse." + "Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.", + "Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).", + "Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online." ] }, { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index df33f8b..4a2a2db 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -42,6 +42,7 @@ const SERVICES = [ { v: 'eqsl', label: 'eQSL.cc' }, { v: 'lotw', label: 'LoTW' }, { v: 'hamlog', label: 'HAMLOG.online' }, + { v: 'hamqth', label: 'HamQTH' }, { v: 'pota', label: 'POTA hunter log' }, { v: 'paper', label: 'Paper QSL' }, ]; diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 2ae8747..f2235fb 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -35,6 +35,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [ { service: 'eqsl', name: 'eQSL.cc' }, { service: 'lotw', name: 'LoTW' }, { service: 'hamlog', name: 'HAMLOG.online' }, + { service: 'hamqth', name: 'HamQTH' }, ]; // Lightweight right-click menu for the QSO grids. AG Grid's native context diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index bd1d333..4f546f0 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -45,7 +45,7 @@ import { SetCIVTrace, CIVTraceEnabled, WinkeyerTraceEnabled, - GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, + GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, DownloadLoTWUsers, GetLoTWUsersStatus, @@ -1848,7 +1848,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged // 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; hamlog: ExtServiceCfg; delete_remote?: boolean }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: 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: '', @@ -1856,7 +1856,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged 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(), hamlog: emptyExtCfg(), delete_remote: false, + qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false, }); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTesting, setQrzTesting] = useState(false); @@ -2025,6 +2025,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const [cloudlogTesting, setCloudlogTesting] = useState(false); const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hamlogTesting, setHamlogTesting] = useState(false); + const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [hamqthTesting, setHamqthTesting] = useState(false); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTesting, setEqslTesting] = useState(false); const [stationLocations, setStationLocations] = useState([]); @@ -2032,7 +2034,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged // 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' | 'hamlog' | 'pota'>('qrz'); + const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | '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); @@ -5928,6 +5930,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged { k: 'lotw', label: 'LOTW', ready: true }, { k: 'cloudlog', label: 'CLOUDLOG', ready: true }, { k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true }, + { k: 'hamqth', label: 'HAMQTH', ready: true }, { k: 'pota', label: 'POTA', ready: true }, ]; @@ -5997,6 +6000,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged } } + const hamqth = extSvc.hamqth ?? emptyExtCfg(); + const setHamqth = (patch: Partial) => + setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } })); + async function testHamqth() { + setHamqthTesting(true); + setHamqthTest(null); + try { + const msg = await TestHamQTHUpload(); + setHamqthTest({ ok: true, msg }); + } catch (e: any) { + setHamqthTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setHamqthTesting(false); + } + } const hamlog = extSvc.hamlog ?? emptyExtCfg(); const setHamlog = (patch: Partial) => setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } })); @@ -6376,6 +6394,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged + ) : extSvcTab === 'hamqth' ? ( +
+
+ + setHamqth({ username: e.target.value })} className="text-xs w-64" /> + + setHamqth({ password: e.target.value })} className="text-xs w-64" /> + + setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} /> +
+
{t('es.hamqthHint')}
+ +
+ +
+ + +
+
+ + {hamqthTest && ( + {hamqthTest.msg} + )} +
+
+
) : extSvcTab === 'cloudlog' ? (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index dfc3207..37cc01c 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -389,7 +389,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.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', + 'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', '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', @@ -900,7 +900,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.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', + 'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez l’indicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', '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/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b3c9ff4..6afc4ab 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1334,6 +1334,8 @@ export function TestEmail(arg1:string):Promise; export function TestHRDLogUpload():Promise; +export function TestHamQTHUpload():Promise; + export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index d999978..f76b59e 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2606,6 +2606,10 @@ export function TestHRDLogUpload() { return window['go']['main']['App']['TestHRDLogUpload'](); } +export function TestHamQTHUpload() { + return window['go']['main']['App']['TestHamQTHUpload'](); +} + export function TestLoTWUpload() { return window['go']['main']['App']['TestLoTWUpload'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 907b2bd..392f106 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1522,6 +1522,7 @@ export namespace extsvc { eqsl: ServiceConfig; cloudlog: ServiceConfig; hamlog: ServiceConfig; + hamqth: ServiceConfig; delete_remote: boolean; static createFrom(source: any = {}) { @@ -1537,6 +1538,7 @@ export namespace extsvc { this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.hamlog = this.convertValues(source["hamlog"], ServiceConfig); + this.hamqth = this.convertValues(source["hamqth"], ServiceConfig); this.delete_remote = source["delete_remote"]; } diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 77fd60e..7b7c7a9 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -38,6 +38,9 @@ const ( ServiceCloudlog Service = "cloudlog" // ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO. ServiceHamlog Service = "hamlog" + // ServiceHamQTH is the HamQTH online logbook — the callbook credentials, + // one ADIF record per QSO. + ServiceHamQTH Service = "hamqth" ) // UploadMode selects when an auto-upload fires after a QSO is saved. @@ -133,6 +136,7 @@ type ExternalServices struct { EQSL ServiceConfig `json:"eqsl"` Cloudlog ServiceConfig `json:"cloudlog"` Hamlog ServiceConfig `json:"hamlog"` + HamQTH ServiceConfig `json:"hamqth"` // DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // it is deleted locally. Off unless the operator turns it on: neither diff --git a/internal/extsvc/hamqth.go b/internal/extsvc/hamqth.go new file mode 100644 index 0000000..0d45279 --- /dev/null +++ b/internal/extsvc/hamqth.go @@ -0,0 +1,129 @@ +package extsvc + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HamQTH real-time QSO upload. +// +// One POST per QSO to qso_realtime.php with the account username/password — +// the same credentials the HamQTH callbook lookup uses. The answer is the +// HTTP status code, not a body format: 200 saved, 400 rejected (bad band, +// duplicate…, reason in the body), 403 wrong credentials, 500 server error. +const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php" + +// hamqthLoginURL is the callbook session login — the one authenticated HamQTH +// endpoint that cannot change anything in the log, which is what the settings +// Test button must call. +const hamqthLoginURL = "https://www.hamqth.com/xml.php" + +// UploadHamQTH pushes one ADIF record to the HamQTH online logbook. +func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) { + return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord) +} + +func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) { + user := strings.TrimSpace(cfg.Username) + switch { + case user == "": + return UploadResult{}, fmt.Errorf("hamqth: username not set") + case cfg.Password == "": + return UploadResult{}, fmt.Errorf("hamqth: password not set") + } + rec := strings.TrimSpace(adifRecord) + if rec == "" { + return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record") + } + form := url.Values{} + form.Set("u", user) + form.Set("p", cfg.Password) + // c: the logbook callsign when the account holds several; empty means the + // account's own call, which is the common case. + if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" { + form.Set("c", c) + } + form.Set("adif", rec) + form.Set("prg", "OpsLog") + form.Set("cmd", "insert") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return UploadResult{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return UploadResult{}, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + msg := strings.TrimSpace(string(body)) + + switch resp.StatusCode { + case http.StatusOK: + return UploadResult{OK: true}, nil + case http.StatusBadRequest: + // "Rejected" covers duplicates too. A duplicate is a SUCCESS for our + // purposes — the QSO is in the logbook, retrying it forever isn't — + // same treatment HRDLog's 0 gets. + if strings.Contains(strings.ToLower(msg), "dupl") { + return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil + } + if msg == "" { + msg = "QSO rejected" + } + return UploadResult{OK: false, Message: msg}, nil + case http.StatusForbidden: + return UploadResult{}, fmt.Errorf("hamqth: wrong username or password") + default: + if msg != "" && len(msg) < 200 { + return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg) + } + return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode) + } +} + +// TestHamQTH verifies the credentials against the callbook session login — +// authenticated, and unable to touch the log. +func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { + user := strings.TrimSpace(cfg.Username) + if user == "" || cfg.Password == "" { + return "", fmt.Errorf("hamqth: set the username and password first") + } + q := url.Values{} + q.Set("u", user) + q.Set("p", cfg.Password) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil) + if err != nil { + return "", err + } + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + s := string(body) + if strings.Contains(s, "") { + return fmt.Sprintf("Connected to HamQTH as %s.", user), nil + } + if i := strings.Index(s, ""); i >= 0 { + e := s[i+len(""):] + if j := strings.Index(e, ""); j >= 0 { + return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j])) + } + } + return "", fmt.Errorf("hamqth: unexpected answer — check the username and password") +} diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index d7e4778..986168b 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -140,6 +140,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { cfg.EQSL = cfg.EQSL.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised() cfg.Hamlog = cfg.Hamlog.normalised() + cfg.HamQTH = cfg.HamQTH.normalised() m.cfg = cfg // Summary of what is armed, written at startup and on every settings save. @@ -153,7 +154,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { }{ {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, - {"hamlog", cfg.Hamlog}, + {"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH}, } { if s.cfg.AutoUpload { on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) @@ -237,6 +238,14 @@ func (m *Manager) OnQSOLogged(id int64) { m.route(ServiceHamlog, id, h) } } + // HamQTH — the callbook credentials double as the logbook login. + if h := cfg.HamQTH; h.AutoUpload { + if h.Username == "" || h.Password == "" { + m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id) + } else { + m.route(ServiceHamQTH, id, h) + } + } } // route sends a logged QSO down the configured timing path: queue it for the @@ -290,6 +299,9 @@ func (m *Manager) onCloseServices() []Service { if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" { out = append(out, ServiceHamlog) } + if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" { + out = append(out, ServiceHamQTH) + } return out } @@ -338,6 +350,8 @@ func (m *Manager) FlushOnClose() int { uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) case ServiceHamlog: uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog) + case ServiceHamQTH: + uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH) } } return uploaded @@ -577,7 +591,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret switch svc { case ServiceQRZ, ServiceLoTW: owner = cfg.ForceStationCallsign - case ServiceClublog, ServiceHRDLog: + case ServiceClublog, ServiceHRDLog, ServiceHamQTH: owner = cfg.Callsign case ServiceEQSL: owner = cfg.Username @@ -669,6 +683,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret return false, false } res, err = UploadHamlog(ctx, m.deps.Client, cfg, record) + case ServiceHamQTH: + // The c parameter names the logbook when the account holds several; + // the QSO keeps its own STATION_CALLSIGN in the ADIF. + record, ok := m.deps.BuildADIF(id, "") + if !ok { + m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) + return false, false + } + res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record) default: return false, false }