chore: release v021.4
This commit is contained in:
@@ -279,6 +279,14 @@ const (
|
||||
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
||||
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
||||
|
||||
// Cloudlog and its fork Wavelog are self-hosted, so the instance URL is part
|
||||
// of the configuration (there is no fixed endpoint).
|
||||
keyExtCloudlogURL = "extsvc.cloudlog.url"
|
||||
keyExtCloudlogAPIKey = "extsvc.cloudlog.api_key"
|
||||
keyExtCloudlogStationID = "extsvc.cloudlog.station_id"
|
||||
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
||||
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
||||
|
||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||
|
||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||
@@ -8512,7 +8520,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
@@ -8582,6 +8592,13 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
out.EQSL.Username = p.Callsign
|
||||
}
|
||||
}
|
||||
out.Cloudlog = extsvc.ServiceConfig{
|
||||
URL: m[keyExtCloudlogURL],
|
||||
APIKey: m[keyExtCloudlogAPIKey],
|
||||
StationID: m[keyExtCloudlogStationID],
|
||||
AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8639,6 +8656,17 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if cfg.EQSL.AutoUpload {
|
||||
eqAuto = "1"
|
||||
}
|
||||
// Cloudlog has no per-QSO upload-status column in the logbook, so the
|
||||
// on-close sweep (which selects by that column) cannot apply — force any
|
||||
// stored on_close back to immediate rather than silently doing nothing.
|
||||
clgMode := modeOf(cfg.Cloudlog.UploadMode)
|
||||
if clgMode == string(extsvc.ModeOnClose) {
|
||||
clgMode = string(extsvc.ModeImmediate)
|
||||
}
|
||||
clgAuto := "0"
|
||||
if cfg.Cloudlog.AutoUpload {
|
||||
clgAuto = "1"
|
||||
}
|
||||
scope := a.profileScope() // write under the active profile's prefix
|
||||
for k, v := range map[string]string{
|
||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||
@@ -8674,6 +8702,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
||||
keyExtEQSLAutoUpload: eqAuto,
|
||||
keyExtEQSLUploadMode: eqMode,
|
||||
|
||||
keyExtCloudlogURL: strings.TrimSpace(cfg.Cloudlog.URL),
|
||||
keyExtCloudlogAPIKey: strings.TrimSpace(cfg.Cloudlog.APIKey),
|
||||
keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID),
|
||||
keyExtCloudlogAutoUpload: clgAuto,
|
||||
keyExtCloudlogUploadMode: clgMode,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
@@ -8712,6 +8746,12 @@ func (a *App) TestEQSLUpload() (string, error) {
|
||||
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
||||
}
|
||||
|
||||
// TestCloudlogUpload checks the Cloudlog/Wavelog URL, API key and station id
|
||||
// against the user's own instance, without inserting anything.
|
||||
func (a *App) TestCloudlogUpload() (string, error) {
|
||||
return extsvc.TestCloudlog(a.ctx, nil, a.loadExternalServices().Cloudlog)
|
||||
}
|
||||
|
||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||
|
||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||
@@ -9999,6 +10039,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceCloudlog:
|
||||
// No per-QSO status column for Cloudlog: it dedupes server-side (its API
|
||||
// checks each incoming record against the log), so re-sending is harmless
|
||||
// and every QSO is eligible. The cost is that a permanently failed upload
|
||||
// is not remembered — hence no on-close mode and no manual backlog.
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||
if strings.EqualFold(q.LOTWSent, f) {
|
||||
@@ -10034,6 +10080,9 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
||||
case extsvc.ServiceEQSL:
|
||||
err = a.qso.MarkEQSLSent(ctx, id, date)
|
||||
case extsvc.ServiceCloudlog:
|
||||
// Nothing to stamp — see extShouldUpload. Still logged and announced so
|
||||
// the upload is visible in the diagnostic log and the UI.
|
||||
}
|
||||
if err != nil {
|
||||
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||
|
||||
+9
-8
@@ -18,14 +18,15 @@ const (
|
||||
// sensitiveSettingKeys are the password fields encrypted at rest when the user
|
||||
// sets a passphrase. Everything else stays plaintext.
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
keyQRZPassword: true,
|
||||
keyHQPassword: true,
|
||||
keyEmailPassword: true,
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyQRZPassword: true,
|
||||
keyHQPassword: true,
|
||||
keyEmailPassword: true,
|
||||
keyExtClublogPassword: true,
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyExtCloudlogAPIKey: true,
|
||||
}
|
||||
|
||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"date": "2026-07-26",
|
||||
"en": [
|
||||
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
|
||||
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
|
||||
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 1–2 minutes so a mistake can still be corrected.",
|
||||
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
|
||||
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
|
||||
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
|
||||
@@ -12,6 +14,8 @@
|
||||
],
|
||||
"fr": [
|
||||
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
|
||||
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
|
||||
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
|
||||
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
|
||||
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
|
||||
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
@@ -1178,7 +1178,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
});
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||
@@ -1223,20 +1222,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
type ExtServiceCfg = {
|
||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||
code: string; qth_nickname: string;
|
||||
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
|
||||
force_station_callsign: string;
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flags: ['N', 'R'], write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -1299,12 +1299,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
};
|
||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [eqslTesting, setEqslTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
// Active tab in the External Services panel — lifted here because
|
||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | '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);
|
||||
@@ -3756,6 +3758,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||
{ k: 'pota', label: 'POTA', ready: true },
|
||||
];
|
||||
|
||||
@@ -3825,6 +3828,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
}
|
||||
|
||||
const cloudlog = extSvc.cloudlog;
|
||||
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
|
||||
|
||||
async function testCloudlog() {
|
||||
setCloudlogTesting(true);
|
||||
setCloudlogTest(null);
|
||||
try {
|
||||
// Save first: the backend test reads the stored config, so it must see
|
||||
// what was just typed (same as the other services' Test buttons).
|
||||
await SaveExternalServices(extSvc as any);
|
||||
const msg = await TestCloudlogUpload();
|
||||
setCloudlogTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setCloudlogTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const eqsl = extSvc.eqsl;
|
||||
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||
@@ -4077,6 +4100,74 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'cloudlog' ? (
|
||||
<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.cloudlogUrl')}</Label>
|
||||
<Input
|
||||
value={cloudlog.url}
|
||||
onChange={(e) => setCloudlog({ url: e.target.value })}
|
||||
placeholder={t('es.cloudlogUrlPh')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={cloudlog.api_key}
|
||||
onChange={(e) => setCloudlog({ api_key: e.target.value })}
|
||||
placeholder={t('es.cloudlogKeyPh')}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
|
||||
<Input
|
||||
value={cloudlog.station_id}
|
||||
onChange={(e) => setCloudlog({ station_id: e.target.value })}
|
||||
placeholder="1"
|
||||
className="font-mono text-xs w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||
{t('es.cloudlogHint')}
|
||||
</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={cloudlog.auto_upload}
|
||||
onCheckedChange={(c) => setCloudlog({ 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={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||||
onValueChange={(v) => setCloudlog({ 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>
|
||||
{/* No "on close" option: unlike the other services, Cloudlog has no
|
||||
per-QSO status column in the logbook, so there is nothing to
|
||||
select a backlog from at shutdown. */}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
|
||||
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
|
||||
</Button>
|
||||
{cloudlogTest && (
|
||||
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
|
||||
{cloudlogTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'eqsl' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
@@ -4992,15 +5083,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('em.username')}</Label>
|
||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<div className="relative">
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
||||
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
disabled={!emailCfg.auth}>
|
||||
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* No reveal button: the settings window is often open while the
|
||||
screen is shared or shown to someone in the shack. */}
|
||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||
|
||||
@@ -273,7 +273,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.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.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',
|
||||
@@ -656,7 +656,7 @@ const fr: Dict = {
|
||||
'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).",
|
||||
'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.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.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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.21.3';
|
||||
export const APP_VERSION = '021.4';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -914,6 +914,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestEQSLUpload():Promise<string>;
|
||||
|
||||
@@ -1778,6 +1778,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
|
||||
export function TestClublogUpload() {
|
||||
return window['go']['main']['App']['TestClublogUpload']();
|
||||
}
|
||||
|
||||
@@ -1184,6 +1184,8 @@ export namespace extsvc {
|
||||
|
||||
export class ServiceConfig {
|
||||
api_key: string;
|
||||
url: string;
|
||||
station_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -1206,6 +1208,8 @@ export namespace extsvc {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.api_key = source["api_key"];
|
||||
this.url = source["url"];
|
||||
this.station_id = source["station_id"];
|
||||
this.email = source["email"];
|
||||
this.username = source["username"];
|
||||
this.password = source["password"];
|
||||
@@ -1228,6 +1232,7 @@ export namespace extsvc {
|
||||
lotw: ServiceConfig;
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -1240,6 +1245,7 @@ export namespace extsvc {
|
||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
|
||||
// fixed endpoint: the user gives the base URL of their own instance and we
|
||||
// append the API path. Both expose the SAME contract — an ADIF record wrapped
|
||||
// in JSON — which is why one uploader serves both.
|
||||
//
|
||||
// POST <base>/index.php/api/qso
|
||||
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
|
||||
//
|
||||
// The station profile id is NOT optional: Cloudlog files the QSO under one of
|
||||
// the account's station locations, and a wrong id silently lands the contact in
|
||||
// someone else's log slot.
|
||||
const cloudlogAPIPath = "index.php/api/qso"
|
||||
|
||||
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
|
||||
// paste the dashboard URL, the API URL, with or without a trailing slash or
|
||||
// index.php, so normalise all of it rather than make them guess the exact form.
|
||||
func cloudlogEndpoint(base string) (string, error) {
|
||||
u := strings.TrimSpace(base)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("cloudlog: URL not set")
|
||||
}
|
||||
// A bare host or IP is almost always meant as http:// on a LAN instance;
|
||||
// requiring the scheme just produces a confusing "unsupported protocol".
|
||||
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
// Trim anything the user copied past the site root, so both
|
||||
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
|
||||
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
|
||||
if strings.HasSuffix(strings.ToLower(u), suffix) {
|
||||
u = u[:len(u)-len(suffix)]
|
||||
u = strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return u + "/" + cloudlogAPIPath, nil
|
||||
}
|
||||
|
||||
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
|
||||
type cloudlogRequest struct {
|
||||
Key string `json:"key"`
|
||||
StationID string `json:"station_profile_id"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogReply covers the documented failure shape
|
||||
// ({"status":"failed","reason":"missing api key"}); success replies vary
|
||||
// between versions, so success is judged on the HTTP status plus the ABSENCE
|
||||
// of a failure marker rather than on a field that may not be there.
|
||||
type cloudlogReply struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogPost sends one JSON body and returns the trimmed response.
|
||||
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
|
||||
}
|
||||
// Content-Type is what most "wrong JSON" reports come down to: without it
|
||||
// Cloudlog falls back to form-decoding and never sees the fields.
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// cloudlogReason turns a reply into a human-readable failure reason, or ""
|
||||
// when the reply looks like a success.
|
||||
func cloudlogReason(body string, status int) string {
|
||||
var r cloudlogReply
|
||||
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
|
||||
if r.Reason != "" {
|
||||
return r.Reason
|
||||
}
|
||||
return "rejected"
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
// The documented 401 body is {"status":"failed","reason":"missing api key"},
|
||||
// but a reverse proxy in front of the instance can swallow it.
|
||||
return "API key refused"
|
||||
case status == http.StatusNotFound:
|
||||
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
|
||||
case status >= 400:
|
||||
msg := body
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", status)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
|
||||
//
|
||||
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
|
||||
// retry of an already-accepted QSO is harmless — the upload stays idempotent
|
||||
// without OpsLog having to track it.
|
||||
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
if strings.TrimSpace(adifRecord) == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
|
||||
}
|
||||
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: adifRecord,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{OK: false, Message: body}, err
|
||||
}
|
||||
// The endpoint is echoed in both outcomes: a self-hosted instance means the
|
||||
// URL itself is a prime suspect, and a log line naming what was actually
|
||||
// called settles it without the operator having to guess how we normalised
|
||||
// what they typed.
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason},
|
||||
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
|
||||
}
|
||||
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
|
||||
}
|
||||
|
||||
// TestCloudlog validates URL, API key and station ID with a REAL request.
|
||||
//
|
||||
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
|
||||
// checked by the server before the record is parsed, so a bad key or id fails
|
||||
// exactly as it would for a real QSO, while nothing is inserted.
|
||||
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return "", fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: "",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return "", fmt.Errorf("cloudlog: %s", reason)
|
||||
}
|
||||
return fmt.Sprintf("Connected — station profile %s", station), nil
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||||
// only the instance URL differs, so one service handles both.
|
||||
ServiceCloudlog Service = "cloudlog"
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -63,6 +66,8 @@ const (
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
@@ -83,6 +88,8 @@ type ServiceConfig struct {
|
||||
// mode (defaults to immediate).
|
||||
func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||
c.URL = strings.TrimSpace(c.URL)
|
||||
c.StationID = strings.TrimSpace(c.StationID)
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||
c.Code = strings.TrimSpace(c.Code)
|
||||
@@ -115,11 +122,12 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
|
||||
// ExternalServices bundles every service's config for the settings UI.
|
||||
type ExternalServices struct {
|
||||
QRZ ServiceConfig `json:"qrz"`
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
QRZ ServiceConfig `json:"qrz"`
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||
cfg.EQSL = cfg.EQSL.normalised()
|
||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||
m.cfg = cfg
|
||||
|
||||
// Summary of what is armed, written at startup and on every settings save.
|
||||
// It answers "is the service even switched on for this profile?" — the
|
||||
// settings are per-profile, so a service configured under another profile
|
||||
// looks enabled in the UI of the one and silent in the other.
|
||||
var on []string
|
||||
for _, s := range []struct {
|
||||
name string
|
||||
cfg ServiceConfig
|
||||
}{
|
||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||
} {
|
||||
if s.cfg.AutoUpload {
|
||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||
}
|
||||
}
|
||||
if len(on) == 0 {
|
||||
m.logf("extsvc: auto-upload disabled for every service")
|
||||
} else {
|
||||
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the current snapshot.
|
||||
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||
m.route(ServiceEQSL, id, e)
|
||||
}
|
||||
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
|
||||
if c := cfg.Cloudlog; c.AutoUpload {
|
||||
// Say WHY nothing happens when the toggle is on but a field is missing.
|
||||
// Without this the whole path was silent — the operator saw no upload and
|
||||
// no log line, with no way to tell "disabled" from "broken".
|
||||
var missing []string
|
||||
if c.URL == "" {
|
||||
missing = append(missing, "URL")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
missing = append(missing, "API key")
|
||||
}
|
||||
if c.StationID == "" {
|
||||
missing = append(missing, "station ID")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
|
||||
strings.Join(missing, ", "), id)
|
||||
} else {
|
||||
m.route(ServiceCloudlog, id, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||
out = append(out, ServiceEQSL)
|
||||
}
|
||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||
out = append(out, ServiceCloudlog)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceCloudlog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
}
|
||||
}
|
||||
|
||||
// One line per attempt, BEFORE the request: a failure that never returns
|
||||
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
|
||||
// all, and "did it even try?" is the first question when an upload is missing.
|
||||
m.logf("extsvc: %s uploading QSO %d…", svc, id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||
case ServiceCloudlog:
|
||||
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
|
||||
// so the ADIF keeps the QSO's own station call (no override).
|
||||
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 = UploadCloudlog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, true // transient (rate-limit / network) → worth a retry
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.3"
|
||||
appVersion = "021.4"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user