Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 |
@@ -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"
|
||||
@@ -2851,7 +2857,9 @@ func (a *App) RestartApp() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
cmd := exec.Command(exe)
|
||||
// --relaunch: the child waits for OUR mutex instead of declaring us a
|
||||
// duplicate — this instance is quitting, just not always fast enough.
|
||||
cmd := exec.Command(exe, "--relaunch")
|
||||
cmd.Dir = filepath.Dir(exe)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||
@@ -11175,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
|
||||
}
|
||||
@@ -11259,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
|
||||
}
|
||||
|
||||
@@ -11333,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"
|
||||
@@ -11388,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
|
||||
@@ -11416,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)
|
||||
@@ -11475,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)
|
||||
@@ -11490,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()
|
||||
@@ -11714,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.
|
||||
@@ -11755,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)
|
||||
@@ -13498,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) {
|
||||
@@ -13518,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) {
|
||||
@@ -13565,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
[
|
||||
{
|
||||
"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.",
|
||||
"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.",
|
||||
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county."
|
||||
],
|
||||
"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.",
|
||||
"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.",
|
||||
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.5",
|
||||
"date": "",
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExternalServices>({
|
||||
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<string[]>([]);
|
||||
@@ -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<ExtServiceCfg>) =>
|
||||
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<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||||
@@ -6376,6 +6394,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'hamqth' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.username')}</Label>
|
||||
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
|
||||
<Label className="text-sm">{t('es.hamqthCall')}</Label>
|
||||
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
|
||||
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
|
||||
</Button>
|
||||
{hamqthTest && (
|
||||
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'cloudlog' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+2
@@ -1334,6 +1334,8 @@ export function TestEmail(arg1:string):Promise<void>;
|
||||
|
||||
export function TestHRDLogUpload():Promise<string>;
|
||||
|
||||
export function TestHamQTHUpload():Promise<string>;
|
||||
|
||||
export function TestLoTWUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
|
||||
@@ -443,5 +443,11 @@ func adifCounty(state, county string) string {
|
||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||
return c
|
||||
}
|
||||
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
|
||||
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
|
||||
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
|
||||
if len(s) != 2 {
|
||||
return c
|
||||
}
|
||||
return strings.ToUpper(s) + "," + c
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <insert>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, "<session_id>") {
|
||||
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
|
||||
}
|
||||
if i := strings.Index(s, "<error>"); i >= 0 {
|
||||
e := s[i+len("<error>"):]
|
||||
if j := strings.Index(e, "</error>"); j >= 0 {
|
||||
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
|
||||
// they were already uploaded OR outside the callsign certificate's date range.
|
||||
// Reporting either as success is how a contact came to be stamped "uploaded"
|
||||
// while LoTW had never seen it.
|
||||
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
|
||||
// subdivisions are the US county enumeration — "XX,County" with a two-letter
|
||||
// state — and TQSL rejects the whole record over anything else, so a Canadian
|
||||
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
|
||||
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
|
||||
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
|
||||
|
||||
func scrubMyCnty(adif string) string {
|
||||
for {
|
||||
loc := myCntyRe.FindStringSubmatchIndex(adif)
|
||||
if loc == nil {
|
||||
return adif
|
||||
}
|
||||
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
|
||||
end := loc[1] + n
|
||||
if end > len(adif) {
|
||||
end = len(adif)
|
||||
}
|
||||
val := adif[loc[1]:end]
|
||||
if len(val) > 3 && val[2] == ',' {
|
||||
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
|
||||
rest := scrubMyCnty(adif[end:])
|
||||
return adif[:end] + rest
|
||||
}
|
||||
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
|
||||
}
|
||||
}
|
||||
|
||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||
adifRecord = scrubMyCnty(adifRecord)
|
||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||
loc := strings.TrimSpace(cfg.StationLocation)
|
||||
switch {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
|
||||
// validation is the US "XX,County" enumeration — so anything else must be
|
||||
// stripped before signing, and the US shape must survive untouched.
|
||||
func TestScrubMyCnty(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
|
||||
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
|
||||
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
|
||||
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
|
||||
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
|
||||
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := scrubMyCnty(c.in); got != c.want {
|
||||
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ func hasFlag(args []string, flag string) bool {
|
||||
}
|
||||
|
||||
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
||||
// previous instance may still be shutting down and holding the mutex, so retry for
|
||||
// a few seconds until it frees.
|
||||
func acquireInstance(postUpdate bool) bool {
|
||||
// try (fail → another OpsLog is running, so exit). On a --post-update or
|
||||
// --relaunch start the previous instance may still be shutting down and holding
|
||||
// the mutex, so retry for a few seconds until it frees.
|
||||
func acquireInstance(wait bool) bool {
|
||||
if acquireSingleInstance() {
|
||||
return true
|
||||
}
|
||||
if !postUpdate {
|
||||
if !wait {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
@@ -90,7 +90,11 @@ func main() {
|
||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||
bootLogLaunch()
|
||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||
if !acquireInstance(postUpdate) {
|
||||
// A self-relaunch (database switch) races its own parent: the new process
|
||||
// regularly wins the start against the old one's teardown, and the operator
|
||||
// got "OpsLog is already running" for following instructions. Same patience
|
||||
// as post-update.
|
||||
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
|
||||
// SAID, not merely done. This exit is correct — a second instance would
|
||||
// fight the first over the rig — but it happened in total silence: no
|
||||
// window, no data folder, no log, which is indistinguishable from a
|
||||
|
||||
Reference in New Issue
Block a user