chore: release v021.4

This commit is contained in:
2026-07-27 00:16:17 +02:00
parent aefb984974
commit cb27aa5ebf
13 changed files with 457 additions and 35 deletions
+101 -16
View File
@@ -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>
+2 -2
View File
@@ -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 (12 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 (12 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é (12 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é (12 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 -1
View File
@@ -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';
+2
View File
@@ -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>;
+4
View File
@@ -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']();
}
+6
View File
@@ -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 {