Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc | ||
|
|
f665d666d6 | ||
|
|
5f9ee9efa2 | ||
|
|
44d276c841 | ||
|
|
e656c79589 | ||
|
|
86cbe94cae | ||
|
|
b9b005ea36 | ||
|
|
2c47c5d052 | ||
|
|
446888a330 | ||
|
|
128364260b | ||
|
|
daf38554a5 | ||
|
|
39f095ae75 | ||
|
|
ea8e954eef | ||
|
|
6db90abcad | ||
|
|
6b9307b427 | ||
|
|
ab06673854 | ||
|
|
1cadefd207 | ||
|
|
05fbb8a3bc | ||
|
|
3d88d8f50f | ||
|
|
5b37397a64 | ||
|
|
1184a675c2 | ||
|
|
7a24282aa4 | ||
|
|
701e8a2c25 | ||
|
|
c4ab935d5f | ||
|
|
be00f6a46d | ||
|
|
7b0a1ac832 | ||
|
|
06183bd5d4 | ||
|
|
fafa0c22ab | ||
|
|
4f32012930 | ||
|
|
a8b3269b1e | ||
|
|
25a53e4110 | ||
|
|
8cf53a0aa2 | ||
|
|
e112de5967 | ||
|
|
be48e58aa6 | ||
|
|
565c143536 | ||
|
|
2ce25339ab | ||
|
|
121ecae314 | ||
|
|
dd0a34dc0a | ||
|
|
45b9bcdea7 | ||
|
|
2d742be7df | ||
|
|
3a6afc28ac | ||
|
|
e590a58702 | ||
|
|
dd4b0004a5 | ||
|
|
abbdde9367 | ||
|
|
fda21e5a6f | ||
|
|
ee9fb51066 | ||
|
|
5bbcaab71a | ||
|
|
18438d7737 | ||
|
|
97e24ea24f | ||
|
|
3e199f9ab6 | ||
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e | ||
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab | ||
|
|
64f2d38d87 | ||
|
|
299184712a | ||
|
|
76c1e2df60 | ||
|
|
165f33caa5 | ||
|
|
464a1c702c | ||
|
|
19c5045dc6 | ||
|
|
fa09251039 | ||
|
|
d6626d96d0 | ||
|
|
8b831145ad | ||
|
|
81c60628c6 | ||
|
|
678787ec62 | ||
|
|
79dc20a859 | ||
|
|
824971d0a1 | ||
|
|
60bcd2422d | ||
|
|
9b0d7ce1dc | ||
|
|
572e8ca538 | ||
|
|
6ac9783f7c | ||
|
|
725600c341 | ||
|
|
5d9765be09 | ||
|
|
b302d4d87b | ||
|
|
8b7c42ec9b | ||
|
|
cde0add5e0 | ||
|
|
0e2ef317c3 |
+300
@@ -0,0 +1,300 @@
|
||||
# OpsLog
|
||||
|
||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||
concepteur de cartes QSL. Construit avec **Wails v2** (backend Go + frontend
|
||||
React/TypeScript), **100 % Go** (sans CGO) : SQLite pour la configuration,
|
||||
**MySQL partagé** optionnel pour le journal afin que plusieurs opérateurs
|
||||
partagent un même log. Entièrement thémable et bilingue (anglais / français).
|
||||
|
||||
Développé par **F4BPO**.
|
||||
|
||||
---
|
||||
|
||||
## Compilation / développement
|
||||
|
||||
- **Dev :** `wails dev` (rechargement à chaud Vite ; méthodes Go accessibles sur http://localhost:34115).
|
||||
- **Build :** `wails build` (utiliser la version wails v2.11 du projet — `~/go/bin/wails.exe`).
|
||||
- **Régénérer les bindings Go↔TS** après modification des méthodes `App`
|
||||
exportées : `wails generate module`.
|
||||
- **Release :** `.vscode/release.ps1` (Ctrl+Maj+P → *Tasks: Run Task* →
|
||||
*Release OpsLog*) — incrémente la version, pousse le source sur Gitea, compile
|
||||
l'exe et le publie sur les releases Gitea + GitHub.
|
||||
|
||||
---
|
||||
|
||||
## Journalisation
|
||||
|
||||
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||
nom/QTH/locator et onglet QRZ.com.
|
||||
- **Résolution DXCC hors ligne** depuis `cty.dat` (pays, zones CQ/ITU,
|
||||
continent), avec gestion des `/MM` `/AM` `/B` (balise) et des changements de
|
||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||
QSL.
|
||||
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||
sélectionnées.
|
||||
- **Recherche de doublons** (Outils) — regroupe les QSO par même indicatif +
|
||||
bande + mode (optionnellement même jour / minute) et permet de choisir lesquels
|
||||
supprimer.
|
||||
- **Conforme ADIF 3.1.7** en import/export : dictionnaire complet des champs,
|
||||
30 colonnes promues, éditeur générique de « champs supplémentaires » et modes
|
||||
d'export standard/complet.
|
||||
- **Profils :** chaque réglage est par profil ; chaque profil peut pointer son
|
||||
journal vers le fichier SQLite local ou une base **MySQL partagée**
|
||||
(multi-opérateur).
|
||||
|
||||
## Cartes & antenne
|
||||
|
||||
- **Vue principale = deux volets configurables** (par profil, Réglages → Général
|
||||
→ *Vue principale*) : carte grand-cercle, carte locator (rue), la grille du
|
||||
cluster, la grille des déjà-contactés, les QSO récents, les **commandes
|
||||
FlexRadio**, la **console Icom** ou le panneau **Net control**.
|
||||
- **Carte grand-cercle** avec distance & azimut trajet court/long, fonds de carte
|
||||
sélectionnables (Light / Voyager / Street / Satellite, tous sans clé et
|
||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||
rotor.
|
||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||
PstRotator.
|
||||
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||
|
||||
## Cluster DX
|
||||
|
||||
- Plusieurs serveurs de cluster avec reconnexion auto, un maître pour les
|
||||
commandes.
|
||||
- **Barre latérale de filtres** (recherche d'indicatif, masquer-déjà-contactés,
|
||||
grouper les doublons, bande / mode / statut / source) partagée entre l'onglet
|
||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||
panadapter).
|
||||
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||
`api.pota.app`).
|
||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
||||
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||
*Gestion des alertes*).
|
||||
|
||||
## Contrôle CAT
|
||||
|
||||
Quatre backends natifs (Réglages → CAT), chacun avec reconnexion auto et une
|
||||
connexion rapide non bloquante — une radio éteinte ne fige jamais l'application :
|
||||
|
||||
- **OmniRig** (Rig 1/2, changement à chaud) — fonctionne avec toute radio
|
||||
supportée par OmniRig.
|
||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
||||
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||
RS-BA1 ni Remote Utility nécessaires.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 et tout serveur compatible TCI :
|
||||
fréquence / mode / PTT / split, plus spots panorama optionnels.
|
||||
|
||||
Le mode est lu depuis la radio ; le sous-mode numérique (FT4 vs FT8) est déduit
|
||||
de la fréquence. Les **antennes RX/TX Flex par bande** sont configurables et
|
||||
appliquées automatiquement au changement de bande.
|
||||
|
||||
### Onglet de commande FlexRadio (façon SmartSDR)
|
||||
|
||||
Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||
|
||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||
|
||||
### Onglet de commande Icom
|
||||
|
||||
Affiché quand le backend CAT est Icom (USB ou réseau). Une console complète façon
|
||||
RS-BA1 :
|
||||
|
||||
- **Double afficheur VFO** (MAIN / SUB) avec la grande fréquence tabulaire, le
|
||||
badge de mode, la bande et l'offset RIT/ΔTX, et une **rangée de boutons de
|
||||
mode** (SSB / CW / RTTY / PSK / AM / FM).
|
||||
- **Scope de spectre + waterfall** (panadapter) : ON/OFF, CTR/FIX, double-clic
|
||||
pour accorder, et boutons **◀ ⊙ ▶** pour centrer le scope sur la fréquence
|
||||
actuelle (±50 kHz) et le décaler à gauche/droite.
|
||||
- **Mesures en direct** toujours visibles : S-mètre (clic → remplir le RST),
|
||||
puissance en watts, ROS.
|
||||
- **DSP réception :** gain AF / RF, squelch, AGC, préampli, atténuateur, filtre
|
||||
(FIL1/2/3), NB, NR, ANF et — **en CW seulement** — l'**APF** (filtre de pic
|
||||
audio).
|
||||
- **Passe-bande / notch :** Twin PBT (intérieur / extérieur), notch manuel +
|
||||
position.
|
||||
- **Émission :** puissance RF, MOX, TUNE, **split avec offset automatique**
|
||||
(+5 kHz en SSB, +1 kHz en CW), et moniteur. **En phonie seulement** : gain
|
||||
micro, processeur de parole, VOX (+ gain + anti-VOX). Les commandes qui ne
|
||||
s'appliquent pas au mode courant sont masquées automatiquement.
|
||||
- **Bandes & antenne :** boutons de bande en un clic et sélection ANT1/ANT2.
|
||||
- **Clarificateurs :** RIT et ΔTX avec accord molette / ± (Ctrl+←/→ décale le
|
||||
RIT).
|
||||
- Boutons **Marche / Arrêt** (manuels par choix — l'application ne réveille
|
||||
jamais la radio à la connexion).
|
||||
- La **manipulation CW** peut passer par le keyer intégré de la radio (voir
|
||||
*Keyers* ci-dessous).
|
||||
|
||||
### Icom à distance (par internet, sans RS-BA1)
|
||||
|
||||
OpsLog parle directement le protocole réseau intégré de l'IC-7610 — il
|
||||
**remplace à la fois l'Icom Remote Utility et RS-BA1**. Saisissez l'IP de la
|
||||
radio, le nom/mot de passe Network User1 et l'adresse CI-V, et toute la console
|
||||
Icom fonctionne sur le LAN/internet : login + token (renouvelé automatiquement),
|
||||
tunnel CI-V, retransmission côté réception pour une liaison très stable même
|
||||
avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmètre
|
||||
— utilisez la radio en USB + une liaison voix comme Mumble.)
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
||||
distance) ou **TCI**.
|
||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||
émettre.
|
||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||
*Log QSO* le contact est sauvegardé dans un WAV par QSO
|
||||
(`INDICATIF_AAAAMMJJ_HHMMSS.wav`) ; mixe RX + micro.
|
||||
|
||||
## Amplis & commutateurs
|
||||
|
||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
||||
sélecteur de mode ventilateur et affichage des défauts.
|
||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||
commutation A/B ancré.
|
||||
|
||||
## QSL & diplômes
|
||||
|
||||
- **Moteur de diplômes :** définitions intégrées + personnalisées (partagées
|
||||
**globalement** entre profils) — DXCC, WAS / WAZ / WAC, WPX,
|
||||
IOTA / POTA / SOTA / WWFF, **DDFM**, contacté/confirmé/validé par bande & mode,
|
||||
règles OU et affectation manuelle de références, détection de référence en
|
||||
direct à la saisie de l'indicatif, **import de listes de références** pour les
|
||||
totaux/noms, et un **Rescan** qui relit le journal (récupère les nouvelles
|
||||
confirmations LoTW/QRZ).
|
||||
- **Services QSL :** ClubLog (upload ADIF par lots), LoTW, QRZ.com, eQSL —
|
||||
upload et **téléchargement des confirmations** (qui rafraîchit automatiquement
|
||||
les stats de diplômes).
|
||||
- **Concepteur de cartes QSL** (voir ci-dessous).
|
||||
- **eQSL par e-mail :** clic droit sur un QSO → *Envoyer l'eQSL par e-mail* via
|
||||
le compte SMTP configuré. (Outlook/Hotmail désactivent le SMTP basic-auth —
|
||||
utilisez Gmail avec un mot de passe d'application, ou un mot de passe
|
||||
d'application Microsoft.)
|
||||
|
||||
## Log de concours
|
||||
|
||||
- **Onglet Contest :** choisissez un concours (liste ADIF `CONTEST_ID` intégrée)
|
||||
et un échange (numéro de série courant ou échange fixe). OpsLog remplit
|
||||
automatiquement `CONTEST_ID` et les numéros émis/reçus (`STX` / `SRX`), impose
|
||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||
direct.
|
||||
|
||||
## Statut opérateur en direct (événements spéciaux)
|
||||
|
||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||
~15 s. Un petit rendu PHP
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||
(`<img src="…/tm74-status.php?img=1">`). OpsLog écrit seulement dans la base —
|
||||
ce n'est pas un serveur web.
|
||||
|
||||
## Net control
|
||||
|
||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||
session active en mémoire — pointez les stations présentes, puis loguez tout le
|
||||
net d'un coup en utilisant la fréquence CAT.
|
||||
|
||||
## Apparence & langue
|
||||
|
||||
- **Thèmes :** quatre thèmes complets (Clair chaud, Sombre chaud, Graphite
|
||||
sombre, Contraste élevé) plus **Auto** (suit la préférence clair/sombre du
|
||||
système), sélectionnables dans Réglages → Général. Chaque panneau et chaque
|
||||
table AG-Grid suit le thème.
|
||||
- **Bilingue :** interface complète **anglais / français**, avec un choix de
|
||||
drapeau au premier lancement et un sélecteur dans Réglages → Général.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Coffre à secrets :** chiffrement optionnel par phrase de passe des mots de
|
||||
passe stockés (AES-GCM + PBKDF2). Les valeurs chiffrées sont portables ; une
|
||||
seule invite de déverrouillage au lancement les déchiffre pour la session.
|
||||
|
||||
## Intégrations (sortantes)
|
||||
|
||||
- **Émetteurs UDP :** pousser la fréquence actuelle vers **PstRotator**, les
|
||||
infos radio au format **N1MM `RadioInfo`**, ou un **enregistrement ADIF à
|
||||
chaque QSO logué** — pour que les outils externes (contrôle de rotor,
|
||||
applications numériques, autres logiciels de log) restent synchronisés.
|
||||
|
||||
## Divers
|
||||
|
||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||
désactivable dans les Préférences).
|
||||
|
||||
---
|
||||
|
||||
## Concepteur de cartes QSL
|
||||
|
||||
Outils → *Concepteur de cartes QSL…* transforme quelques photos en une carte
|
||||
eQSL soignée :
|
||||
|
||||
1. Choisissez 1 à 6 photos (jpeg/png). OpsLog les analyse hors ligne (grille de
|
||||
détail/luminance) et propose **3 designs** — indicatif dans la zone la plus
|
||||
calme de la meilleure photo, nom de l'opérateur, zones CQ/ITU + ligne locator,
|
||||
drapeau du pays, les autres photos en inserts encadrés, et un encart de
|
||||
confirmation par QSO.
|
||||
2. Choisissez une proposition et affinez-la : cliquez un élément pour le
|
||||
sélectionner, glissez pour déplacer, changez la police / le préréglage de
|
||||
style (gel or, gel argent, contour blanc classique, script, plat) et les
|
||||
réglages par préréglage dans le panneau de droite.
|
||||
3. Enregistrez le modèle (les photos sont copiées dans
|
||||
`data/qsl/templates/<id>/`, les originaux peuvent donc être déplacés). Un
|
||||
modèle peut être le défaut par profil.
|
||||
|
||||
Envoi : clic droit sur un QSO → *Envoyer l'eQSL par e-mail*. La carte est rendue
|
||||
avec les données de ce QSO, matricée en JPEG ≤ 800 Ko, archivée dans
|
||||
`data/qsl/outbox/` et envoyée via le compte SMTP configuré à l'adresse trouvée
|
||||
par la recherche QRZ/HamQTH. En cas de succès le QSO est marqué `EQSL_SENT=Y`
|
||||
(ADIF). Les modèles de sujet/corps de l'e-mail sont dans le concepteur (variables
|
||||
`{CALL} {DATE} {BAND} {MODE} {MYCALL}`).
|
||||
|
||||
Polices : Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura
|
||||
(toutes OFL, embarquées — licences dans `internal/qslcard/assets/fonts/`) ;
|
||||
Cooper Black est proposée si MS Office l'a installée. Drapeaux : flag-icons
|
||||
(MIT), embarqués pour les entités DXCC couramment contactées.
|
||||
|
||||
---
|
||||
|
||||
## Données & stockage
|
||||
|
||||
- La **config** (réglages, profils, radios/antennes, nœuds de cluster, cache de
|
||||
recherche, listes de diplômes, modèles QSL) réside toujours dans le fichier
|
||||
SQLite local sous `data/` — instantané même quand le journal est sur un MySQL
|
||||
lointain.
|
||||
- Le **journal** (QSO) réside là où pointe le profil actif : le fichier SQLite
|
||||
local ou une base **MySQL** partagée par profil.
|
||||
|
||||
---
|
||||
|
||||
*An English version of this document is available in [README.md](README.md).*
|
||||
@@ -1,10 +1,13 @@
|
||||
# OpsLog
|
||||
|
||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||
Fully themeable and bilingual (English / French).
|
||||
|
||||
Developed by **F4BPO**.
|
||||
|
||||
@@ -30,10 +33,16 @@ Developed by **F4BPO**.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
and the QRZ.com tab.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
||||
date overrides.
|
||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||
ClubLog DXpedition date overrides.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||
presets) with filtered- and selected-row **ADIF export**.
|
||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||
same day / minute) and lets you pick which to delete.
|
||||
- **ADIF 3.1.7 compliant** import/export: a full field dictionary, 30 promoted
|
||||
columns, a generic "extra fields" editor and standard/all export modes.
|
||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||
|
||||
@@ -41,7 +50,8 @@ Developed by **F4BPO**.
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, or the **FlexRadio controls**.
|
||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
||||
console** or the **Net control** panel.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
@@ -58,15 +68,29 @@ Developed by **F4BPO**.
|
||||
pane, with a show/hide toggle.
|
||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||
management*).
|
||||
|
||||
## CAT control
|
||||
|
||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
||||
Flex display, click → fill the call).
|
||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
||||
from the frequency.
|
||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app:
|
||||
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||
the Flex display, click → fill the call).
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, plus optional panorama spots.
|
||||
|
||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
automatically on band change.
|
||||
|
||||
### FlexRadio control tab (SmartSDR-style)
|
||||
|
||||
@@ -80,19 +104,64 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
### Icom control tab
|
||||
|
||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||
|
||||
- **Twin VFO readout** (MAIN / SUB) with the big tabular frequency, mode badge,
|
||||
band and RIT/ΔTX offset, and a **mode-button row** (SSB / CW / RTTY / PSK /
|
||||
AM / FM).
|
||||
- **Spectrum scope + waterfall** (panadapter): ON/OFF, CTR/FIX, double-click to
|
||||
tune, and **◀ ⊙ ▶** buttons to centre the scope on the current frequency
|
||||
(±50 kHz) and pan left/right.
|
||||
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
|
||||
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, attenuator, filter
|
||||
(FIL1/2/3), NB, NR, ANF and — **on CW only** — the **APF** (audio peak filter).
|
||||
- **Passband / notch:** Twin PBT (inner / outer), manual notch + position.
|
||||
- **Transmit:** RF power, MOX, TUNE, **split with an automatic offset**
|
||||
(+5 kHz on SSB, +1 kHz on CW), and monitor. On **voice modes only**: mic gain,
|
||||
speech compressor, VOX (+ gain + anti-VOX). Controls that don't apply to the
|
||||
current mode are hidden automatically.
|
||||
- **Bands & antenna:** one-touch band buttons and ANT1/ANT2 selection.
|
||||
- **Clarifiers:** RIT and ΔTX with wheel / ± tuning (Ctrl+←/→ nudges RIT).
|
||||
- **Power ON / OFF** buttons (manual by design — the app never wakes the rig on
|
||||
connect).
|
||||
- **CW keying** can run through the radio's own keyer (see *Keyers* below).
|
||||
|
||||
### Remote Icom (over the internet, no RS-BA1)
|
||||
|
||||
OpsLog speaks the IC-7610's built-in network protocol directly — it **replaces
|
||||
both the Icom Remote Utility and RS-BA1**. Enter the radio's IP, the Network
|
||||
User1 name/password and the CI-V address, and the whole Icom console works over
|
||||
the LAN/internet: login + token (auto-renewed), CI-V tunnel, receive-side
|
||||
retransmit for a rock-solid link even with the panadapter streaming, and manual
|
||||
power ON/OFF. (Audio is out of scope — use the radio in USB + a voice link such
|
||||
as Mumble.)
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
||||
- **Digital Voice Keyer** (DVK) message playback.
|
||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
||||
audio in CW).
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
||||
manual reference assignment, live reference detection on call entry, and a
|
||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
||||
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
|
||||
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
|
||||
reference assignment, live reference detection on call entry, **reference-list
|
||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||
fresh LoTW/QRZ confirmations).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||
and **confirmation download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
@@ -100,6 +169,13 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
password, or a Microsoft app password.)
|
||||
|
||||
## Contest logging
|
||||
|
||||
- **Contest tab:** pick a contest (built-in ADIF `CONTEST_ID` list) and an
|
||||
exchange (running serial or a fixed exchange). OpsLog auto-fills `CONTEST_ID`
|
||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||
flags dupes and keeps a live scoreboard.
|
||||
|
||||
## Multi-operator live status (special events)
|
||||
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||
@@ -111,10 +187,37 @@ own web server reads that table and produces a live page/image you can embed on
|
||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||
only writes to the DB — it is not a web server.
|
||||
|
||||
## Net control
|
||||
|
||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||
in-memory active session — check stations in, then log the whole net at once
|
||||
using the CAT frequency.
|
||||
|
||||
## Appearance & language
|
||||
|
||||
- **Themes:** four complete themes (Warm light, Warm dark, Graphite dark, High
|
||||
contrast) plus **Auto** (follows the OS light/dark preference), selectable in
|
||||
Settings → General. Every panel and every AG-Grid table follows the theme.
|
||||
- **Bilingual:** full **English / French** UI, with a first-run flag chooser and
|
||||
a switcher in Settings → General.
|
||||
|
||||
## Security
|
||||
|
||||
- **Secret vault:** opt-in passphrase encryption of the stored passwords
|
||||
(AES-GCM + PBKDF2). Encrypted values are portable; a single unlock prompt at
|
||||
launch decrypts them for the session.
|
||||
|
||||
## Integrations (outbound)
|
||||
|
||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||
|
||||
## Other
|
||||
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
@@ -156,3 +259,7 @@ commonly-worked DXCC entities.
|
||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||
file or a per-profile shared **MySQL** database.
|
||||
|
||||
---
|
||||
|
||||
*A French version of this document is available in [README.fr.md](README.fr.md).*
|
||||
|
||||
+10
-2
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QSLListTemplates returns all saved templates (defaults first).
|
||||
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
|
||||
// shared/global ones), defaults first — so the default is per-profile, not a
|
||||
// single global list shared by every profile.
|
||||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||
if a.qslTemplates == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
recs, err := a.qslTemplates.List(a.ctx)
|
||||
var recs []qslcard.Record
|
||||
var err error
|
||||
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
|
||||
} else {
|
||||
recs, err = a.qslTemplates.List(a.ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/award"
|
||||
)
|
||||
|
||||
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||
// CORRECTED. These tests pin the three rules that make that safe:
|
||||
// - a new catalog award reaches an operator who already has awards stored;
|
||||
// - a fixed one (higher Version) replaces the stored copy;
|
||||
// - unless the operator has edited it, in which case their work wins.
|
||||
|
||||
func catalogDef(t *testing.T, code string) award.Def {
|
||||
t.Helper()
|
||||
for _, d := range award.Defaults() {
|
||||
if d.Code == code {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is not in the catalog", code)
|
||||
return award.Def{}
|
||||
}
|
||||
|
||||
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||
for _, d := range defs {
|
||||
if d.Code == code {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return award.Def{}, false
|
||||
}
|
||||
|
||||
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||
got, updated, changed := mergeCatalog(stored)
|
||||
if !changed {
|
||||
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||
}
|
||||
if len(updated) != 0 {
|
||||
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||
}
|
||||
if _, ok := findDef(got, "FFMA"); !ok {
|
||||
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||
}
|
||||
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||
t.Error("the operator's own award was dropped by the merge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||
old := catalogDef(t, "FFMA")
|
||||
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||
old.Field = "wrong"
|
||||
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||
|
||||
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||
if !changed || len(updated) == 0 {
|
||||
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||
}
|
||||
d, _ := findDef(got, "FFMA")
|
||||
if d.Field != "grid4" {
|
||||
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||
}
|
||||
if d.Valid {
|
||||
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||
old := catalogDef(t, "FFMA")
|
||||
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||
old.Field = "mine"
|
||||
old.UserEdited = true
|
||||
|
||||
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||
if len(updated) != 0 {
|
||||
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||
}
|
||||
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||
prev := []award.Def{
|
||||
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||
}
|
||||
next := []award.Def{
|
||||
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||
}
|
||||
markUserEdited(next, prev)
|
||||
|
||||
if next[0].UserEdited {
|
||||
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||
}
|
||||
if !next[1].UserEdited {
|
||||
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||
}
|
||||
if next[2].UserEdited {
|
||||
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||
}
|
||||
// A save must not pretend to be a new shipped revision.
|
||||
if next[0].Version != 2 || next[1].Version != 2 {
|
||||
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -157,6 +158,7 @@ func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
||||
func (a *App) chatLoop() {
|
||||
defer func() { _ = recover() }()
|
||||
var lastID int64 = -1 // -1 = not yet baselined
|
||||
var lastDB *sql.DB // logbook the baseline belongs to
|
||||
lastPresence := time.Time{}
|
||||
lastPurge := time.Time{}
|
||||
t := time.NewTicker(chatPollInterval)
|
||||
@@ -164,8 +166,15 @@ func (a *App) chatLoop() {
|
||||
for range t.C {
|
||||
if !a.chatActive() {
|
||||
lastID = -1 // re-baseline if the backend changes
|
||||
lastDB = nil
|
||||
continue
|
||||
}
|
||||
// Profile switch swaps the logbook under us: re-baseline against the new
|
||||
// DB so we don't query it with the previous log's id cursor.
|
||||
if a.logDb != lastDB {
|
||||
lastID = -1
|
||||
lastDB = a.logDb
|
||||
}
|
||||
if err := a.ensureChatTables(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
// Command icomnettest is an iteration probe for the Icom IP remote protocol —
|
||||
// the LAN server built into the IC-7610 that the Icom "Remote Utility" (and
|
||||
// wfview) talk to. OpsLog reimplements this directly so it can BE both the
|
||||
// Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the
|
||||
// virtual-COM + RS-BA1 chain entirely.
|
||||
//
|
||||
// This probe drives TWO streams and hex-dumps everything:
|
||||
// Control (UDP 50001): handshake → login → token [VERIFIED on the real rig]
|
||||
// CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq
|
||||
// (FE FE 98 E0 03 FD) → print the rig's reply.
|
||||
// Framing (passcode table, packet offsets, CI-V data_packet, openclose) is
|
||||
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||
// against real Remote-Utility captures (build/bin/civ*.pcapng). No GPLv3 code.
|
||||
//
|
||||
// Usage:
|
||||
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
|
||||
//
|
||||
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var le = binary.LittleEndian
|
||||
var be = binary.BigEndian
|
||||
|
||||
// passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e).
|
||||
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||
var passcodeSeq = [256]byte{
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||
}
|
||||
|
||||
func passcode(s string) []byte {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s) && i < 16; i++ {
|
||||
p := int(s[i]) + i
|
||||
if p > 126 {
|
||||
p = 32 + p%127
|
||||
}
|
||||
out = append(out, passcodeSeq[p])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- packet builders (offsets verified vs wfview structs + real captures) ---
|
||||
|
||||
func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||
b := make([]byte, 16)
|
||||
le.PutUint32(b[0:], 0x10)
|
||||
le.PutUint16(b[4:], typ)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
return b
|
||||
}
|
||||
|
||||
func buildLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||
b := make([]byte, 0x80)
|
||||
le.PutUint32(b[0:], 0x80)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x80-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x00 // requesttype = login
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
copy(b[0x40:0x50], passcode(user))
|
||||
copy(b[0x50:0x60], passcode(pass))
|
||||
nm := name
|
||||
if len(nm) > 16 {
|
||||
nm = nm[:16]
|
||||
}
|
||||
copy(b[0x60:0x70], []byte(nm))
|
||||
return b
|
||||
}
|
||||
|
||||
func buildToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||
b := make([]byte, 0x40)
|
||||
le.PutUint32(b[0:], 0x40)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x40-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x02 // requesttype = token
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
return b
|
||||
}
|
||||
|
||||
// buildConnInfo — 144-byte sendRequestStream on the CONTROL stream. Tells the
|
||||
// rig to route the CI-V/audio streams to the authenticated session and which
|
||||
// local ports we use. Values verified byte-for-byte vs a real Remote-Utility
|
||||
// capture (civ4): requesttype=0x03, commoncap=0x8010, the rig's MAC echoed,
|
||||
// name "IC-7610", scrambled username, rxenable=0 (audio off — CI-V only),
|
||||
// rxcodec 0x10 / txcodec 0x04, rxsample 16000 / txsample 8000 (BE), civport /
|
||||
// audioport (BE), txbuffer 100.
|
||||
func buildConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
||||
b := make([]byte, 0x90)
|
||||
le.PutUint32(b[0:], 0x90)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
be.PutUint32(b[0x10:], 0x90-0x10)
|
||||
b[0x14] = 0x01 // requestreply
|
||||
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||
be.PutUint16(b[0x16:], innerSeq)
|
||||
le.PutUint16(b[0x1a:], tokReq)
|
||||
le.PutUint32(b[0x1c:], token)
|
||||
le.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||
copy(b[0x2a:0x30], rigMAC) // macaddress (the rig's, echoed back)
|
||||
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||
copy(b[0x60:0x70], passcode(user))
|
||||
b[0x70] = 0x00 // rxenable (0 = audio off)
|
||||
b[0x71] = 0x00 // txenable
|
||||
b[0x72] = 0x10 // rxcodec
|
||||
b[0x73] = 0x04 // txcodec
|
||||
be.PutUint32(b[0x74:], 16000) // rxsample
|
||||
be.PutUint32(b[0x78:], 8000) // txsample
|
||||
be.PutUint32(b[0x7c:], uint32(civPort))
|
||||
be.PutUint32(b[0x80:], uint32(audioPort))
|
||||
be.PutUint32(b[0x84:], 100) // txbuffer
|
||||
b[0x88] = 0x00 // convert
|
||||
return b
|
||||
}
|
||||
|
||||
// buildOpenClose — 22-byte start/stop for the CI-V stream. magic 0x04=open,
|
||||
// 0x00=close. data=0x01c0 (@0x10), civSeq (BE @0x13), magic (@0x15).
|
||||
func buildOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||
b := make([]byte, 0x16)
|
||||
le.PutUint32(b[0:], 0x16)
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
le.PutUint16(b[0x10:], 0x01c0)
|
||||
be.PutUint16(b[0x13:], civSeq)
|
||||
b[0x15] = magic
|
||||
return b
|
||||
}
|
||||
|
||||
// buildCivData — wraps raw CI-V bytes: 21-byte header (reply 0xc1 @0x10,
|
||||
// datalen LE @0x11, civSeq BE @0x13) + CI-V frame @0x15.
|
||||
func buildCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||
n := 0x15 + len(civ)
|
||||
b := make([]byte, n)
|
||||
le.PutUint32(b[0:], uint32(n))
|
||||
le.PutUint16(b[6:], seq)
|
||||
le.PutUint32(b[8:], sentid)
|
||||
le.PutUint32(b[12:], rcvdid)
|
||||
b[0x10] = 0xc1
|
||||
le.PutUint16(b[0x11:], uint16(len(civ)))
|
||||
be.PutUint16(b[0x13:], civSeq)
|
||||
copy(b[0x15:], civ)
|
||||
return b
|
||||
}
|
||||
|
||||
func header(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
|
||||
if len(b) < 16 {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true
|
||||
}
|
||||
|
||||
func localID(conn net.Conn) uint32 {
|
||||
a := conn.LocalAddr().(*net.UDPAddr)
|
||||
return uint32(a.IP.To4()[0])<<24 | uint32(a.IP.To4()[1])<<16 | uint32(uint16(a.Port))
|
||||
}
|
||||
|
||||
func recv(conn net.Conn, ms int, buf []byte) ([]byte, bool) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return append([]byte(nil), buf[:n]...), true
|
||||
}
|
||||
|
||||
func dump(tag string, p []byte) { fmt.Printf("%s (%d)\n%s\n", tag, len(p), hex.Dump(p)) }
|
||||
|
||||
// pingReply mirrors a ping, swaps ids, sets the reply flag at offset 16.
|
||||
func pingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||
r := append([]byte(nil), pkt...)
|
||||
if len(r) >= 17 {
|
||||
le.PutUint32(r[8:], myID)
|
||||
le.PutUint32(r[12:], remoteID)
|
||||
r[16] = 0x01
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// handshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||
// Returns the rig's remote id. Replies to any pings meanwhile.
|
||||
func handshake(conn net.Conn, myID uint32, label string) (uint32, bool) {
|
||||
buf := make([]byte, 2048)
|
||||
conn.Write(ctrlPacket(0x03, 0, myID, 0)) // areYouThere
|
||||
fmt.Printf("[%s] TX areYouThere\n", label)
|
||||
var remoteID uint32
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
lastTry := time.Now()
|
||||
for time.Now().Before(deadline) {
|
||||
p, ok := recv(conn, 200, buf)
|
||||
if !ok {
|
||||
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||
conn.Write(ctrlPacket(0x03, 0, myID, 0))
|
||||
lastTry = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, typ, _, sentid, _, ok := header(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typ {
|
||||
case 0x04: // iAmHere
|
||||
remoteID = sentid
|
||||
fmt.Printf("[%s] iAmHere remoteID=0x%08X → TX areYouReady\n", label, remoteID)
|
||||
conn.Write(ctrlPacket(0x06, 1, myID, remoteID))
|
||||
case 0x06: // iAmReady
|
||||
if remoteID != 0 {
|
||||
fmt.Printf("[%s] iAmReady — link up ✓\n", label)
|
||||
return remoteID, true
|
||||
}
|
||||
case 0x07: // ping
|
||||
conn.Write(pingReply(p, myID, remoteID))
|
||||
}
|
||||
}
|
||||
return remoteID, false
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
|
||||
compName := "OpsLog"
|
||||
if len(os.Args) >= 5 {
|
||||
compName = os.Args[4]
|
||||
}
|
||||
|
||||
// ===================== CONTROL STREAM (50001) =====================
|
||||
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
|
||||
if err != nil {
|
||||
fmt.Printf("dial control: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
cID := localID(ctrl)
|
||||
fmt.Printf("=== CONTROL 50001 (myID=0x%08X) ===\n", cID)
|
||||
fmt.Printf("scrambled user=% X pass=% X\n\n", passcode(user), passcode(pass))
|
||||
|
||||
cRemote, ok := handshake(ctrl, cID, "ctrl")
|
||||
if !ok {
|
||||
fmt.Println("control handshake failed")
|
||||
return
|
||||
}
|
||||
|
||||
// login → token
|
||||
var cTracked uint16 = 1
|
||||
var cInner uint16 = 1
|
||||
tokReq := uint16(0x0c77)
|
||||
dump("[ctrl] TX login", buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||
ctrl.Write(buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||
cTracked++
|
||||
cInner++
|
||||
|
||||
var token uint32
|
||||
buf := make([]byte, 2048)
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for token == 0 && time.Now().Before(deadline) {
|
||||
p, ok := recv(ctrl, 200, buf)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
length, typ, _, _, _, _ := header(p)
|
||||
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||
token = le.Uint32(p[0x1c:])
|
||||
errCode := le.Uint32(p[0x30:])
|
||||
if errCode != 0 || token == 0 {
|
||||
fmt.Printf(">> LOGIN REJECTED err=0x%08X token=0x%08X\n", errCode, token)
|
||||
return
|
||||
}
|
||||
fmt.Printf(">> LOGIN OK ✓ token=0x%08X\n", token)
|
||||
ctrl.Write(buildToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||
cTracked++
|
||||
cInner++
|
||||
} else if typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
}
|
||||
if token == 0 {
|
||||
fmt.Println("no token — login not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// Send conninfo on the control stream — routes the CI-V stream to this
|
||||
// authenticated session and announces our civ/audio local ports (50002/3).
|
||||
rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet)
|
||||
dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||
ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||
cTracked++
|
||||
cInner++
|
||||
// Let the rig's caps/conninfo replies flow for ~600ms (reply to pings).
|
||||
drainEnd := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(drainEnd) {
|
||||
if p, ok := recv(ctrl, 100, buf); ok {
|
||||
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== CI-V STREAM (50002) =====================
|
||||
// Bind our civ socket to LOCAL port 50002 (= the civport announced above),
|
||||
// as the Remote Utility does. Requires the Remote Utility to be CLOSED.
|
||||
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, &net.UDPAddr{IP: net.ParseIP(ip), Port: 50002})
|
||||
if err != nil {
|
||||
fmt.Printf("dial civ (local :50002 — is the Remote Utility still running?): %v\n", err)
|
||||
return
|
||||
}
|
||||
defer civ.Close()
|
||||
vID := localID(civ)
|
||||
fmt.Printf("\n=== CI-V 50002 (myID=0x%08X) ===\n", vID)
|
||||
vRemote, ok := handshake(civ, vID, "civ")
|
||||
if !ok {
|
||||
fmt.Println("CI-V handshake failed (may need the conninfo packet on control first)")
|
||||
return
|
||||
}
|
||||
|
||||
var vTracked uint16 = 1 // outer tracked seq @0x06
|
||||
var vCivSeq uint16 = 1 // inner CI-V seq @0x13 (BE)
|
||||
// openClose(open) starts CI-V data flow.
|
||||
dump("[civ] TX openClose(open)", buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||
vTracked++
|
||||
vCivSeq++
|
||||
|
||||
// Try several read commands, spaced out. Some rigs NG the basic 0x03 read
|
||||
// over the network tunnel; 0x25 / 0x04 and unsolicited transceive frames
|
||||
// (sent when you turn the VFO) still work. The tunnel itself is proven, so
|
||||
// this figures out which read the rig actually answers.
|
||||
sendCiv := func(name string, f []byte) {
|
||||
fmt.Printf("[civ] TX %s\n", name)
|
||||
civ.Write(buildCivData(vTracked, vID, vRemote, vCivSeq, f))
|
||||
vTracked++
|
||||
vCivSeq++
|
||||
}
|
||||
// The rig is in STANDBY (network up, radio off) — it NG's every command
|
||||
// until powered on via CI-V. Send power-on (0x18 0x01, with an FE wake
|
||||
// preamble, as the Remote Utility does), then poll read-freq while it boots.
|
||||
powerOn := make([]byte, 0, 32)
|
||||
for i := 0; i < 25; i++ {
|
||||
powerOn = append(powerOn, 0xFE)
|
||||
}
|
||||
powerOn = append(powerOn, 0xFE, 0xFE, 0x98, 0xE0, 0x18, 0x01, 0xFD)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
sendCiv("POWER ON (0x18 01)", powerOn)
|
||||
fmt.Print("\n>>> rig booting (~10-15 s) — polling read-freq until it answers <<<\n\n")
|
||||
readFreq := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||
|
||||
cbuf := make([]byte, 4096)
|
||||
vbuf := make([]byte, 4096)
|
||||
end := time.Now().Add(30 * time.Second)
|
||||
lastIdleC, lastIdleV, lastCmd := time.Now(), time.Now(), time.Now()
|
||||
for time.Now().Before(end) {
|
||||
if p, ok := recv(ctrl, 40, cbuf); ok {
|
||||
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||
ctrl.Write(pingReply(p, cID, cRemote))
|
||||
}
|
||||
} else if time.Since(lastIdleC) > 200*time.Millisecond {
|
||||
ctrl.Write(ctrlPacket(0x00, 0, cID, cRemote))
|
||||
lastIdleC = time.Now()
|
||||
}
|
||||
if p, ok := recv(civ, 40, vbuf); ok {
|
||||
_, typ, _, _, _, _ := header(p)
|
||||
if typ == 0x07 {
|
||||
civ.Write(pingReply(p, vID, vRemote))
|
||||
} else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 {
|
||||
f := p[0x15:]
|
||||
if d := decodeCiv(f); d != "" {
|
||||
fmt.Printf(">> CI-V RX: % X %s\n", f, d)
|
||||
}
|
||||
}
|
||||
} else if time.Since(lastIdleV) > 200*time.Millisecond {
|
||||
civ.Write(ctrlPacket(0x00, 0, vID, vRemote))
|
||||
lastIdleV = time.Now()
|
||||
}
|
||||
if time.Since(lastCmd) > 1000*time.Millisecond {
|
||||
sendCiv("read-freq 0x03", readFreq)
|
||||
lastCmd = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean close.
|
||||
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x00)) // openClose(close)
|
||||
civ.Write(ctrlPacket(0x05, 0, vID, vRemote)) // disconnect
|
||||
ctrl.Write(ctrlPacket(0x05, 0, cID, cRemote))
|
||||
fmt.Println("\nDone. Look for '>> CI-V RX:' and 'FREQUENCY reply'.")
|
||||
}
|
||||
|
||||
// decodeCiv describes a received CI-V frame (FE FE <to> <from> <cmd> … FD).
|
||||
// Only frames FROM the rig (from=0x98) are interesting; our own echoed commands
|
||||
// (from=0xE0) return "" so they're not printed.
|
||||
func decodeCiv(f []byte) string {
|
||||
if len(f) < 6 || f[0] != 0xFE || f[1] != 0xFE {
|
||||
return ""
|
||||
}
|
||||
if f[3] != 0x98 { // not from the rig (our echoed command) — skip
|
||||
return ""
|
||||
}
|
||||
cmd := f[4]
|
||||
body := f[5 : len(f)-1] // between cmd and the trailing FD
|
||||
switch cmd {
|
||||
case 0xFA:
|
||||
return "NG (command rejected)"
|
||||
case 0xFB:
|
||||
return "OK (ack)"
|
||||
case 0x00, 0x03, 0x05: // (transceive) freq / read-freq
|
||||
if len(body) >= 5 {
|
||||
return "FREQ " + decodeFreq(body[:5])
|
||||
}
|
||||
case 0x25: // read/set VFO freq (body = subcmd + 5 BCD)
|
||||
if len(body) >= 6 {
|
||||
return fmt.Sprintf("VFO%d FREQ %s", body[0], decodeFreq(body[1:6]))
|
||||
}
|
||||
case 0x01, 0x04: // (transceive) mode / read-mode
|
||||
if len(body) >= 1 {
|
||||
return fmt.Sprintf("MODE 0x%02X filt 0x%02X", body[0], lastOr(body, 1))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("cmd 0x%02X", cmd)
|
||||
}
|
||||
|
||||
func lastOr(b []byte, i int) byte {
|
||||
if i < len(b) {
|
||||
return b[i]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// decodeFreq turns Icom little-endian BCD (5 bytes) into a MHz string.
|
||||
func decodeFreq(bcd []byte) string {
|
||||
var hz uint64
|
||||
mul := uint64(1)
|
||||
for _, b := range bcd {
|
||||
hz += uint64(b&0x0f) * mul
|
||||
mul *= 10
|
||||
hz += uint64(b>>4) * mul
|
||||
mul *= 10
|
||||
}
|
||||
return fmt.Sprintf("%.6f MHz", float64(hz)/1e6)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire
|
||||
// format it expects for the "DXLog.net" tracker. We don't have the spec, so this
|
||||
// sends a series of labelled candidate datagrams to the target and pauses between
|
||||
// each so you can watch PstRotatorAz and note which one it reacts to (the band
|
||||
// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency
|
||||
// shows in the tracker box).
|
||||
//
|
||||
// Setup on the PstRotatorAz side:
|
||||
// - Tracker = DXLog.net
|
||||
// - Note the UDP port it listens on (commonly 12040) and this PC's IP
|
||||
// - In the tracker setup tick a couple of bands so the band filter is visible
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz
|
||||
// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x
|
||||
//
|
||||
// Watch PstRotatorAz as it runs; when one variant makes it react, that's the
|
||||
// format — tell me the variant number and I'll wire it into OpsLog.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type variant struct {
|
||||
name string
|
||||
// build returns the datagram for a given frequency in kHz.
|
||||
build func(khz float64) string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("usage: psttest <ip:port> [freqKHz] [repeat]")
|
||||
fmt.Println("example: psttest 192.168.1.50:12040 3525.5")
|
||||
os.Exit(2)
|
||||
}
|
||||
target := os.Args[1]
|
||||
khz := 3525.5
|
||||
if len(os.Args) >= 3 {
|
||||
if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil {
|
||||
khz = v
|
||||
}
|
||||
}
|
||||
repeat := 1
|
||||
if len(os.Args) >= 4 {
|
||||
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 {
|
||||
repeat = v
|
||||
}
|
||||
}
|
||||
|
||||
hz := int64(khz*1000 + 0.5)
|
||||
tensHz := hz / 10 // N1MM/DXLog <Freq> unit
|
||||
|
||||
// Tag <PST><FREQUENCY> is confirmed. PstRotator strips the decimal point and
|
||||
// reads the digits, so pin down the exact unit: watch which one shows the real
|
||||
// frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255.
|
||||
variants := []variant{
|
||||
{"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", hz) }},
|
||||
{"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensHz) }},
|
||||
{"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", int64(k+0.5)) }},
|
||||
{"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%.1f</FREQUENCY></PST>", k) }},
|
||||
}
|
||||
|
||||
conn, err := net.Dial("udp", target)
|
||||
if err != nil {
|
||||
fmt.Printf("dial %s: %v\n", target, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat)
|
||||
for i, v := range variants {
|
||||
msg := v.build(khz)
|
||||
fmt.Printf("── Variant %d: %s\n", i+1, v.name)
|
||||
fmt.Printf(" payload: %s\n", msg)
|
||||
for r := 0; r < repeat; r++ {
|
||||
if _, err := conn.Write([]byte(msg)); err != nil {
|
||||
fmt.Printf(" write error: %v\n", err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n")
|
||||
time.Sleep(4 * time.Second)
|
||||
}
|
||||
fmt.Println("Done. Which variant number made PstRotatorAz react?")
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
|
||||
$DB_NAME = 'opslog'; // database name
|
||||
$DB_USER = 'opslog';
|
||||
$DB_PASS = 'CHANGE_ME';
|
||||
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
|
||||
// instead of a fatal "table doesn't exist".
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||
if ($mysqli->connect_errno) {
|
||||
http_response_code(500);
|
||||
exit('DB error');
|
||||
|
||||
+1149
-234
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { ADIFFields } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldDef = {
|
||||
name: string; kind: string; category: string;
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
||||
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
|
||||
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
|
||||
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
|
||||
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
|
||||
{t('adx.introPart1')}<code className="bg-muted px-1 rounded font-mono">APP_*</code>{t('adx.introPart2')}<strong>{t('adx.fullMode')}</strong>{t('adx.introPart3')}
|
||||
</p>
|
||||
|
||||
{/* Add a field */}
|
||||
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<Combobox
|
||||
value=""
|
||||
options={addable}
|
||||
placeholder="Add ADIF field…"
|
||||
placeholder={t('adx.addFieldPh')}
|
||||
allowFreeText
|
||||
onChange={addField}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||
Show deprecated
|
||||
{t('adx.showDeprecated')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Current entries */}
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
||||
No extra ADIF fields. Use the picker above to add one.
|
||||
{t('adx.noExtra')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -105,11 +105,11 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||
{def && (
|
||||
<span className="block text-[10px] text-muted-foreground leading-tight">
|
||||
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
|
||||
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
|
||||
{!def && ''}
|
||||
</span>
|
||||
)}
|
||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
|
||||
{!def && <span className="block text-[10px] text-warning leading-tight">{t('adx.nonStandard')}</span>}
|
||||
</div>
|
||||
<Input
|
||||
className="flex-1 h-8 text-xs font-mono"
|
||||
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(k)} title="Remove field"
|
||||
onClick={() => remove(k)} title={t('adx.removeField')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
import { alerts } from '@/../wailsjs/go/models';
|
||||
|
||||
type Rule = alerts.Rule;
|
||||
|
||||
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||
|
||||
function emptyRule(): Rule {
|
||||
return alerts.Rule.createFrom({
|
||||
id: '', name: 'New alert', enabled: true,
|
||||
calls: [], countries: [], continents: [], bands: [], modes: [],
|
||||
spotter_call: '', spotter_continents: [], spotter_countries: [],
|
||||
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
|
||||
});
|
||||
}
|
||||
|
||||
// MultiCheck — a scrollable checkbox list for a set of options with an optional
|
||||
// search box (used for the long country list). Empty selection = ALL.
|
||||
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const shown = useMemo(
|
||||
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||
[options, q],
|
||||
);
|
||||
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
|
||||
return (
|
||||
<div className="rounded-md border border-border">
|
||||
{searchable && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||
<Search className="size-3 text-muted-foreground" />
|
||||
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('overflow-y-auto p-1', height)}>
|
||||
{shown.map((o) => (
|
||||
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
|
||||
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
|
||||
<span className="truncate">{o}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
|
||||
</div>
|
||||
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
const [emailTo, setEmailTo] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||
|
||||
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||
if (!d) return d;
|
||||
const cur = ((d as any)[key] as string[]) ?? [];
|
||||
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
|
||||
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
|
||||
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { setDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||
{/* Rule list */}
|
||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||
{rules.map((r) => (
|
||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
<span className="truncate flex-1">{r.name}</span>
|
||||
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 border-t border-border/60">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('altm.emailTo')}</Label>
|
||||
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||
onChange={(e) => setEmailTo(e.target.value)}
|
||||
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||
{!draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">{t('altm.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
|
||||
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
|
||||
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
|
||||
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<TabsContent value="def" className="mt-0 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">{t('altm.ruleName')}</Label>
|
||||
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-40 shrink-0">{t('altm.againAfter')}</Label>
|
||||
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> {t('altm.visual')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> {t('altm.sound')}</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> {t('altm.email')}</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
|
||||
</label>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-xs">{t('altm.continents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.bands')}</Label>
|
||||
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.modes')}</Label>
|
||||
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.spotterCall')}</Label>
|
||||
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
|
||||
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{CONTINENTS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
|
||||
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* Editor actions */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-5 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||
export type AGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||
antennas: AGAntenna[];
|
||||
};
|
||||
|
||||
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
|
||||
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
|
||||
// configured for the current band, like the native app. If the mapping ever
|
||||
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
|
||||
// device's actual mask so it can be corrected.
|
||||
const AG_BAND_BITS: Record<string, number> = {
|
||||
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
|
||||
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
|
||||
};
|
||||
|
||||
function bandBit(band?: string): number {
|
||||
if (!band) return 0;
|
||||
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
|
||||
}
|
||||
|
||||
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||
function pretty(name: string): string {
|
||||
const t = name.trim();
|
||||
if (!t) return t;
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||
// Clicking an already-selected port deselects it (port → None).
|
||||
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
status: AGStatus;
|
||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||
onClose: () => void;
|
||||
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const allAntennas = status.antennas ?? [];
|
||||
|
||||
// Band filter (persisted, default ON): show only antennas whose band mask
|
||||
// includes the current band — matching the native 4O3A app. Antennas with an
|
||||
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
|
||||
// band we can't map, or masks the device didn't send) we fall back to the full
|
||||
// list so the panel is never mysteriously empty.
|
||||
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
|
||||
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
|
||||
const bit = bandBit(band);
|
||||
let list = allAntennas;
|
||||
if (bandFilter && bit !== 0) {
|
||||
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={toggleBandFilter}
|
||||
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
|
||||
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
|
||||
<ListFilter className="size-3.5" />
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{!status.connected ? (
|
||||
<div className="text-center py-6 text-xs space-y-2">
|
||||
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||
{status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||
) : list.map((a) => {
|
||||
const aActive = status.port_a === a.index;
|
||||
const bActive = status.port_b === a.index;
|
||||
const aTx = aActive && !!status.tx_a;
|
||||
const bTx = bActive && !!status.tx_b;
|
||||
const nameCls = (aTx || bTx)
|
||||
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
|
||||
: aActive
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
|
||||
: bActive
|
||||
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
return (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
// Above this many references the editor stops loading the whole list and
|
||||
@@ -29,8 +31,9 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
|
||||
export type AwardDef = {
|
||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
@@ -102,6 +105,7 @@ function Field2({ label, children }: { label: string; children: React.ReactNode
|
||||
// DxccFilter — pick the entities an award is scoped to by country name (like the
|
||||
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
|
||||
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||
const { t } = useI18n();
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -124,7 +128,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||
<Combobox value="" options={countries} placeholder={t('awed.addCountry')} onChange={addCountry} className="h-8 w-full" />
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((n) => (
|
||||
@@ -141,6 +145,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
||||
}
|
||||
|
||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [fields, setFields] = useState<string[]>([]);
|
||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||
@@ -173,7 +178,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
loadMeta();
|
||||
}, [open]);
|
||||
|
||||
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||
// unless it has been exported. The list flags those.
|
||||
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||
}, [open]);
|
||||
|
||||
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||
const loadUpdates = useCallback(() => {
|
||||
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||
|
||||
// Pending import awaiting the operator's decision on the awards that collide.
|
||||
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!importPreview) return;
|
||||
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||
// an award because the operator clicked through a dialog without reading it.
|
||||
const d: Record<string, string> = {};
|
||||
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||
setDecisions(d);
|
||||
}, [importPreview]);
|
||||
|
||||
const cur = defs[sel];
|
||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||
|
||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||
type Rejected = { candidate: string; reason: string };
|
||||
type Step = {
|
||||
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||
skipped?: boolean; error?: string;
|
||||
};
|
||||
type Explanation = {
|
||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||
};
|
||||
type TestRow = { qso: any; explanation: Explanation };
|
||||
const [testCall, setTestCall] = useState('');
|
||||
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||
const [testErr, setTestErr] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const runTest = async () => {
|
||||
if (!cur) return;
|
||||
setTesting(true); setTestErr(''); setTestRows(null);
|
||||
try {
|
||||
const r = await ExplainAward(cur.code, testCall);
|
||||
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||
} catch (e: any) {
|
||||
setTestErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||
// so, rather than let the operator test a rule they only think they applied.
|
||||
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||
|
||||
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||
const testGroups = useMemo(() => {
|
||||
if (!testRows) return null;
|
||||
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||
for (const row of testRows) {
|
||||
const key = JSON.stringify(row.explanation);
|
||||
const g = groups.find((x) => x.key === key);
|
||||
if (g) g.rows.push(row);
|
||||
else groups.push({ key, rows: [row] });
|
||||
}
|
||||
return groups;
|
||||
}, [testRows]);
|
||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||
const arr = ((cur?.[key] as string[]) ?? []);
|
||||
@@ -208,20 +296,42 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
setErr('');
|
||||
try {
|
||||
const p = await ExportAwards();
|
||||
if (p) setErr(`Awards exported to:\n${p}`);
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import an award bundle: definitions are upserted by code, reference lists
|
||||
// replaced. Reloads the editor afterwards.
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||
// so it must not be a data-loss trap.
|
||||
async function importAwards() {
|
||||
setErr('');
|
||||
try {
|
||||
const r: any = await ImportAwards();
|
||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
||||
const p: any = await InspectAwardImport();
|
||||
if (!p?.path) return; // cancelled
|
||||
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||
if (clashes.length === 0) {
|
||||
// Nothing collides — nothing to ask. This is the common case.
|
||||
const dec: Record<string, string> = {};
|
||||
for (const e of p.awards) dec[e.code] = 'replace';
|
||||
await applyImport(p.path, dec);
|
||||
return;
|
||||
}
|
||||
setImportPreview(p); // → the collision dialog decides
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||
try {
|
||||
const r: any = await ApplyAwardImport(path, decisions);
|
||||
setImportPreview(null);
|
||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||
setDefs((d ?? []) as any); setSel(0);
|
||||
onSaved();
|
||||
setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`);
|
||||
if (r?.awards || r?.references) {
|
||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||
}
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function updateList(code: string) {
|
||||
@@ -244,7 +354,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>Award management</DialogTitle>
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
@@ -253,37 +363,91 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Input className="h-7 pl-7 text-xs" placeholder={t('awed.searchAwards')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filtered.map(({ d, i }) => (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.map(({ d, i }) => {
|
||||
// An award that is NOT in the shipped catalog exists only in THIS
|
||||
// database: nobody else has it, and a reinstall loses it unless it
|
||||
// has been exported. That deserves to be visible at a glance, not
|
||||
// discovered the hard way.
|
||||
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||
// A pending update is only reachable from the award's own banner, so
|
||||
// the list has to say which award to open — otherwise the fix waits
|
||||
// behind a click nobody knows to make.
|
||||
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||
return (
|
||||
<button key={i} onClick={() => setSel(i)}
|
||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
{hasUpdate && (
|
||||
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||
<ArrowUpCircle className="size-3.5 text-info" />
|
||||
</span>
|
||||
)}
|
||||
{onlyHere && (
|
||||
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||
title={t('awed.onlyHereTip')}>
|
||||
{t('awed.onlyHere')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||
<Plus className="size-3.5 mr-1" /> New award
|
||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title="Click to dismiss" className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||
apply it — that would destroy their work — so we offer it, and say
|
||||
plainly what accepting costs. */}
|
||||
{cur && selUpdate && (
|
||||
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||
</div>
|
||||
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ApplyAwardUpdate(cur.code);
|
||||
// The backend rewrote this award (and its references) — pull the
|
||||
// new state back, or the editor would keep showing, and on the
|
||||
// next Save re-persist, the definition we just replaced.
|
||||
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||
loadMeta();
|
||||
loadUpdates();
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateApply')}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||
onClick={async () => {
|
||||
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}}>{t('awed.updateKeepMine')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">Award info</TabsTrigger>
|
||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
||||
<TabsTrigger value="refs">References</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -291,29 +455,48 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||
the catalog derives that on load. Asking the author to tick a
|
||||
box to declare it would be one more step nobody can guess —
|
||||
forget it and the award silently misses every future catalog
|
||||
correction. "Protected" stays: whether an award can be deleted
|
||||
IS a real choice. */}
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||
</label>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||
<Field2 label={t('awed.refDisplay')}>
|
||||
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
|
||||
<Label className="text-xs text-muted-foreground pt-1.5">{t('awed.dxccFilter')}</Label>
|
||||
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validBands')}</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.emission')}</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validModes')}</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Award type ── */}
|
||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||
<Field2 label="Award type">
|
||||
<Field2 label={t('awed.awardType')}>
|
||||
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{
|
||||
@@ -324,18 +507,20 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined — any value counts, like POTA)</label>
|
||||
{/* No "allow multiple references" switch: a QSO always counts for
|
||||
every reference its field holds (an n-fer POTA activation, a
|
||||
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||
|
||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
|
||||
<Field2 label="Search in field">
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||
<Field2 label={t('awed.searchInField')}>
|
||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field2>
|
||||
<Field2 label="Match by">
|
||||
<Field2 label={t('awed.matchBy')}>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||
@@ -344,21 +529,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Additional OR searches: a QSO earns a reference if the
|
||||
primary rule OR any of these match. */}
|
||||
{/* Fallback searches: tried in order, only while nothing
|
||||
has matched yet — the first that hits wins (short-circuit),
|
||||
so a value already resolved by the primary rule isn't
|
||||
re-derived differently by a later one. */}
|
||||
<div className="border-t pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||
<Button size="sm" variant="outline" className="h-7"
|
||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||
<Plus className="size-3.5" /> Add OR
|
||||
<Plus className="size-3.5" /> {t('awed.addOr')}
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
@@ -367,7 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('awed.orSearchIn')}</span>
|
||||
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
@@ -379,12 +566,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> {t('awed.exact')}</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder={t('awed.orPatternPlaceholder')} />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -397,20 +584,117 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Confirmation (worked → confirmed)</Label>
|
||||
<Label className="text-xs font-semibold">{t('awed.confirmationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">Validation (confirmed → validated)</Label>
|
||||
<Label className="text-xs font-semibold">{t('awed.validationLabel')}</Label>
|
||||
{CONFIRM_SRC.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
|
||||
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||
did nothing at all. The stored values are kept (see award.Def);
|
||||
put the controls back when the export is actually wired up. */}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Test ──
|
||||
Runs the SAVED award against a real QSO and shows every rule:
|
||||
what it scanned, what it produced, and why each candidate was
|
||||
rejected. An award that matches nothing used to be a black box. */}
|
||||
<TabsContent value="test" className="mt-0 space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||
{t('awed.testRun')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||
</div>
|
||||
|
||||
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||
|
||||
{testGroups?.map((g, ri) => {
|
||||
const row = g.rows[0];
|
||||
const ex = row.explanation;
|
||||
const matched = (ex.result ?? []).length > 0;
|
||||
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||
return (
|
||||
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||
{g.rows.length > 1 && (
|
||||
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!ex.in_scope ? (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{(ex.steps ?? []).map((s, si) => (
|
||||
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||
</span>
|
||||
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||
</div>
|
||||
{!s.skipped && !s.error && (
|
||||
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||
{s.field_value
|
||||
? <span className="font-mono">{s.field_value}</span>
|
||||
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||
</div>
|
||||
{(s.kept ?? []).map((k) => (
|
||||
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||
))}
|
||||
{(s.rejected ?? []).map((r, i2) => (
|
||||
<div key={i2} className="font-mono text-muted-foreground">
|
||||
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
@@ -428,18 +712,85 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
|
||||
<Download className="size-3.5 mr-1" /> Export…
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
|
||||
<Upload className="size-3.5 mr-1" /> Import…
|
||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||
</Button>
|
||||
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||
then has to retype. */}
|
||||
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||
title={t('awed.awardsFolderTip')}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* Collision dialog. The import cannot silently replace an award you already
|
||||
have — importing a shared WAPC used to destroy yours (province list, city
|
||||
regexes, band scope) without a word. You decide, per award. */}
|
||||
{importPreview && (
|
||||
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||
{importPreview.awards.map((e) => (
|
||||
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('awed.importRefs', { n: e.references })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!e.exists ? (
|
||||
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{([
|
||||
['skip', t('awed.importKeepMine')],
|
||||
['replace', t('awed.importReplace')],
|
||||
['copy', t('awed.importCopy', { code: e.code })],
|
||||
] as [string, string][]).map(([v, label]) => (
|
||||
<button key={v} type="button"
|
||||
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||
decisions[e.code] === v
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -451,6 +802,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [selCode, setSelCode] = useState<string | null>(null);
|
||||
@@ -486,7 +838,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
}, [code, q, large]);
|
||||
|
||||
async function populateBuiltin() {
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -505,7 +857,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function addRef() {
|
||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||
if (!c) return;
|
||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||
await saveRef(r); setSelCode(c);
|
||||
@@ -520,7 +872,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function importBulk() {
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
|
||||
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(t('awed.importedRefsMsg', { n })); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -528,46 +880,46 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<span className="text-xs text-muted-foreground">{t('awed.referenceCount')} <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
|
||||
<div className="flex-1" />
|
||||
<Select value="" onValueChange={applyPreset}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder={t('awed.applyPreset')} /></SelectTrigger>
|
||||
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>{t('awed.pasteCsv')}</Button>
|
||||
{hasBuiltin && (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
||||
Populate built-in
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
|
||||
{t('awed.populateBuiltin')}
|
||||
</Button>
|
||||
)}
|
||||
{meta?.can_update && (
|
||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
|
||||
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} {t('awed.updateOnline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> {t('awed.add')}</Button>
|
||||
</div>
|
||||
|
||||
{showBulk && (
|
||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
||||
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('awed.onePerLine')} <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> {t('awed.replacesList')}</p>
|
||||
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>{t('awed.cancel')}</Button><Button size="sm" className="h-7" onClick={importBulk}>{t('awed.import2')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||
{/* List */}
|
||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder={t('awed.search')} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
||||
{!busy && large && q.trim().length < 2 && (
|
||||
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
|
||||
<div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
|
||||
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
|
||||
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('awed.noReferences')}</div>}
|
||||
{filtered.map((r) => (
|
||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
||||
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
|
||||
@@ -581,37 +933,29 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
{/* Per-reference editor */}
|
||||
<div className="border rounded p-3">
|
||||
{!sel ? (
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
|
||||
<div className="grid place-items-center h-full text-xs text-muted-foreground">{t('awed.selectReference')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||
<div className="flex-1" />
|
||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
</div>
|
||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||
{/* One per row: side by side, each half spends 120px of its width on
|
||||
the label column and the input is left too narrow to read a group
|
||||
name ("Basilicata" → "Basili"). */}
|
||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Score</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Bonus</Label>
|
||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Grid</Label>
|
||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||
boxes were pure decoration. The columns stay in the database — a
|
||||
third-party list may carry the values — but they are not offered
|
||||
for editing until something actually reads them. */}
|
||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||
// result assigns it to the QSO.
|
||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<Ref[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -48,14 +50,14 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||
<div className="relative" ref={boxRef}>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
|
||||
<button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={`Search ${label}…`}
|
||||
placeholder={t('awrp.searchLabel', { label })}
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
@@ -63,9 +65,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
||||
)}
|
||||
{open && !value && (
|
||||
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
|
||||
{!busy && results.length === 0 && (
|
||||
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
||||
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../.
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
@@ -52,6 +54,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||
|
||||
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||
// also blank the picker's own state — the search box and the highlighted /
|
||||
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedEntry(null);
|
||||
setSelectedRef(null);
|
||||
setQ('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||
.then(([d, m]) => {
|
||||
@@ -196,24 +210,24 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* Group / Sub from selected ref */}
|
||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span className="text-muted-foreground text-[11px]">Group</span>
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.group')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Sub</span>
|
||||
<span className="text-muted-foreground text-[11px]">{t('awrs.sub')}</span>
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* Selected ref chip */}
|
||||
{selectedRef ? (
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
||||
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||
<button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
||||
← pick a reference
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -224,10 +238,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
onClick={() => selectedRef && addRef(selectedRef)}
|
||||
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="size-3" />Add
|
||||
<Plus className="size-3" />{t('awrs.add')}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
||||
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +250,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{/* Added refs list */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
|
||||
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
@@ -261,7 +275,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
|
||||
{/* Right panel: reference search */}
|
||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
||||
<span className="text-xs font-semibold">References</span>
|
||||
<span className="text-xs font-semibold">{t('awrs.references')}</span>
|
||||
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||
{autoMatch && (
|
||||
<button
|
||||
@@ -270,20 +284,20 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||
autoAlreadyAdded
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
||||
? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
|
||||
: 'border-success-border bg-success-muted/60 text-success-muted-foreground hover:bg-success-muted'
|
||||
}`}
|
||||
title={`The ${selField.toUpperCase()} field is ${autoMatch.code} — this award counts it automatically`}
|
||||
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||
>
|
||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||
<span className="text-emerald-700"> from {selField}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto — click to add</span>}
|
||||
<span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
|
||||
{!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Search…"
|
||||
placeholder={t('awrs.search')}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -294,22 +308,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
type="button"
|
||||
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
||||
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
||||
title="Add this reference even though it isn't in the list yet (new / unlisted)"
|
||||
title={t('awrs.addUnlistedTitle')}
|
||||
>
|
||||
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
||||
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||
{busy && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />Searching…
|
||||
<Loader2 className="size-3 animate-spin" />{t('awrs.searching')}
|
||||
</div>
|
||||
)}
|
||||
{/* Too many auto-results → require typed search */}
|
||||
{!busy && q.length < 2 && tooManyAuto && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
Type 2+ chars to search
|
||||
{t('awrs.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||
@@ -317,16 +331,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
{isDynamic && !dxcc
|
||||
? 'Enter a callsign, or type to search.'
|
||||
: 'No references for this entity.'}
|
||||
? t('awrs.enterCallsignOrSearch')
|
||||
: t('awrs.noRefsForEntity')}
|
||||
</div>
|
||||
)}
|
||||
{/* Typed search, no results */}
|
||||
{!busy && q.length >= 2 && results.length === 0 && (
|
||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||
No results.
|
||||
{t('awrs.noResults')}
|
||||
<br />
|
||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
||||
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
@@ -35,9 +36,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
return 'none';
|
||||
}
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-emerald-500 text-white',
|
||||
confirmed: 'bg-amber-400 text-amber-950',
|
||||
worked: 'bg-stone-400 text-white',
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
worked: 'bg-muted-foreground text-background',
|
||||
none: '',
|
||||
};
|
||||
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||
@@ -52,15 +53,16 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
||||
if (total <= 0) return null;
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||
<div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||
|
||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||
// Computed results are cached per award code — each award is scanned only the
|
||||
// first time it's selected (or when explicitly rescanned).
|
||||
@@ -207,23 +209,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<AwardIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Awards</span>
|
||||
<span className="text-sm font-semibold">{t('awp.awards')}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||
title={t('awp.rescanTitle')}>
|
||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
Rescan
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* Quick selector — scales when there are many awards. */}
|
||||
{awardList.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/40">
|
||||
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
{awardList.map((a) => (
|
||||
<SelectItem key={a.code} value={a.code} className="text-xs">
|
||||
@@ -253,9 +255,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="font-semibold text-sm">{a.code}</span>
|
||||
{r ? (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
<span className="text-emerald-600">{r.confirmed}</span>
|
||||
<span className="text-success">{r.confirmed}</span>
|
||||
/<span className="text-foreground">{r.worked}</span>
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
||||
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||
@@ -273,7 +275,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{!current ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{loading ? 'Computing…' : 'No data'}
|
||||
{loading ? t('awp.computing') : t('awp.noData')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -283,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
|
||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||
{current.total > 0 && (
|
||||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||
@@ -296,12 +298,12 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{/* Band breakdown */}
|
||||
{(current.bands ?? []).length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(current.bands ?? []).map((b) => (
|
||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||
<span className="text-success font-mono">{b.confirmed}</span>
|
||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -313,35 +315,35 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||
title={t('awp.missingRefsTitle')}
|
||||
>
|
||||
<AlertTriangle className="size-3" /> Missing refs
|
||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -349,15 +351,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{view === 'stats' ? (
|
||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||
{statsLoading || !stats ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
|
||||
) : (
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -368,7 +370,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||
); })}
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||
@@ -384,8 +386,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
@@ -405,7 +407,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
)}
|
||||
@@ -422,11 +424,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
|
||||
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||
<th className="py-1.5 font-medium">Bands</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -436,14 +438,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
||||
: <span className="text-stone-500">worked</span>}
|
||||
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
|
||||
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
|
||||
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-muted-foreground">
|
||||
{r.bands.map((b) => (
|
||||
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
||||
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -472,6 +474,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||
|
||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
@@ -534,7 +537,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
const done = new Set(assignedIds);
|
||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||
setSel(new Set());
|
||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
@@ -555,27 +558,26 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||
<AlertTriangle className="size-4 text-amber-600" />
|
||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
||||
<AlertTriangle className="size-4 text-warning" />
|
||||
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
|
||||
title="Recompute now — contacts you've fixed drop off the list">
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
|
||||
title={t('awp.recomputeTitle')}>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
|
||||
</div>
|
||||
|
||||
{/* Bulk-assign toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||
<span className="text-xs text-muted-foreground">{sel.size} selected →</span>
|
||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{refs.map((r) => (
|
||||
<SelectItem key={r.code} value={r.code}>
|
||||
@@ -585,29 +587,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||
</Button>
|
||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
|
||||
{t('awp.noGaps')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
||||
<SortTh k="callsign" label="Callsign" />
|
||||
<SortTh k="band" label="Band" />
|
||||
<SortTh k="mode" label="Mode" />
|
||||
<SortTh k="country" label="Country" />
|
||||
<SortTh k="qth" label="QTH / Note" />
|
||||
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
|
||||
<SortTh k="callsign" label={t('awp.callsign')} />
|
||||
<SortTh k="band" label={t('awp.band')} />
|
||||
<SortTh k="mode" label={t('awp.mode')} />
|
||||
<SortTh k="country" label={t('awp.country')} />
|
||||
<SortTh k="qth" label={t('awp.qthNote')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -633,8 +635,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
|
||||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
||||
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -643,6 +645,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
@@ -666,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading…</div>
|
||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
|
||||
) : qsos.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
|
||||
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qsos.map((q, i) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
@@ -34,6 +35,12 @@ interface Props {
|
||||
onClose?: () => void;
|
||||
side?: 'left' | 'right';
|
||||
onToggleSide?: () => void;
|
||||
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
|
||||
// watering holes don't hog the map. fitToBand overrides zoom so the whole
|
||||
// band edge-to-edge fits the visible height (no scrolling). Both are driven
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
@@ -54,17 +61,17 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
};
|
||||
|
||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
@@ -79,13 +86,13 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string): string {
|
||||
function statusLabel(s: string, t: (k: string) => string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
case 'new': return t('bmp.statusNew');
|
||||
case 'new-band': return t('bmp.statusNewBand');
|
||||
case 'new-slot': return t('bmp.statusNewSlot');
|
||||
case 'worked': return t('bmp.statusWorked');
|
||||
default: return t('bmp.statusUnresolved');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,22 +103,22 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
||||
// dot = small marker on the freq scale
|
||||
switch (s) {
|
||||
case 'new': return {
|
||||
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
||||
bar: 'bg-rose-500',
|
||||
line: 'stroke-rose-400',
|
||||
dot: 'fill-rose-500',
|
||||
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||
bar: 'bg-danger',
|
||||
line: 'stroke-danger',
|
||||
dot: 'fill-danger',
|
||||
};
|
||||
case 'new-band': return {
|
||||
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
||||
bar: 'bg-amber-500',
|
||||
line: 'stroke-amber-400',
|
||||
dot: 'fill-amber-500',
|
||||
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||||
bar: 'bg-warning',
|
||||
line: 'stroke-warning',
|
||||
dot: 'fill-warning',
|
||||
};
|
||||
case 'new-slot': return {
|
||||
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
||||
bar: 'bg-yellow-500',
|
||||
line: 'stroke-yellow-500',
|
||||
dot: 'fill-yellow-500',
|
||||
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||
bar: 'bg-caution',
|
||||
line: 'stroke-caution',
|
||||
dot: 'fill-caution',
|
||||
};
|
||||
case 'worked': return {
|
||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||
@@ -146,10 +153,11 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
const [zoomIdx, setZoomIdx] = useState(0);
|
||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerH, setContainerH] = useState(400);
|
||||
|
||||
@@ -165,7 +173,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
const fallback: [number, number] = range ?? [0, 1];
|
||||
const [lo, hi] = fallback;
|
||||
const span = hi - lo;
|
||||
const pxPerKHz = PX_PER_KHZ[zoomIdx];
|
||||
// Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
|
||||
// the visible height; otherwise use the discrete zoom step.
|
||||
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
|
||||
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
|
||||
: 0;
|
||||
const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];
|
||||
|
||||
// Anti-overlap layout: each label wants to sit at its true freq, but
|
||||
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
||||
@@ -197,7 +210,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// spots carry no mode word and the band-plan fallback labels them the
|
||||
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||
const ftSpots = inBand.filter(isFlood);
|
||||
// hideDigital removes them entirely; otherwise they're capped below.
|
||||
const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
|
||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||
|
||||
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||
@@ -270,48 +284,39 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||
}
|
||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||
// In fit mode the band scale exactly fills the viewport (innerH = containerH
|
||||
// − pads); when crowded sub-bands stack labels past the bottom the content
|
||||
// grows and a scroll bar appears — the map defaults to the top so the whole
|
||||
// band is visible, and you scroll down to reach the stacked spots.
|
||||
return {
|
||||
placed: out,
|
||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||
hidden: hiddenCount,
|
||||
};
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);
|
||||
|
||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||
|
||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
||||
// for the scroller height to be measured and a valid in-band rig freq.
|
||||
const centeredForRef = useRef<string>('');
|
||||
// Keep the map centred on the rig frequency: the effect re-runs only when the
|
||||
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||||
// while a stable frequency leaves your manual scroll alone.
|
||||
useEffect(() => {
|
||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
if (centeredForRef.current === band) return;
|
||||
if (!range || containerH <= 0) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
centeredForRef.current = band;
|
||||
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||||
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||||
if (fitToBand) { el.scrollTop = 0; return; }
|
||||
if (currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
||||
|
||||
// Re-centre on the rig frequency whenever the zoom level changes — zooming
|
||||
// would otherwise drift away from where you're operating. Skips the initial
|
||||
// mount (the band-centre effect above handles that).
|
||||
const firstZoomRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstZoomRef.current) { firstZoomRef.current = false; return; }
|
||||
const el = scrollerRef.current;
|
||||
if (!el || !range || containerH <= 0) return;
|
||||
const kHz = currentFreqHz && currentFreqHz / 1000 >= lo && currentFreqHz / 1000 <= hi
|
||||
? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomIdx]);
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
@@ -330,8 +335,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
<div className="text-sm font-semibold mb-1">Band map</div>
|
||||
Not configured for {band || '—'}.
|
||||
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||
{t('bmp.notConfigured', { band: band || '—' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -363,35 +368,35 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
|
||||
<span className="flex-1">Map · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom out">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomOut')}>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title="Zoom in">
|
||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||
title={t('bmp.zoomIn')}>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
<button type="button" onClick={recenterOnRig}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Scroll to current rig frequency">
|
||||
title={t('bmp.scrollToRig')}>
|
||||
<Crosshair className="size-3" />
|
||||
</button>
|
||||
{onToggleSide && (
|
||||
<button type="button" onClick={onToggleSide}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
|
||||
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
|
||||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}
|
||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||
title="Hide band map">
|
||||
title={t('bmp.hide')}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
@@ -496,7 +501,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -515,14 +520,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
</div>
|
||||
{/* Colour legend — what each pill colour means. */}
|
||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
||||
{t('bmp.footerHint')}
|
||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,22 +51,27 @@ function classMatchesMode(cls: string, mode: string): boolean {
|
||||
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
||||
}
|
||||
|
||||
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
|
||||
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
|
||||
// light and dark surfaces (the generic status -muted fills were too dark on the
|
||||
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
|
||||
// the original emerald/indigo/stone colours exactly.
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
||||
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
||||
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
||||
call_c: 'bg-mx-call-conf',
|
||||
call_w: 'bg-mx-call-work',
|
||||
dxcc_c: 'bg-mx-dx-conf',
|
||||
dxcc_w: 'bg-mx-dx-work',
|
||||
};
|
||||
|
||||
// Legend entries, in the same colour order as the cells. swatch = the
|
||||
// background class (or a special ring marker for the current-entry cell).
|
||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
|
||||
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
||||
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
||||
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
||||
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
||||
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
||||
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
||||
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
||||
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
||||
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
||||
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
||||
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
||||
];
|
||||
|
||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||
@@ -128,18 +133,18 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
<section
|
||||
className={cn(
|
||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
<Star className="size-3 fill-current" />
|
||||
NEW ONE
|
||||
</Badge>
|
||||
<span className="text-xs text-amber-900">
|
||||
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
<span className="text-xs text-warning-muted-foreground">
|
||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
{' '}· never worked this entity
|
||||
</span>
|
||||
</>
|
||||
@@ -161,10 +166,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</span>
|
||||
{(newBand || newMode || newBandMode || newSlot) && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
{newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||
{newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||
{newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -220,8 +225,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
className={cn(
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -240,7 +245,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
className={cn(
|
||||
'inline-block size-3 rounded shrink-0',
|
||||
l.swatch,
|
||||
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
||||
l.ring && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
{l.label}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text';
|
||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||
@@ -17,52 +18,81 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||
// the backend BulkUpdateField whitelist.
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||
// Contest
|
||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
||||
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
||||
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
||||
// Propagation / satellite
|
||||
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
// Contacted station (activation refs / SIG)
|
||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||
// Misc
|
||||
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
||||
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
|
||||
];
|
||||
|
||||
// label holds an i18n key.
|
||||
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
||||
{ v: 'N', label: 'N — No' },
|
||||
{ v: 'R', label: 'R — Requested' },
|
||||
{ v: 'I', label: 'I — Ignore' },
|
||||
{ v: '_', label: '(blank — clear)' },
|
||||
{ v: 'Y', label: 'bulk.statusY' },
|
||||
{ v: 'N', label: 'bulk.statusN' },
|
||||
{ v: 'R', label: 'bulk.statusR' },
|
||||
{ v: 'I', label: 'bulk.statusI' },
|
||||
{ v: '_', label: 'bulk.statusBlank' },
|
||||
];
|
||||
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
||||
// Maps the internal group key → its i18n label key.
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'QSL / upload': 'bulk.groupQsl',
|
||||
'My station': 'bulk.groupMyStation',
|
||||
'Contacted station': 'bulk.groupContacted',
|
||||
'Contest': 'bulk.groupContest',
|
||||
'Propagation': 'bulk.groupPropagation',
|
||||
'Misc': 'bulk.groupMisc',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -75,6 +105,7 @@ type Props = {
|
||||
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
@@ -105,60 +136,62 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk edit field</DialogTitle>
|
||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||
This overwrites the current value — there is no undo.
|
||||
{t('bulk.desc', { n: ids.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-2 space-y-3">
|
||||
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Field</Label>
|
||||
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
|
||||
<Select value={field} onValueChange={setField}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
||||
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||
))}
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||
{FIELDS.filter((f) => f.group === g)
|
||||
.map((f) => ({ f, txt: t(f.label) }))
|
||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||
.map(({ f, txt }) => (
|
||||
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">Value</Label>
|
||||
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
||||
{isStatus ? (
|
||||
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={textValue}
|
||||
placeholder="leave empty to clear the field"
|
||||
placeholder={t('bulk.clearPlaceholder')}
|
||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
</div>
|
||||
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
|
||||
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
Apply to {ids.length}
|
||||
{t('bulk.applyTo', { n: ids.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -26,6 +27,7 @@ function fmtDateTime(s: any): string {
|
||||
}
|
||||
|
||||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
const hasCall = currentCall.trim() !== '';
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
@@ -35,7 +37,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Worked before
|
||||
{t('chp.workedBefore')}
|
||||
</span>
|
||||
{hasCall && (
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||
@@ -52,54 +54,54 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
|
||||
{!hasCall ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign to see prior contacts.
|
||||
{t('chp.typeCallsign')}
|
||||
</div>
|
||||
) : busy && count === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('chp.checking')}
|
||||
</div>
|
||||
) : count === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-6 text-primary fill-current" />
|
||||
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with this callsign.</div>
|
||||
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||||
<div>{t('chp.noPriorQso')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-stone-50">
|
||||
<tr key={e.id} className="hover:bg-card">
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
|
||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||
{e.lotw_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||
)}
|
||||
{e.qsl_rcvd === 'Y' && (
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
|
||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -108,7 +110,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||
</table>
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||||
+ {count - entries.length} older QSOs
|
||||
{t('chp.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||
@@ -18,6 +19,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||
onSend: (text: string) => void; onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const me = (myCall || '').toUpperCase();
|
||||
@@ -33,26 +35,38 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
||||
<MessageSquare className="size-4 text-sky-600" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
||||
<MessageSquare className="size-4 text-info" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||
<span className="flex-1" />
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground" title={online.map((o) => o.operator).join(', ')}>
|
||||
{online.length}
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
||||
{/* Online count — hover to see who's connected. */}
|
||||
<div className="relative group">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||
<Users className="size-3.5" />{online.length}
|
||||
</span>
|
||||
{online.length > 0 && (
|
||||
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
|
||||
{online.map((o) => (
|
||||
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||
{msgs.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
|
||||
<div className="text-muted-foreground italic text-center py-6">{t('chatp.noMessages')}</div>
|
||||
) : msgs.map((m) => {
|
||||
const mine = m.operator.toUpperCase() === me;
|
||||
return (
|
||||
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
|
||||
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-info-muted text-info-muted-foreground' : 'bg-muted')}>
|
||||
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||
</div>
|
||||
@@ -67,7 +81,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="Message…"
|
||||
placeholder={t('chatp.messagePh')}
|
||||
maxLength={1000}
|
||||
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import {
|
||||
@@ -12,30 +13,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
export type ClusterSpot = {
|
||||
source_id: number;
|
||||
@@ -117,53 +101,60 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const COL_CATALOG: ColEntry[] = [
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: 'Time', colId: 'time',
|
||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Call', colId: 'call',
|
||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
||||
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
||||
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
||||
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
||||
// the rest of the row across every theme instead of always shouting orange.
|
||||
cellStyle: (p: any): any => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
||||
return { fontWeight: 700 };
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Status', colId: 'status',
|
||||
headerName: 'Status', width: 96, sortable: true,
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(statusFor(p));
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
@@ -174,88 +165,98 @@ const COL_CATALOG: ColEntry[] = [
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||
if (s?.worked_call) return 'Already worked this call';
|
||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#166534' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Band', colId: 'band',
|
||||
headerName: 'Band', field: 'band' as any, width: 75,
|
||||
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
||||
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
||||
: undefined),
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||
// Status badge text tells them apart.
|
||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||
return undefined;
|
||||
},
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Country', colId: 'country',
|
||||
headerName: 'Country', width: 140,
|
||||
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||
headerName: t('clg2.c.country'), width: 140,
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -263,8 +264,8 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
@@ -272,50 +273,57 @@ const COL_CATALOG: ColEntry[] = [
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Source', colId: 'source',
|
||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
|
||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), []);
|
||||
}), [COL_CATALOG]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -327,6 +335,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// change below.
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||
// turns blue once its status loads.
|
||||
useEffect(() => {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, [spotStatus]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
@@ -384,11 +400,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('clg2.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -417,9 +433,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cluster columns</DialogTitle>
|
||||
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Cluster table.
|
||||
{t('clg2.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
@@ -429,10 +445,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@@ -451,8 +467,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
|
||||
// so a running contest (serial counter included) survives a restart.
|
||||
export type ContestSession = {
|
||||
active: boolean;
|
||||
code: string; // ADIF CONTEST_ID written to each QSO
|
||||
name: string;
|
||||
exchange: 'serial' | 'fixed';
|
||||
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
|
||||
sentFixed: string; // the value YOU send when the exchange is fixed
|
||||
nextSerial: number; // running sent serial number
|
||||
startISO: string; // contest window start (UTC ISO) — only QSOs after count
|
||||
endISO: string; // contest window end (UTC ISO, empty = open)
|
||||
};
|
||||
|
||||
export const CONTEST_DEFAULT: ContestSession = {
|
||||
active: false, code: '', name: '', exchange: 'serial',
|
||||
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
|
||||
startISO: '', endISO: '',
|
||||
};
|
||||
|
||||
// Contests run on UTC, so the date/time inputs are treated as UTC.
|
||||
const utcInputToISO = (v: string) => (v ? new Date(v + ':00Z').toISOString() : '');
|
||||
const isoToUtcInput = (iso: string) => (iso ? new Date(iso).toISOString().slice(0, 16) : '');
|
||||
|
||||
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
|
||||
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
|
||||
};
|
||||
|
||||
type Def = { code: string; name: string; exchange: string };
|
||||
type Stats = { qsos: number; by_band?: { band: string; count: number }[] | null; mult: number; mult_label: string; score: number; last_hour: number };
|
||||
|
||||
function Stat({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2 text-center">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="text-xl font-black tabular-nums" style={accent ? { color: accent } : undefined}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContestPanel({ session, onChange }: {
|
||||
session: ContestSession;
|
||||
onChange: (patch: Partial<ContestSession>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [contests, setContests] = useState<Def[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
|
||||
|
||||
// Poll the scoreboard while a contest is running.
|
||||
useEffect(() => {
|
||||
if (!session.active || !session.code) { setStats(null); return; }
|
||||
let alive = true;
|
||||
const load = () => ContestStats(
|
||||
session.code,
|
||||
session.exchange === 'fixed' ? kindKey(session.fixedKind) : 'serial',
|
||||
session.startISO, session.endISO,
|
||||
).then((s) => { if (alive) setStats(s as Stats); }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 4000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [session.active, session.code, session.exchange, session.fixedKind, session.startISO, session.endISO]);
|
||||
|
||||
const pickContest = (code: string) => {
|
||||
const def = contests.find((c) => c.code === code);
|
||||
if (!def) { onChange({ code: '', name: '' }); return; }
|
||||
if (def.exchange === 'serial' || def.exchange === 'rst') {
|
||||
onChange({ code: def.code, name: def.name, exchange: 'serial' });
|
||||
} else {
|
||||
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-4">
|
||||
{/* Setup card. */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||
<Trophy className="size-4 text-warning" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
||||
{session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
|
||||
<option value="">{t('ctp.chooseContest')}</option>
|
||||
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
</select>
|
||||
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{(['serial', 'fixed'] as const).map((x) => (
|
||||
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
|
||||
className={cn('px-3 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{x === 'serial' ? t('ctp.serial') : t('ctp.fixedExchange')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{session.exchange === 'serial' ? (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('ctp.nextSerial')}
|
||||
<input type="number" min={1} value={session.nextSerial}
|
||||
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
|
||||
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm">
|
||||
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
|
||||
placeholder={t('ctp.yourExchangePh')} className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contest window (UTC). Only QSOs inside it count toward the score and
|
||||
dupe check — so old runs of the same contest aren't included. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('ctp.startUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
|
||||
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('ctp.endUtc')}
|
||||
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
|
||||
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{session.active ? (
|
||||
<button type="button" onClick={() => onChange({ active: false })}
|
||||
className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
|
||||
{t('ctp.stopContest')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={!session.code}
|
||||
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
||||
className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
|
||||
{t('ctp.startContest')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scoreboard. */}
|
||||
{session.active && (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.scoreboard')}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
|
||||
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
|
||||
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
|
||||
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
|
||||
</div>
|
||||
{stats && stats.by_band && stats.by_band.length > 0 && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
|
||||
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{stats.by_band.map((r) => (
|
||||
<tr key={r.band} className="border-t border-border/60">
|
||||
<td className="px-3 py-1 font-mono">{r.band}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums">{r.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// kindKey maps a fixed-kind label back to the backend's exchange key for mult
|
||||
// counting.
|
||||
function kindKey(label: string): string {
|
||||
const m: Record<string, string> = {
|
||||
'CQ Zone': 'cq-zone', 'ITU Zone': 'itu-zone', 'State/Prov': 'state',
|
||||
'Département': 'dept', 'Grid': 'grid', 'Name': 'name', 'Section': 'section', 'Age': 'age',
|
||||
};
|
||||
return m[label] || 'state';
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -35,8 +36,11 @@ export interface DetailsState {
|
||||
sat_name: string;
|
||||
sat_mode: string;
|
||||
contest_id: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
// Contest exchanges as free text — most are serials (001) but some are
|
||||
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
|
||||
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
|
||||
srx_string?: string;
|
||||
stx_string?: string;
|
||||
email: string;
|
||||
// Award references for the contacted station (set via the Awards tab picker).
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||
@@ -72,35 +76,35 @@ export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||
const PROP_MODES: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'AS', label: 'Aircraft Scatter' },
|
||||
{ value: 'AUR', label: 'Aurora' },
|
||||
{ value: 'AUE', label: 'Aurora-E' },
|
||||
{ value: 'BS', label: 'Back Scatter' },
|
||||
{ value: 'AS', label: 'detp.propAS' },
|
||||
{ value: 'AUR', label: 'detp.propAUR' },
|
||||
{ value: 'AUE', label: 'detp.propAUE' },
|
||||
{ value: 'BS', label: 'detp.propBS' },
|
||||
{ value: 'ECH', label: 'EchoLink' },
|
||||
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
||||
{ value: 'ES', label: 'Sporadic E' },
|
||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
||||
{ value: 'F2', label: 'F2 Reflection' },
|
||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
||||
{ value: 'ION', label: 'Ionoscatter' },
|
||||
{ value: 'EME', label: 'detp.propEME' },
|
||||
{ value: 'ES', label: 'detp.propES' },
|
||||
{ value: 'FAI', label: 'detp.propFAI' },
|
||||
{ value: 'F2', label: 'detp.propF2' },
|
||||
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
||||
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
||||
{ value: 'ION', label: 'detp.propION' },
|
||||
{ value: 'IRL', label: 'IRLP' },
|
||||
{ value: 'LOS', label: 'Line of Sight' },
|
||||
{ value: 'MS', label: 'Meteor Scatter' },
|
||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
||||
{ value: 'RS', label: 'Rain Scatter' },
|
||||
{ value: 'SAT', label: 'Satellite' },
|
||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
||||
{ value: 'LOS', label: 'detp.propLOS' },
|
||||
{ value: 'MS', label: 'detp.propMS' },
|
||||
{ value: 'RPT', label: 'detp.propRPT' },
|
||||
{ value: 'RS', label: 'detp.propRS' },
|
||||
{ value: 'SAT', label: 'detp.propSAT' },
|
||||
{ value: 'TEP', label: 'detp.propTEP' },
|
||||
{ value: 'TR', label: 'detp.propTR' },
|
||||
];
|
||||
|
||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||
const ANT_PATHS: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'S', label: 'Short Path' },
|
||||
{ value: 'L', label: 'Long Path' },
|
||||
{ value: 'G', label: 'Grayline' },
|
||||
{ value: 'O', label: 'Other' },
|
||||
{ value: 'S', label: 'detp.pathShort' },
|
||||
{ value: 'L', label: 'detp.pathLong' },
|
||||
{ value: 'G', label: 'detp.pathGrayline' },
|
||||
{ value: 'O', label: 'detp.pathOther' },
|
||||
];
|
||||
|
||||
function numOrUndef(v: string): number | undefined {
|
||||
@@ -120,6 +124,7 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
@@ -188,11 +193,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||
{ key: 'info', label: `Info (${fk}2)` },
|
||||
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||
{ key: 'my', label: `My (${fk}4)` },
|
||||
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
||||
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
||||
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
||||
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
||||
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -214,7 +219,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
@@ -223,27 +228,27 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="County">
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Prefix">
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label="CQ zone">
|
||||
<Field label={t('detp.cqZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
<Field label="ITU zone">
|
||||
<Field label={t('detp.ituZone')}>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</Field>
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label="DXCC #">
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -252,7 +257,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Azimuth LP">
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -261,7 +266,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance SP">
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -270,7 +275,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance LP">
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -279,13 +284,13 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address" span={3}>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL message" span={3}>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL via" span={2}>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -302,7 +307,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
@@ -315,49 +320,49 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'my' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Azimuth (°)">
|
||||
<Field label={t('detp.azimuth')}>
|
||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Elevation (°)">
|
||||
<Field label={t('detp.elevation')}>
|
||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="TX power (W)">
|
||||
<Field label={t('detp.txPower')}>
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<div className="col-span-3 flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
Satellite mode
|
||||
{t('detp.satelliteMode')}
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Ant. path" span={2}>
|
||||
<Field label={t('detp.antPath')} span={2}>
|
||||
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Propagation" span={4}>
|
||||
<Field label={t('detp.propagation')} span={4}>
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Rig" span={3}>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Antenna" span={3}>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label="Satellite name" span={3}>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Satellite mode" span={3}>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
@@ -367,16 +372,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
|
||||
{open === 'extended' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Contest ID" span={2}>
|
||||
<Field label={t('detp.contestId')} span={2}>
|
||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="SRX">
|
||||
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
||||
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="STX">
|
||||
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Contacted email" span={3}>
|
||||
<Field label={t('detp.contactedEmail')} span={3}>
|
||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, Loader2, Trash2, X } from 'lucide-react';
|
||||
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type QSO = {
|
||||
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
|
||||
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
|
||||
};
|
||||
type Group = { key: string; qsos: QSO[] };
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
};
|
||||
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
|
||||
|
||||
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
|
||||
// same day; optionally same minute), groups them, and lets the operator pick
|
||||
// which copies to delete. The oldest in each group is left unchecked by default.
|
||||
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = (w: number) => {
|
||||
setLoading(true);
|
||||
FindDuplicates(w)
|
||||
.then((g) => {
|
||||
const gs = (g ?? []) as Group[];
|
||||
setGroups(gs);
|
||||
// Default: select every QSO except the first (oldest) of each group.
|
||||
const next = new Set<number>();
|
||||
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
|
||||
setSel(next);
|
||||
})
|
||||
.catch(() => { setGroups([]); setSel(new Set()); })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
|
||||
|
||||
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
|
||||
const deselectAll = () => setSel(new Set());
|
||||
|
||||
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (sel.size === 0) return;
|
||||
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await DeleteQSOs(Array.from(sel) as any);
|
||||
onDeleted();
|
||||
load(windowMin); // rescan so the list reflects what's left
|
||||
} catch { /* ignore */ } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
|
||||
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||
<Copy className="size-4 text-primary" />
|
||||
<span className="font-bold">{t('dup.title')}</span>
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-5 py-3 border-b border-border/60 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('dup.window')}
|
||||
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
|
||||
<option value={0}>{t('dup.sameDay')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
|
||||
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
|
||||
<span className="font-mono">{g.qsos[0].callsign}</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr className="border-b border-border/60">
|
||||
<th className="w-8"></th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.qsos.map((q, i) => {
|
||||
const checked = sel.has(q.id);
|
||||
return (
|
||||
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
|
||||
<td className="text-center py-1">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||
{fmtDate(q.qso_date)}
|
||||
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
||||
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
||||
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
||||
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
{t('dup.deleteSel', { n: sel.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
@@ -17,19 +18,20 @@ type Props = {
|
||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const anyAudio = messages.some((m) => m.has_audio);
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||
<Mic className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||
<Square className="size-3" /> Stop
|
||||
<Square className="size-3" /> {t('dvkp.stop')}
|
||||
</Button>
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -38,7 +40,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
{!anyAudio ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
||||
<Radio className="size-5 opacity-50" />
|
||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
@@ -48,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
||||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||
m.has_audio
|
||||
@@ -57,7 +59,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? 'message' : '—')}</span>
|
||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
|
||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||
// adds field/operator/value conditions, joins them with AND or OR, and can
|
||||
@@ -27,83 +28,83 @@ export interface QueryFilter {
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'Callsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
|
||||
{ value: 'band', label: 'Band', type: 'text' },
|
||||
{ value: 'band_rx', label: 'RX band', type: 'text' },
|
||||
{ value: 'mode', label: 'Mode', type: 'text' },
|
||||
{ value: 'submode', label: 'Submode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
|
||||
{ value: 'name', label: 'Name', type: 'text' },
|
||||
{ value: 'qth', label: 'QTH', type: 'text' },
|
||||
{ value: 'address', label: 'Address', type: 'text' },
|
||||
{ value: 'email', label: 'E-mail', type: 'text' },
|
||||
{ value: 'grid', label: 'Grid', type: 'text' },
|
||||
{ value: 'country', label: 'Country', type: 'text' },
|
||||
{ value: 'state', label: 'State', type: 'text' },
|
||||
{ value: 'cnty', label: 'County', type: 'text' },
|
||||
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
|
||||
{ value: 'cont', label: 'Continent', type: 'text' },
|
||||
{ value: 'cqz', label: 'CQ zone', type: 'number' },
|
||||
{ value: 'ituz', label: 'ITU zone', type: 'number' },
|
||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
||||
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
|
||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
|
||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'Satellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
|
||||
{ value: 'operator', label: 'Operator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
||||
{ value: 'my_state', label: 'My state', type: 'text' },
|
||||
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
||||
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
||||
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
||||
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
||||
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
||||
{ value: 'my_street', label: 'My street', type: 'text' },
|
||||
{ value: 'my_city', label: 'My city', type: 'text' },
|
||||
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
||||
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
||||
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
|
||||
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
|
||||
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
|
||||
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
|
||||
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
|
||||
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
|
||||
{ value: 'name', label: 'fltb.fName', type: 'text' },
|
||||
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
|
||||
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
|
||||
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
|
||||
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
|
||||
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
|
||||
{ value: 'state', label: 'fltb.fState', type: 'text' },
|
||||
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
|
||||
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
|
||||
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
|
||||
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
|
||||
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
|
||||
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
|
||||
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
|
||||
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
|
||||
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
|
||||
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
|
||||
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
|
||||
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
|
||||
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
|
||||
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
|
||||
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
|
||||
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
|
||||
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
|
||||
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
|
||||
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
|
||||
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
|
||||
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
|
||||
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
|
||||
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
|
||||
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
|
||||
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
|
||||
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
|
||||
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
|
||||
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
|
||||
];
|
||||
|
||||
const OPS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'eq', label: 'equals (=)' },
|
||||
{ value: 'ne', label: 'not equal (≠)' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'startswith', label: 'starts with' },
|
||||
{ value: 'endswith', label: 'ends with' },
|
||||
{ value: 'gt', label: 'greater than (>)' },
|
||||
{ value: 'lt', label: 'less than (<)' },
|
||||
{ value: 'ge', label: 'greater or equal (≥)' },
|
||||
{ value: 'le', label: 'less or equal (≤)' },
|
||||
{ value: 'empty', label: 'is empty' },
|
||||
{ value: 'notempty', label: 'is not empty' },
|
||||
{ value: 'eq', label: 'fltb.opEq' },
|
||||
{ value: 'ne', label: 'fltb.opNe' },
|
||||
{ value: 'contains', label: 'fltb.opContains' },
|
||||
{ value: 'startswith', label: 'fltb.opStartsWith' },
|
||||
{ value: 'endswith', label: 'fltb.opEndsWith' },
|
||||
{ value: 'gt', label: 'fltb.opGt' },
|
||||
{ value: 'lt', label: 'fltb.opLt' },
|
||||
{ value: 'ge', label: 'fltb.opGe' },
|
||||
{ value: 'le', label: 'fltb.opLe' },
|
||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||
];
|
||||
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
||||
@@ -132,6 +133,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
|
||||
// grouped by category for maintenance; the operator wants it A→Z to find things).
|
||||
const sortedFields = useMemo(
|
||||
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
|
||||
[t],
|
||||
);
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||
@@ -184,25 +192,25 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader className="px-6 py-4">
|
||||
<DialogTitle>QSO filter</DialogTitle>
|
||||
<DialogTitle>{t('fltb.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
{/* Match mode + presets */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Match</span>
|
||||
<span className="text-muted-foreground">{t('fltb.match')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{(['AND', 'OR'] as const).map((m) => (
|
||||
<button key={m} type="button" onClick={() => setMatch(m)}
|
||||
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
|
||||
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
|
||||
{m === 'AND' ? t('fltb.all') : t('fltb.any')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{presetNames.length > 0 && (
|
||||
<Select onValueChange={loadPreset}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder={t('fltb.loadPreset')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{presetNames.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
@@ -220,14 +228,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
{/* Conditions */}
|
||||
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
||||
{conditions.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">No conditions — the list shows all QSOs. Add one below.</div>
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">{t('fltb.noConditions')}</div>
|
||||
)}
|
||||
{conditions.map((c, i) => {
|
||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? t('fltb.where') : match}</span>
|
||||
<Select value={c.field} onValueChange={(v) => {
|
||||
// Reset op if the new field type doesn't allow the current one.
|
||||
const allowed = opsFor(v).map((o) => o.value);
|
||||
@@ -235,50 +243,50 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
}}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
|
||||
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{t(o.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
||||
<Plus className="size-3.5 mr-1" /> Add condition
|
||||
<Plus className="size-3.5 mr-1" /> {t('fltb.addCondition')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save preset */}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
|
||||
<Input className="h-8 w-56 text-xs" placeholder={t('fltb.presetNamePh')} value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
||||
<Save className="size-3.5 mr-1" /> Save preset
|
||||
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={apply}>Apply & close</Button>
|
||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
|
||||
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
|
||||
@@ -13,6 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
// (no callsign configured yet). It writes straight into the active profile, so
|
||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const [p, setP] = useState<Profile | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -66,50 +68,66 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||
{/* Language chooser — lives here (and not only in the localStorage-backed
|
||||
first-launch flag gate) so a fresh setup always offers EN/FR, like the
|
||||
station identity below. */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
|
||||
<button key={code} type="button" onClick={() => setLang(code)}
|
||||
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0 transition-colors',
|
||||
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
<Flag className="w-5 rounded-[2px] border border-border/30" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Radio className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||
{t('frm.intro')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||
<Label className="text-sm">{t('frm.callsign')} <span className="text-destructive">*</span></Label>
|
||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
||||
<Label className="text-sm">{t('frm.locator')} <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Operator</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.operatorPh')} value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Owner</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.owner')}</Label>
|
||||
<Input className="h-9 font-mono uppercase" placeholder={t('frm.ownerPh')} value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||
|
||||
<Label className="text-sm">Name</Label>
|
||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
<Label className="text-sm">{t('frm.name')}</Label>
|
||||
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium">Award reference lists</div>
|
||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||
<div className="font-medium">{t('frm.awardRefs')}</div>
|
||||
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
||||
</Button>
|
||||
</div>
|
||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-success' : 'text-destructive')}>{refsMsg}</div>}
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
||||
{err && <div className="mt-3 text-xs text-destructive">{err}</div>}
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
|
||||
type FlexState = {
|
||||
available: boolean; model?: string;
|
||||
@@ -18,23 +23,32 @@ type FlexState = {
|
||||
proc_enable: boolean; proc_level: number;
|
||||
mon: boolean; mon_level: number; mic_level: number;
|
||||
atu_status?: string; atu_memories: boolean;
|
||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
wnb: boolean; wnb_level: number;
|
||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||
meters?: Meter[];
|
||||
slices?: FlexSlice[];
|
||||
};
|
||||
|
||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
|
||||
|
||||
const ZERO: FlexState = {
|
||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||
amp_available: false, amp_operate: false,
|
||||
@@ -74,7 +88,7 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||
/>
|
||||
);
|
||||
@@ -102,15 +116,18 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||
}) {
|
||||
const onCls = {
|
||||
emerald: 'bg-emerald-600 border-emerald-600 text-white',
|
||||
violet: 'bg-violet-600 border-violet-600 text-white',
|
||||
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
||||
amber: 'bg-amber-500 border-amber-500 text-white',
|
||||
emerald: 'bg-success border-success text-success-foreground',
|
||||
violet: 'bg-info border-info text-info-foreground',
|
||||
cyan: 'bg-info border-info text-info-foreground',
|
||||
amber: 'bg-warning border-warning text-warning-foreground',
|
||||
}[accent];
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled}
|
||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
className={cn(
|
||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
|
||||
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -129,39 +146,106 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
|
||||
);
|
||||
}
|
||||
|
||||
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||
// driven identically.
|
||||
//
|
||||
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||
disabled?: boolean; title?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const step = useRef((_d: number) => {});
|
||||
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||
|
||||
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||
step.current(e.deltaY < 0 ? mag : -mag);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||
if (!up && !dn) return;
|
||||
e.preventDefault();
|
||||
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||
step.current(up ? mag : -mag);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={title}>
|
||||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">−</button>
|
||||
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
|
||||
</span>
|
||||
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
|
||||
</div>
|
||||
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||
const METER_SEGMENTS = 26;
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: {
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||
segColor?: (frac: number) => string;
|
||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||
}) {
|
||||
const span = hi - lo;
|
||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
||||
<div onClick={onClick} title={title}
|
||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||
{display !== undefined ? display : (
|
||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-[2px] h-2.5 items-stretch">
|
||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||
const on = i < lit;
|
||||
const frac = i / METER_SEGMENTS;
|
||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||
return (
|
||||
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100"
|
||||
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
|
||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||
style={on
|
||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,7 +262,10 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
||||
);
|
||||
}
|
||||
|
||||
export function FlexPanel() {
|
||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
const hold = useRef<Record<string, number>>({});
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
@@ -212,6 +299,16 @@ export function FlexPanel() {
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
setSt((p) => ({ ...p, [key]: val }));
|
||||
@@ -224,7 +321,22 @@ export function FlexPanel() {
|
||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||
const CW_BW = [100, 200, 300, 400, 500];
|
||||
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
|
||||
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
|
||||
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
|
||||
// 100–2790), so an exact/±50 match would leave nothing lit — "doesn't pick up
|
||||
// the current filter". Snapping to the nearest preset always reflects the rig.
|
||||
const ssbActiveBW = curBW > 0
|
||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||
: -1;
|
||||
|
||||
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
|
||||
// user wanted these in the header to save vertical space in the Meters card.
|
||||
const hdrMeters = st.meters || [];
|
||||
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const voltM = hdrFind('13.8') || hdrFind('VOLT');
|
||||
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
@@ -235,34 +347,144 @@ export function FlexPanel() {
|
||||
<Radio className="size-6 text-sky-400" />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||||
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
|
||||
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
|
||||
{!off && (voltM || patempM) && (
|
||||
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
|
||||
{voltM && (
|
||||
<span className="flex flex-col items-end leading-none">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
|
||||
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
|
||||
</span>
|
||||
)}
|
||||
{patempM && (
|
||||
<span className="flex flex-col items-end leading-none">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
|
||||
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||
!st.available ? 'bg-slate-700 text-slate-300'
|
||||
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
||||
!st.available ? 'bg-muted text-muted-foreground'
|
||||
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||||
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the
|
||||
ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow
|
||||
it. The active slice is highlighted; the TX slice is flagged. */}
|
||||
{!!st.slices && st.slices.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{st.slices.map((sl) => (
|
||||
<button key={sl.index} type="button" disabled={off}
|
||||
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
|
||||
title={t('flxp.sliceHint')}
|
||||
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
|
||||
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
|
||||
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
|
||||
<span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
|
||||
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
|
||||
</span>
|
||||
{/* TX badge — click a non-TX slice's badge to move TX onto it
|
||||
(e.g. transmit on the active slice). stopPropagation so it
|
||||
doesn't also fire the outer "make active" click. */}
|
||||
<span role="button" tabIndex={-1}
|
||||
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
|
||||
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
|
||||
className={cn('rounded text-[10px] font-bold px-1',
|
||||
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
|
||||
TX
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
|
||||
<Card icon={Gauge} title={t('flxp.meters')}>
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
if (off || meters.length === 0) {
|
||||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
||||
}
|
||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
// Radio meters (exclude the amplifier's, which we show separately).
|
||||
const radio = (name: string) => meters.find((m) =>
|
||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
|
||||
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
|
||||
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
|
||||
const sliceMeter = (name: string) => {
|
||||
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
|
||||
if (m.length === 0) return undefined;
|
||||
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
|
||||
};
|
||||
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
|
||||
const fwd = radio('FWDPWR');
|
||||
const swr = radio('SWR');
|
||||
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||
// header now). All already in the meter stream.
|
||||
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const comp = radio('COMPPEAK') || radio('COMP');
|
||||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||
const sUnit = (dbm: number) => {
|
||||
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
||||
if (sv >= 9) {
|
||||
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
||||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
||||
}
|
||||
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
||||
};
|
||||
const cur = [
|
||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||
); })(),
|
||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||
// Speech compression (dB of gain reduction).
|
||||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{off && (
|
||||
<div className="text-center text-sm text-muted-foreground py-6">
|
||||
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||||
{t('flxp.waiting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TX + RX columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* TRANSMIT */}
|
||||
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.tunePwr')}</span>
|
||||
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||||
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||
</div>
|
||||
@@ -270,16 +492,32 @@ export function FlexPanel() {
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
|
||||
st.tune ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
TUNE
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
|
||||
st.transmitting ? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50' : 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.splitHint')}
|
||||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||
SPLIT
|
||||
</button>
|
||||
{st.split && !!st.tx_freq_hz && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
@@ -294,7 +532,7 @@ export function FlexPanel() {
|
||||
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">{t('flxp.voxDly')}</span>
|
||||
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||
@@ -307,22 +545,55 @@ export function FlexPanel() {
|
||||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||
</div>
|
||||
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
|
||||
blur/Enter so we don't spam a command per keystroke; the hold guard
|
||||
keeps the poll from clobbering the field mid-edit. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
|
||||
{(() => {
|
||||
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
|
||||
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
|
||||
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
|
||||
return (<>
|
||||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
|
||||
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
|
||||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||
<span className="text-muted-foreground text-xs">–</span>
|
||||
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
|
||||
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
|
||||
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
|
||||
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
|
||||
<select disabled={off} value={st.mic_profile || ''}
|
||||
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
|
||||
{!st.mic_profiles.includes(st.mic_profile || '') && <option value="">—</option>}
|
||||
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.speed')}</span>
|
||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.pitch')}</span>
|
||||
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.delay')}</span>
|
||||
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||
</div>
|
||||
@@ -334,32 +605,77 @@ export function FlexPanel() {
|
||||
</Card>
|
||||
|
||||
{/* RECEIVE */}
|
||||
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||
<select disabled={rxOff} value={st.rx_ant ?? ''}
|
||||
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
|
||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<span className="text-[10px] text-muted-foreground">TX</span>
|
||||
<select disabled={rxOff} value={st.tx_ant ?? ''}
|
||||
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
|
||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
||||
listening (chasing a station that drifts off your transmit frequency),
|
||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
||||
ACTIVE slice and follow slice focus like every control here. */}
|
||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
|
||||
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
||||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||
st.mute ? 'bg-destructive text-destructive-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||
</button>
|
||||
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
|
||||
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
|
||||
{!isCW && (
|
||||
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
)}
|
||||
</div>
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
@@ -367,7 +683,7 @@ export function FlexPanel() {
|
||||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{CW_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
@@ -382,91 +698,104 @@ export function FlexPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCW && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SSB_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
onClick={() => {
|
||||
const lsb = (st.mode || '').toUpperCase().includes('LSB');
|
||||
let lo: number, hi: number;
|
||||
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
|
||||
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
|
||||
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
|
||||
FlexSetFilter(lo, hi).catch(() => {});
|
||||
}}
|
||||
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{(bw / 1000).toFixed(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
|
||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||||
>
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
and shown compact so they sit with the amp controls. */}
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
}
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Live meters (UDP VITA-49 stream) */}
|
||||
<Card icon={Gauge} title="Meters">
|
||||
{(() => {
|
||||
const meters = st.meters || [];
|
||||
if (off || meters.length === 0) {
|
||||
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</p>;
|
||||
}
|
||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
// Radio meters (exclude the amplifier's, which we show separately).
|
||||
const radio = (name: string) => meters.find((m) =>
|
||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
||||
const fwd = radio('FWDPWR');
|
||||
const swr = radio('SWR');
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||||
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
||||
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||
const sUnit = (dbm: number) => {
|
||||
const s = (dbm + 127) / 6; // S0 = -127 dBm
|
||||
if (s >= 9) {
|
||||
const over = Math.round(dbm + 73); // dB over S9
|
||||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
|
||||
}
|
||||
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||
};
|
||||
const cur = [
|
||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||
); })(),
|
||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||
{amp.length > 0 && (
|
||||
<div className="border-t border-border/60 pt-2 mt-1">
|
||||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||
import {
|
||||
GetIcomState, IcomRefresh,
|
||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
|
||||
type IcomState = {
|
||||
available: boolean; model?: string; mode?: string;
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number;
|
||||
af_gain: number; rf_gain: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||||
agc?: string; preamp: number; att: number; filter: number;
|
||||
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||||
antenna: number;
|
||||
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||||
squelch: number; comp: boolean; comp_level: number;
|
||||
monitor: boolean; mon_level: number;
|
||||
vox: boolean; vox_gain: number; anti_vox: number;
|
||||
};
|
||||
|
||||
const ZERO: IcomState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
|
||||
af_gain: 0, rf_gain: 0,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||||
preamp: 0, att: 0, filter: 1,
|
||||
rit_hz: 0, rit_on: false, xit_on: false,
|
||||
antenna: 1,
|
||||
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||||
squelch: 0, comp: false, comp_level: 0,
|
||||
monitor: false, mon_level: 0,
|
||||
vox: false, vox_gain: 0, anti_vox: 0,
|
||||
};
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||
const BANDS: { l: string; hz: number }[] = [
|
||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||
{ l: '6', hz: 50_150_000 },
|
||||
];
|
||||
|
||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||
// SSB by frequency and the rig's data variant for digital modes.
|
||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||
|
||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||
function attOptions(model?: string): { v: string; l: string }[] {
|
||||
const m = (model ?? '').toUpperCase();
|
||||
const OFF = { v: '0', l: 'OFF' };
|
||||
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
}
|
||||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||
}
|
||||
|
||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||
function fmtVFO(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '––.–––.––';
|
||||
const mhz = Math.floor(hz / 1_000_000);
|
||||
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||
const h2 = Math.floor((hz % 1000) / 10);
|
||||
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||
function modeMatches(btn: string, cur?: string): boolean {
|
||||
if (!cur) return false;
|
||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||||
return btn === cur;
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||
// is ignored), so attach a non-passive native listener; read live values via
|
||||
// refs to avoid stale closures.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const stepRef = useRef(step); stepRef.current = step;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(0, Math.min(100, valRef.current + d));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented({ value, options, onChange }: {
|
||||
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||
{options.map((o) => (
|
||||
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||||
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||||
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const body = (
|
||||
<>
|
||||
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||||
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||
</div>
|
||||
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||||
}
|
||||
return <div className="flex items-center gap-2">{body}</div>;
|
||||
}
|
||||
|
||||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||||
label: string; on: boolean; hz: number; accent: string;
|
||||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cb = useRef(onDelta); cb.current = onDelta;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<div ref={ref} title="Wheel / ± to shift"
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||
</span>
|
||||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||
</div>
|
||||
<button type="button" onClick={onClear}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||
// the RST-tx value on click.
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= 47) {
|
||||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||||
// (near-black → blue → cyan → green → amber → red).
|
||||
const WF_STOPS: [number, [number, number, number]][] = [
|
||||
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||||
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||||
];
|
||||
function wfColor(v: number): [number, number, number] {
|
||||
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||||
for (let i = 1; i < WF_STOPS.length; i++) {
|
||||
if (v <= WF_STOPS[i][0]) {
|
||||
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||||
const f = (v - a) / (b - a || 1);
|
||||
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||||
}
|
||||
}
|
||||
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||
}
|
||||
|
||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||||
function ScopePanadapter() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [fixed, setFixed] = useState(true);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
||||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||||
|
||||
const toggle = () => {
|
||||
const next = !on;
|
||||
setOn(next);
|
||||
IcomSetScope(next).catch(() => {});
|
||||
};
|
||||
|
||||
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
||||
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
||||
// just writes the rig's fixed edges — simple and independent of the waveform
|
||||
// decode.
|
||||
const SCOPE_HALF = 50_000;
|
||||
const applyEdges = (center: number) => {
|
||||
if (center <= 0) return;
|
||||
centerRef.current = center;
|
||||
setFixed(true);
|
||||
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
||||
};
|
||||
const centerOnVfo = async () => {
|
||||
let c = vfoRef.current;
|
||||
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
||||
applyEdges(c);
|
||||
};
|
||||
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
||||
const setMode = (nextFixed: boolean) => {
|
||||
setFixed(nextFixed);
|
||||
IcomSetScopeMode(nextFixed).catch(() => {});
|
||||
};
|
||||
// Stop the stream when the panel unmounts.
|
||||
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
let raf = 0, lastSeq = -1, alive = true;
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const sw = await IcomScopeData();
|
||||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
||||
lastSeq = sw.seq;
|
||||
setFixed(sw.fixed);
|
||||
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
||||
let vfo = 0;
|
||||
try {
|
||||
const cs = await GetCATState();
|
||||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||||
} catch {}
|
||||
if (vfo > 0) vfoRef.current = vfo;
|
||||
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
||||
}
|
||||
} catch {}
|
||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on]);
|
||||
|
||||
// Double-click tunes the rig to the clicked frequency.
|
||||
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
const { low, high } = spanRef.current;
|
||||
if (!cv || !(low > 0 && high > low)) return;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
||||
SetCATFrequency(hz).catch(() => {});
|
||||
};
|
||||
|
||||
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
||||
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
||||
// accumulate before the poll reconciles.
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el || !on) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!vfoRef.current) return;
|
||||
e.preventDefault();
|
||||
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
||||
vfoRef.current = next;
|
||||
SetCATFrequency(next).catch(() => {});
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [on]);
|
||||
|
||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||||
const peak = Math.max(...amp);
|
||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||||
const scale = peakRef.current;
|
||||
const n = amp.length;
|
||||
|
||||
// Background — deep navy vertical gradient.
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||||
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid.
|
||||
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
|
||||
const xOf = (i: number) => (i / (n - 1)) * w;
|
||||
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
||||
|
||||
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
||||
const hold = holdRef.current;
|
||||
if (hold.length !== n) hold.length = n, hold.fill(0);
|
||||
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
||||
|
||||
// Filled spectrum area.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||||
ctx.lineTo(w, h); ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||
ctx.fillStyle = grad; ctx.fill();
|
||||
|
||||
// Peak-hold trace (thin, faint).
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
|
||||
// Live spectrum trace with a soft glow.
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
||||
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
||||
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
||||
// you can tell which way to tune to get it back on-screen).
|
||||
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
||||
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
||||
let markerX = -1;
|
||||
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
||||
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||||
else if (!fixedMode) markerX = w / 2;
|
||||
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
||||
if (markerX >= 0) {
|
||||
const x = markerX;
|
||||
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
||||
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
||||
if (offEdge === 0) {
|
||||
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
||||
} else {
|
||||
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||||
// we label low/centre/high from them. In centre mode the header frame's edge
|
||||
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||||
// so we always label the centre with the live VFO frequency (which we fetch
|
||||
// each sweep), and only add edge labels when the reported edges genuinely
|
||||
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||
ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
||||
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
||||
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
||||
const validEdges = lowHz > 0 && highHz > lowHz;
|
||||
if (fixedMode) {
|
||||
if (validEdges) {
|
||||
label(mhz(lowHz), 4, 'left');
|
||||
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
||||
label(mhz(highHz), w - 4, 'right');
|
||||
}
|
||||
} else {
|
||||
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
||||
label(mhz(lowHz), 4, 'left');
|
||||
label(mhz(highHz), w - 4, 'right');
|
||||
}
|
||||
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
drawWaterfall(amp, scale);
|
||||
};
|
||||
|
||||
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
||||
// as a colour-mapped line at the top.
|
||||
const drawWaterfall = (amp: number[], scale: number) => {
|
||||
const cv = wfRef.current;
|
||||
if (!cv) return;
|
||||
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
// Scroll everything down by one pixel row.
|
||||
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||
// Paint the new top row.
|
||||
const row = ctx.createImageData(w, 1);
|
||||
const n = amp.length;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
||||
const [r, g, b] = wfColor(amp[i] / scale);
|
||||
const o = x * 4;
|
||||
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(row, 0, 0);
|
||||
};
|
||||
|
||||
// Collapsible card: when the scope is off, only the header band shows (the
|
||||
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
||||
// and ON/OFF controls live in the header itself.
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{on && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
||||
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
||||
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
||||
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
||||
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
||||
</div>
|
||||
)}
|
||||
{on && (
|
||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||||
onChange={(v) => setMode(v === 'FIX')} />
|
||||
)}
|
||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||
</div>
|
||||
</div>
|
||||
{on && (
|
||||
<div className="p-3">
|
||||
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||
const [tuning, setTuning] = useState(false);
|
||||
const txRef = useRef(false);
|
||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||
|
||||
const load = () => {
|
||||
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||
};
|
||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||
const refresh = async () => {
|
||||
try { await IcomRefresh(); } catch {}
|
||||
await load();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||||
return () => window.clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Optimistic local update + fire the command; the cache poll reconciles.
|
||||
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||||
setSt((s) => ({ ...s, ...patch }));
|
||||
fn().catch(() => {});
|
||||
};
|
||||
|
||||
const toggleMox = () => {
|
||||
const next = !txRef.current;
|
||||
txRef.current = next;
|
||||
set({ transmitting: next }, () => IcomSetPTT(next));
|
||||
};
|
||||
|
||||
const tune = async () => {
|
||||
setTuning(true);
|
||||
try { await IcomTune(); } catch {}
|
||||
window.setTimeout(() => setTuning(false), 4000);
|
||||
};
|
||||
|
||||
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||||
const setRit = (hz: number) => {
|
||||
const v = Math.max(-9999, Math.min(9999, hz));
|
||||
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||||
};
|
||||
|
||||
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||||
// clarifier for zero-beating a caller without touching the mouse.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||
const s = stRef.current;
|
||||
if (!s.available || !s.rit_on) return;
|
||||
e.preventDefault();
|
||||
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||
{t('icmp.notConnected')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tx = st.transmitting;
|
||||
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||
const split = !!cat?.split;
|
||||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||||
const curMode: string = cat?.mode || st.mode || '';
|
||||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||
const um = curMode.toUpperCase();
|
||||
const isCW = um === 'CW' || um === 'CWR';
|
||||
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||||
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||||
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||||
{tx ? 'TX' : 'RX'}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||||
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||
rig's LAN server stays alive in standby, so both work. */}
|
||||
{isNetwork && (
|
||||
<>
|
||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||
<Power className="size-3.5" /> ON
|
||||
</button>
|
||||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||
<Power className="size-3.5" /> OFF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||||
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||||
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||||
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||||
{/* MAIN VFO */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||||
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||||
</div>
|
||||
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||||
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||||
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||||
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||||
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||||
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||||
</div>
|
||||
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||||
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
||||
{MODES.map((m) => {
|
||||
const on = modeMatches(m, curMode);
|
||||
return (
|
||||
<button key={m} type="button" onClick={() => setMode(m)}
|
||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||||
{(() => { const sp = sParts(st.s_meter); return (
|
||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||||
); })()}
|
||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||
</div>
|
||||
|
||||
{/* Spectrum panadapter (full width). */}
|
||||
<ScopePanadapter />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Transmit controls. */}
|
||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||
<Row label={t('icmp.power')}>
|
||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||||
</Row>
|
||||
{isPhone && (
|
||||
<Row label={t('icmp.mic')}>
|
||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||
</Row>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button type="button" onClick={toggleMox}
|
||||
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||||
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
{tx ? 'TX ON' : 'MOX'}
|
||||
</button>
|
||||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||
<button type="button" onClick={tune} disabled={tuning}
|
||||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||
TUNE
|
||||
</button>
|
||||
</div>
|
||||
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||||
they don't exist on CW or data). */}
|
||||
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||||
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||||
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||||
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||||
{isPhone && (
|
||||
<>
|
||||
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||||
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||||
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||||
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||||
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||||
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||||
<Row label="Anti-VOX">
|
||||
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||||
<Row label="AF">
|
||||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||
</Row>
|
||||
<Row label={t('icmp.squelch')}>
|
||||
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||||
</Row>
|
||||
<Row label="AGC">
|
||||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||
</Row>
|
||||
<Row label={t('icmp.preamp')}>
|
||||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Att">
|
||||
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label={t('icmp.filter')}>
|
||||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||||
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||||
<Row label="PBT-IN">
|
||||
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||||
</Row>
|
||||
<Row label="PBT-OUT">
|
||||
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||||
</Row>
|
||||
<button type="button"
|
||||
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||||
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
{t('icmp.pbtCenter')}
|
||||
</button>
|
||||
<div className="pt-1 border-t border-border/60 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||||
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||
</div>
|
||||
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||||
{isCW && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||||
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||
}
|
||||
|
||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||
// layers WebView2 has to hold — is double every other basemap's.
|
||||
function addBasemap(
|
||||
m: L.Map,
|
||||
key: BasemapKey,
|
||||
base: React.MutableRefObject<L.TileLayer | null>,
|
||||
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||
) {
|
||||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||
const bm = BASEMAPS[key];
|
||||
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||
}
|
||||
|
||||
function dot(color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
@@ -81,10 +99,11 @@ interface WorldProps {
|
||||
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||
beamWidth?: number; // beamwidth (deg), default 30
|
||||
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
|
||||
}
|
||||
|
||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||
@@ -102,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// One-time map creation.
|
||||
useEffect(() => {
|
||||
if (worldRef.current && !worldMap.current) {
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||
// enough to blow WebView2's raster budget and leave the window painting in
|
||||
// patches. On canvas it is a single layer.
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||
.setView([20, 0], 1);
|
||||
const bm = BASEMAPS[basemap];
|
||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
worldOverlay.current = L.layerGroup().addTo(m);
|
||||
worldMap.current = m;
|
||||
const sv = loadMapView();
|
||||
@@ -114,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||
}
|
||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||
return () => window.clearTimeout(t);
|
||||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||
// the rotor moved a degree.
|
||||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||
if (worldRef.current) ro.observe(worldRef.current);
|
||||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||
}, []);
|
||||
|
||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||
@@ -122,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
||||
const bm = BASEMAPS[basemap];
|
||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
@@ -139,18 +161,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
if (from && to) {
|
||||
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||||
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||||
// the beam heading should show even before a callsign is entered.
|
||||
if (from) {
|
||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||
// line, which makes a smooth arc look angular/bumpy).
|
||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (beamAzimuths && beamAzimuths.length) {
|
||||
@@ -169,21 +186,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
return out;
|
||||
};
|
||||
for (const az of beamAzimuths) {
|
||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||
// (its edges run off toward ±90° and the fill smears across the map),
|
||||
// while each radial LINE stays clean. The overlapping lines read as a
|
||||
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||
// for any azimuth, north/south included.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||
// the antenna and fanning out toward the front.
|
||||
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
||||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
|
||||
}
|
||||
|
||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||
// Dark casing under the boresight so the bright dashed line stays
|
||||
// readable on any basemap (esp. dark satellite imagery). Same dashArray
|
||||
// as the red line so the casing tracks each dash — otherwise the wide
|
||||
// casing peeks through the gaps and the line looks bumpy.
|
||||
// readable on any basemap. Same dashArray so the casing tracks each dash.
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
@@ -204,20 +220,39 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
} else if (autoZoom && to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (autoZoom && from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||
|
||||
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||||
let arcPts: [number, number][] | null = null;
|
||||
if (from && to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||||
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
}
|
||||
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||
// thrash the compositor.
|
||||
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||
|
||||
const path = pathBetween(fromGrid, toGrid);
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||
WorkedBefore,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
import { netctl } from '@/../wailsjs/go/models';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type Net = netctl.Net;
|
||||
type Station = netctl.Station;
|
||||
|
||||
function fmtTimeOn(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
const [openId, setOpenId] = useState<string>('');
|
||||
const [roster, setRoster] = useState<Station[]>([]);
|
||||
const [active, setActive] = useState<QSOForm[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [contact, setContact] = useState<Station>(emptyStation());
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const activeGrid = useRef<any>(null);
|
||||
const rosterGrid = useRef<any>(null);
|
||||
|
||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||
const [wbCall, setWbCall] = useState('');
|
||||
const [wb, setWb] = useState<any>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
const wbReq = useRef(0);
|
||||
// Resizable height (drag the handle on top). Persisted.
|
||||
const [wbHeight, setWbHeight] = useState(() => {
|
||||
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
||||
return v >= 80 && v <= 600 ? v : 176;
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
||||
const startWbResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY, startH = wbHeight;
|
||||
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
||||
const c = (call ?? '').trim().toUpperCase();
|
||||
setWbCall(c);
|
||||
if (!c) { setWb(null); return; }
|
||||
const req = ++wbReq.current;
|
||||
setWbBusy(true);
|
||||
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
||||
catch { if (wbReq.current === req) setWb(null); }
|
||||
finally { if (wbReq.current === req) setWbBusy(false); }
|
||||
}, []);
|
||||
|
||||
const isOpen = openId !== '' && openId === selId;
|
||||
|
||||
const refreshNets = useCallback(async () => {
|
||||
try {
|
||||
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
||||
const arr = (list ?? []) as Net[];
|
||||
setNets(arr);
|
||||
setOpenId(oid ?? '');
|
||||
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshRoster = useCallback(async (id: string) => {
|
||||
if (!id) { setRoster([]); return; }
|
||||
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshActive = useCallback(async () => {
|
||||
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||
|
||||
// The roster side hides callsigns currently on the air (they live in the left
|
||||
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||
const activeCalls = useMemo(
|
||||
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||
[active],
|
||||
);
|
||||
const rosterShown = useMemo(
|
||||
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||
[roster, activeCalls],
|
||||
);
|
||||
|
||||
async function newNet() {
|
||||
const name = window.prompt(t('ncp.newNetPrompt'));
|
||||
if (!name?.trim()) return;
|
||||
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function renameNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
||||
if (!name?.trim()) return;
|
||||
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function deleteNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
||||
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function toggleOpen() {
|
||||
try {
|
||||
if (isOpen) {
|
||||
if (active.length > 0 &&
|
||||
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
||||
await NetClose(); setOpenId('');
|
||||
} else {
|
||||
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Roster → active (start QSO).
|
||||
async function activate(call: string) {
|
||||
if (!isOpen || !call) return;
|
||||
try { await NetActivate(call); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||
async function saveDraft(q: QSOForm) {
|
||||
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function discardDraft(id: number) {
|
||||
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Add-contact dialog.
|
||||
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
||||
async function lookupContact() {
|
||||
const call = (contact.callsign ?? '').trim();
|
||||
if (!call) return;
|
||||
setLooking(true);
|
||||
try {
|
||||
const r = await NetLookup(call);
|
||||
setContact((c) => netctl.Station.createFrom({
|
||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
||||
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
||||
email: r.email || c.email,
|
||||
}));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setLooking(false); }
|
||||
}
|
||||
async function saveContact() {
|
||||
const call = (contact.callsign ?? '').trim().toUpperCase();
|
||||
if (!call || !selId) return;
|
||||
try {
|
||||
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
||||
setContactOpen(false);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeSelectedRoster() {
|
||||
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||
if (sel.length === 0) return;
|
||||
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
||||
try {
|
||||
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||
], [t]);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||
], [t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||
value={selId}
|
||||
disabled={isOpen}
|
||||
onChange={(e) => setSelId(e.target.value)}
|
||||
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
||||
>
|
||||
<option value="">{t('ncp.selectNetOption')}</option>
|
||||
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select>
|
||||
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||
</span>
|
||||
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* ACTIVE USERS (left) */}
|
||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={activeGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={active}
|
||||
columnDefs={activeCols}
|
||||
defaultColDef={defaultColDef}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && active.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worked-before for the clicked station — click a row (on air or
|
||||
roster) to see if/when we contacted it before. Lives INSIDE the
|
||||
on-air column so resizing only splits this column; the roster on
|
||||
the right keeps its full height. */}
|
||||
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
||||
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
||||
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
<History className="size-3.5" />
|
||||
{t('ncp.workedBefore')}
|
||||
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
||||
{wb && wb.count > 0 && (
|
||||
<span className="ml-auto font-normal normal-case text-foreground">
|
||||
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
||||
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{!wbCall ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||
) : wbBusy ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
||||
) : !wb || wb.count === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
||||
<th className="px-2 py-1 font-semibold">RST</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((wb.entries ?? []) as any[]).map((q, i) => (
|
||||
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
||||
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
||||
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
||||
<td className="px-2 py-1">{q.band}</td>
|
||||
<td className="px-2 py-1">{q.mode}</td>
|
||||
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NET USERS / roster (right) */}
|
||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={rosterShown}
|
||||
columnDefs={rosterCols}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).callsign)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
||||
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
||||
{editingDraft && (
|
||||
<QSOEditModal
|
||||
qso={editingDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={discardDraft}
|
||||
onClose={() => setEditingDraft(null)}
|
||||
countries={countries}
|
||||
bands={bands}
|
||||
modes={modes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add / edit contact dialog */}
|
||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 px-5 py-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
||||
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
||||
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">QTH</Label>
|
||||
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
||||
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
||||
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Band = { band: string; is_default: boolean };
|
||||
type Antenna = {
|
||||
@@ -38,6 +39,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function OperatingPanel({ bands, onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [tree, setTree] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// expanded keeps which stations show their antennas; everything open by
|
||||
@@ -131,7 +133,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeAntenna(stationId: number, antId: number) {
|
||||
if (!confirm('Delete this antenna?')) return;
|
||||
if (!confirm(t('op.deleteAntConfirm'))) return;
|
||||
try {
|
||||
await DeleteOperatingAntenna(antId);
|
||||
setTree((prev) => prev.map((s) =>
|
||||
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
Define your rigs (stations) and the antennas connected to each one.
|
||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
||||
the default antenna for that band — when you change the band in the
|
||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
||||
band; setting one clears the previous default.
|
||||
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={addStation}>
|
||||
<Plus className="size-3.5" /> Add rig
|
||||
<Plus className="size-3.5" /> {t('op.addRig')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||
No rig configured yet. Click "Add rig" to get started.
|
||||
{t('op.noRig')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -212,6 +209,7 @@ function StationRow({
|
||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||
onUpdateAntenna, onDeleteAntenna,
|
||||
}: StationRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `station:${station.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({
|
||||
@@ -242,7 +240,7 @@ function StationRow({
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="p-0.5 hover:bg-accent/40 rounded"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? t('op.collapse') : t('op.expand')}
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
@@ -253,12 +251,12 @@ function StationRow({
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Rig name (also stamped as MY_RIG)"
|
||||
placeholder={t('op.rigNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
||||
<Label className="text-[11px] text-muted-foreground">{t('op.powerW')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -269,8 +267,8 @@ function StationRow({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commit}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -279,13 +277,13 @@ function StationRow({
|
||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title={t('op.edit')}>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||
<Plus className="size-3" /> Antenna
|
||||
<Plus className="size-3" /> {t('op.antenna')}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title={t('op.deleteRig')}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -296,7 +294,7 @@ function StationRow({
|
||||
<div className="p-2 space-y-2">
|
||||
{(station.antennas ?? []).length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||
No antenna yet — click "Antenna" above to add one.
|
||||
{t('op.noAntenna')}
|
||||
</div>
|
||||
) : (
|
||||
(station.antennas ?? []).map((a) => (
|
||||
@@ -329,6 +327,7 @@ type AntennaRowProps = {
|
||||
};
|
||||
|
||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||
const { t } = useI18n();
|
||||
const editKey = `antenna:${antenna.id}`;
|
||||
const isEditing = editing === editKey;
|
||||
const [draft, setDraft] = useState({ name: antenna.name });
|
||||
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm flex-1"
|
||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
||||
placeholder={t('op.antNamePh')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||
/>
|
||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -412,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||
isDefault
|
||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
||||
? 'border-warning-border bg-warning-muted shadow-sm'
|
||||
: enabled
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||
@@ -431,14 +430,14 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||
isDefault
|
||||
? 'bg-amber-400 text-white'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-warning hover:text-warning',
|
||||
)}
|
||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
||||
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
||||
>
|
||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||
{isDefault ? 'Default' : 'Set'}
|
||||
{isDefault ? t('op.default') : t('op.set')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
|
||||
import { AllCommunityModule, ModuleRegistry, type ColDef } from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -10,19 +10,12 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Warm theme matching the other grids (Recent QSOs / Cluster).
|
||||
const qslTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
|
||||
});
|
||||
|
||||
type UploadRow = {
|
||||
id: number; qso_date: string; callsign: string;
|
||||
band: string; mode: string; country: string; status: string;
|
||||
@@ -52,14 +45,30 @@ const SERVICES = [
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ v: '_', label: '— leave —' },
|
||||
{ v: 'Y', label: 'Yes' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'I', label: 'Ignore' },
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Y', label: 'qslm.yes' },
|
||||
{ v: 'N', label: 'qslm.no' },
|
||||
{ v: 'R', label: 'qslm.requested' },
|
||||
{ v: 'I', label: 'qslm.ignore' },
|
||||
];
|
||||
|
||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||
const QSL_VIA_OPTIONS = [
|
||||
{ v: '_', label: 'qslm.leave' },
|
||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
||||
];
|
||||
|
||||
// Maps a service value → its i18n label key (only for services with
|
||||
// translatable names; brand names like QRZ.com stay as-is in SERVICES).
|
||||
const SERVICE_LABEL_KEYS: Record<string, string> = {
|
||||
pota: 'qslm.svcPota',
|
||||
paper: 'qslm.svcPaper',
|
||||
};
|
||||
|
||||
type LogQSO = {
|
||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||
@@ -68,13 +77,14 @@ type LogQSO = {
|
||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
||||
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
|
||||
|
||||
// label holds an i18n key.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'Q', label: 'Queued' },
|
||||
{ v: 'Y', label: 'Yes (already sent)' },
|
||||
{ v: 'I', label: 'Invalid' },
|
||||
{ v: '_', label: '— blank —' },
|
||||
{ v: 'R', label: 'qslm.sentRequested' },
|
||||
{ v: 'N', label: 'qslm.sentNo' },
|
||||
{ v: 'Q', label: 'qslm.sentQueued' },
|
||||
{ v: 'Y', label: 'qslm.sentYes' },
|
||||
{ v: 'I', label: 'qslm.sentInvalid' },
|
||||
{ v: '_', label: 'qslm.sentBlank' },
|
||||
];
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
@@ -100,6 +110,7 @@ export function fmtQslDate(s?: string): string {
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [service, setService] = useState('lotw');
|
||||
// The callsign this profile signs/uploads/downloads as for the selected
|
||||
// service (Force station callsign, else the profile call). Shown so the user
|
||||
@@ -127,13 +138,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const [paperCall, setPaperCall] = useState('');
|
||||
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
|
||||
const [paperBusy, setPaperBusy] = useState(false);
|
||||
const [paperMsg, setPaperMsg] = useState('');
|
||||
const [qslRcvd, setQslRcvd] = useState('Y');
|
||||
const [qslRcvd, setQslRcvd] = useState('N');
|
||||
const [qslSent, setQslSent] = useState('_');
|
||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||
const [qslSentDate, setQslSentDate] = useState('');
|
||||
const [qslVia, setQslVia] = useState('');
|
||||
const [qslVia, setQslVia] = useState('_');
|
||||
const [qslNotes, setQslNotes] = useState('');
|
||||
const [qslComment, setQslComment] = useState('');
|
||||
|
||||
const searchPaper = useCallback(async () => {
|
||||
const c = paperCall.trim().toUpperCase();
|
||||
@@ -144,14 +158,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const list = (r ?? []) as LogQSO[];
|
||||
setPaperRows(list);
|
||||
setPaperSel(new Set(list.map((x) => x.id)));
|
||||
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
|
||||
|
||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
||||
finally { setPaperBusy(false); }
|
||||
}, [paperCall]);
|
||||
|
||||
function togglePaper(id: number) {
|
||||
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
}
|
||||
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
|
||||
|
||||
async function applyPaper() {
|
||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||
@@ -164,19 +176,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||
sent_date: ymd(qslSentDate),
|
||||
rcvd_date: ymd(qslRcvdDate),
|
||||
via: qslVia,
|
||||
via: qslVia === '_' ? '' : qslVia,
|
||||
notes: qslNotes,
|
||||
comment: qslComment,
|
||||
} as any);
|
||||
setPaperMsg(`${n} QSO updated.`);
|
||||
setPaperMsg(t('qslm.qsoUpdated', { n }));
|
||||
await searchPaper();
|
||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||
finally { setPaperBusy(false); }
|
||||
}
|
||||
const [sent, setSent] = useState('R');
|
||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
|
||||
const gridRef = useRef<AgGridReact<UploadRow>>(null);
|
||||
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const selectAllNext = useRef(false); // selectAll once after the next data load
|
||||
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
|
||||
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [addNotFound, setAddNotFound] = useState(false);
|
||||
@@ -210,15 +224,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||
function onUploadSelChanged() {
|
||||
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
|
||||
}
|
||||
// After "Select required" loads new rows, select them all (the old default).
|
||||
function onUploadRowsLoaded() {
|
||||
if (selectAllNext.current) {
|
||||
selectAllNext.current = false;
|
||||
gridRef.current?.api?.selectAll();
|
||||
}
|
||||
function onUploadRowSelected(ids: number[]) {
|
||||
setUploadSelIds(ids);
|
||||
setSelectedCount(ids.length);
|
||||
}
|
||||
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
@@ -236,10 +244,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
setError('');
|
||||
try {
|
||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
selectAllNext.current = true; // pre-select everything once the grid renders
|
||||
const list = (r ?? []) as any[];
|
||||
setRows(list);
|
||||
setUploadSelIds(list.map((x) => x.id));
|
||||
setSelectedCount(list.length);
|
||||
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
|
||||
setViewMode('upload');
|
||||
setShowLog(false);
|
||||
} catch (e: any) {
|
||||
@@ -252,7 +261,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
}, [service, sent]);
|
||||
|
||||
async function upload() {
|
||||
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
|
||||
const ids = uploadSelIds;
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||
try { await UploadQSOsManual(service, ids); }
|
||||
@@ -280,18 +289,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{uploadCall && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<span
|
||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||
title={t('qslm.callsignScopeTitle')}
|
||||
>
|
||||
{uploadCall}
|
||||
</span>
|
||||
@@ -301,142 +310,126 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||
Sync hunter log
|
||||
{t('qslm.syncHunterLog')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
|
||||
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||
Only my profile callsign
|
||||
{t('qslm.onlyMyCall')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
|
||||
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||
Add not-found QSOs to my log
|
||||
{t('qslm.addMissing')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
|
||||
</>
|
||||
) : service === 'paper' ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||
onChange={(e) => setPaperCall(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||
placeholder="e.g. DL1ABC" />
|
||||
placeholder={t('qslm.callsignPlaceholder')} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Search
|
||||
{t('qslm.search')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
||||
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
{t('qslm.selectRequired')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
|
||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||
</span>
|
||||
)}
|
||||
{service === 'paper' && paperRows.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
||||
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
|
||||
)}
|
||||
{!showLog && viewMode === 'confirmations' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
|
||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="new">New (any)</SelectItem>
|
||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
||||
<SelectItem value="band">New band</SelectItem>
|
||||
<SelectItem value="slot">New slot</SelectItem>
|
||||
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
|
||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{logLines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
||||
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content: log OR results grid */}
|
||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{error && <div className="text-xs text-danger mb-2">{error}</div>}
|
||||
|
||||
{service === 'paper' ? (
|
||||
paperRows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paperRows.map((r) => (
|
||||
<tr key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => togglePaper(r.id)}>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||
// drives which QSOs the apply-form below updates; a search selects all.
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
rows={paperRows as any}
|
||||
total={paperRows.length}
|
||||
selectAllSignal={paperSelAllSig}
|
||||
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : service === 'pota' ? (
|
||||
<div className="space-y-3">
|
||||
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
||||
{!potaRes && !potaErr && !potaSyncing && (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
|
||||
)}
|
||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app…</div>}
|
||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
||||
{potaRes && (
|
||||
<>
|
||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
||||
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
|
||||
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||
{potaRes.skipped_other_call > 0 && (
|
||||
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
|
||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||
)}
|
||||
{' '}Rescan the POTA award to count the new references.
|
||||
{' '}{t('qslm.potaRescan')}
|
||||
</div>
|
||||
{potaRes.unmatched_list?.length > 0 && (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
||||
<th className="py-1.5 px-2">Why unmatched</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -444,7 +437,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<tr key={i}
|
||||
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
||||
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
|
||||
title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}>
|
||||
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
|
||||
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
||||
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||
<td className="py-1 px-2">{u.band}</td>
|
||||
@@ -464,27 +457,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
) : showLog ? (
|
||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||
{logLines.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
||||
/FAIL|failed|error/i.test(l) ? 'text-danger'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
|
||||
: 'text-foreground/90')}>{l}</div>
|
||||
))}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
||||
</div>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
||||
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -496,9 +489,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -507,21 +500,14 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
|
||||
) : (
|
||||
<div className="h-full w-full">
|
||||
<AgGridReact<UploadRow>
|
||||
ref={gridRef}
|
||||
theme={qslTheme}
|
||||
rowData={rows}
|
||||
columnDefs={UPLOAD_COLS}
|
||||
defaultColDef={{ sortable: true, resizable: true, filter: true }}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
|
||||
onSelectionChanged={onUploadSelChanged}
|
||||
onRowDataUpdated={onUploadRowsLoaded}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
onRowSelected={onUploadRowSelected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -531,33 +517,44 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
{service === 'paper' && (
|
||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL received</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</label>
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={qslSent} onValueChange={setQslSent}>
|
||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
||||
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
||||
<Select value={qslVia} onValueChange={setQslVia}>
|
||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
|
||||
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||
Apply to {paperSel.size} selected
|
||||
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -567,18 +564,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
{/* Date window */}
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last">Since last download</SelectItem>
|
||||
<SelectItem value="date">Since date…</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
|
||||
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
|
||||
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sinceMode === 'date' && (
|
||||
@@ -587,16 +584,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
value={sinceDate}
|
||||
onChange={(e) => setSinceDate(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
|
||||
title={service === 'qrz' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
@@ -15,32 +16,35 @@ type Props = {
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||
{ service: 'clublog', label: 'Send to Club Log' },
|
||||
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
||||
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
||||
{ service: 'lotw', label: 'Send to LoTW' },
|
||||
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'qrz', name: 'QRZ.com' },
|
||||
{ service: 'clublog', name: 'Club Log' },
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
|
||||
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('mousedown', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
@@ -58,29 +62,29 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{n} QSO{n > 1 ? 's' : ''} selected
|
||||
{t('qctx.selected', { n })}
|
||||
</div>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||
>
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<span>Fix country & zones from cty.dat</span>
|
||||
<span>{t('qctx.fixCountry')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-sky-600" />
|
||||
<span>Update from QRZ.com</span>
|
||||
<RefreshCw className="size-4 text-info" />
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
{onUpdateFromClublog && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-violet-600" />
|
||||
<span>Update from ClubLog (exceptions)</span>
|
||||
<BadgeCheck className="size-4 text-info" />
|
||||
<span>{t('qctx.updateClublog')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -92,8 +96,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-amber-600" />
|
||||
<span>Send OpsLog QSL by e-mail</span>
|
||||
<Mail className="size-4 text-warning" />
|
||||
<span>{t('qctx.sendQslEmail')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onSendRecording && (
|
||||
@@ -101,8 +105,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||
>
|
||||
<Mail className="size-4 text-rose-600" />
|
||||
<span>Send recording by e-mail</span>
|
||||
<Mail className="size-4 text-danger" />
|
||||
<span>{t('qctx.sendRecording')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -115,8 +119,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||
>
|
||||
<PencilLine className="size-4 text-indigo-600" />
|
||||
<span>Bulk edit field… ({n})</span>
|
||||
<PencilLine className="size-4 text-info" />
|
||||
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -129,8 +133,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-sky-600" />
|
||||
<span>Export selected to ADIF ({n})</span>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFiltered && (
|
||||
@@ -138,8 +142,26 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-violet-600" />
|
||||
<span>Export filtered view to ADIF (no limit)</span>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloSelected && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloFiltered && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-warning" />
|
||||
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -148,18 +170,31 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
{onSendTo && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{UPLOAD_TARGETS.map((t) => (
|
||||
{UPLOAD_TARGETS.map((target) => (
|
||||
<button
|
||||
key={t.service}
|
||||
key={target.service}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
|
||||
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||
>
|
||||
<Upload className="size-4 text-emerald-600" />
|
||||
<span>{t.label}</span>
|
||||
<Upload className="size-4 text-success" />
|
||||
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
|
||||
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>{t('qctx.delete', { n })}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
@@ -37,12 +38,13 @@ function pfxOf(call: string): string {
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
{ value: '_', label: '—' },
|
||||
{ value: 'Y', label: 'Yes' },
|
||||
{ value: 'N', label: 'No' },
|
||||
{ value: 'R', label: 'Requested' },
|
||||
{ value: 'I', label: 'Ignore' },
|
||||
{ value: '_', label: 'qedit.qslDash' },
|
||||
{ value: 'Y', label: 'qedit.qslYes' },
|
||||
{ value: 'N', label: 'qedit.qslNo' },
|
||||
{ value: 'R', label: 'qedit.qslRequested' },
|
||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||
];
|
||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
@@ -63,20 +65,38 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
|
||||
const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
QSL: 'qedit.confQslPaper',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
const v = (value || '').toUpperCase();
|
||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||
if (v === '') {
|
||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||
}
|
||||
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||
: v === 'R' ? 'bg-orange-400 text-white'
|
||||
: v === 'I' ? 'bg-stone-400 text-white'
|
||||
: 'bg-amber-400 text-amber-950';
|
||||
// One colour per state, and each colour means something:
|
||||
// Yes green — confirmed, the thing you wanted
|
||||
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||
// This is the ONLY state asking for action, so it gets the
|
||||
// only alarming colour.
|
||||
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||
// every row; painting that orange (as it used to be, in the
|
||||
// same orange as Requested) made the table shout about a
|
||||
// non-problem and told you nothing apart.
|
||||
// Ignore dashed — deliberately excluded, on purpose.
|
||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||||
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||
: 'bg-muted text-muted-foreground border border-border';
|
||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||
}
|
||||
|
||||
@@ -128,17 +148,19 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||
// imported/legacy value is never silently dropped from the dropdown.
|
||||
@@ -177,6 +199,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||
const awardFieldRef = useRef<Record<string, string>>({});
|
||||
const [awardRefs, setAwardRefs] = useState('');
|
||||
// The refs present when the editor opened, so on save we can tell which ones
|
||||
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||||
const seedAwardRefsRef = useRef('');
|
||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||
|
||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||
@@ -197,6 +222,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
.filter((r: any) => r.pickable)
|
||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||
.join(';');
|
||||
seedAwardRefsRef.current = seed;
|
||||
setAwardRefs(seed);
|
||||
} catch { /* leave manual refs empty on failure */ }
|
||||
})
|
||||
@@ -215,6 +241,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
return () => window.clearTimeout(t);
|
||||
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||
|
||||
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
|
||||
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
|
||||
// accepts letters (sections/zones), not just numbers.
|
||||
function setExchange(which: 'srx' | 'stx', raw: string) {
|
||||
const t = raw.trim();
|
||||
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
|
||||
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
|
||||
}
|
||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
@@ -234,7 +268,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr('Callsign required'); return; }
|
||||
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
@@ -258,14 +292,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
||||
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||||
} finally {
|
||||
setLooking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||
setSaving(true);
|
||||
setLocalErr('');
|
||||
const out: any = {
|
||||
@@ -309,7 +343,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// the dedicated columns, then route the picked refs back onto the payload
|
||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
||||
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||
onSave(out);
|
||||
}
|
||||
|
||||
@@ -329,25 +363,25 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="flex-row items-baseline gap-2">
|
||||
<DialogTitle>Edit QSO</DialogTitle>
|
||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||
<TabsList className="px-3 overflow-x-auto">
|
||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
||||
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||||
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||||
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||||
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||||
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||||
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||||
<TabsTrigger value="extras">
|
||||
ADIF fields
|
||||
{t('qedit.tabAdifFields')}
|
||||
{extrasCount > 0 && (
|
||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||
)}
|
||||
@@ -365,7 +399,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label>Callsign</Label>
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
@@ -374,39 +408,39 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* ── Left column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Band</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">RX Band</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
|
||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Mode</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
|
||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Country</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
|
||||
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
|
||||
onChange={onCountryChange} className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -414,16 +448,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Label>CQ</Label>
|
||||
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
|
||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">Freq</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">RX Freq</Label>
|
||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
</div>
|
||||
@@ -431,7 +465,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* ── Right column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||
@@ -440,16 +474,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
// Prefill an empty end with the start time so the user
|
||||
// only tweaks the minutes instead of typing a full date.
|
||||
if (on && !dateOff) setDateOff(dateOn);
|
||||
}} /> QSO End (UTC)
|
||||
}} /> {t('qedit.qsoEnd')}
|
||||
</Label>
|
||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -458,10 +492,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||
<div>
|
||||
<Label>Continent</Label>
|
||||
<Label>{t('qedit.continent')}</Label>
|
||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -471,17 +505,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
</div>
|
||||
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -495,13 +529,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<span className="text-xs font-semibold">Computed (automatic)</span>
|
||||
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||
{t('qedit.computedHint')}
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||
{computedRefs.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
|
||||
) : (
|
||||
computedRefs.map((r) => (
|
||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||
@@ -525,44 +559,48 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
<div className="flex-1 max-w-sm space-y-3">
|
||||
<div>
|
||||
<Label>Manage Confirmation</Label>
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>Received</Label>
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
{def.rcvd
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right: live status grid for every channel */}
|
||||
<div className="w-72 shrink-0">
|
||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
||||
{/* Right: live status grid for every channel.
|
||||
Sized by its content, not pinned to a width: a fixed 288px box
|
||||
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||
two lines and padded out the whole row. There is spare width to
|
||||
the right — spend it on the label. */}
|
||||
<div className="shrink-0">
|
||||
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-semibold">Type</th>
|
||||
<th className="font-semibold">Sent</th>
|
||||
<th className="font-semibold">Received</th>
|
||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||
<th className="font-semibold">{t('qedit.sent')}</th>
|
||||
<th className="font-semibold">{t('qedit.received')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CONFIRMATIONS.map((c) => (
|
||||
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||
<tr key={c.key} className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||
</tr>
|
||||
@@ -577,90 +615,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<TabsContent value="contest" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
||||
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
||||
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sat" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Propagation mode">
|
||||
<F label={t('qedit.propMode')}>
|
||||
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</F>
|
||||
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mystation" className="mt-0 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
||||
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
||||
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power & propagation */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity & clubs */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity & clubs</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
||||
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
||||
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||
@@ -670,28 +706,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
{/* Flags & credits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
||||
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My station extras */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -704,11 +740,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
<DialogFooter className="!flex-row gap-2">
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
<Trash2 className="size-3.5" /> {t('qedit.delete')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
@@ -13,38 +14,22 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 32,
|
||||
headerHeight: 34,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
// Shared theme (follows the app palette); this table runs slightly taller rows.
|
||||
const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });
|
||||
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
// Bump this number to programmatically select every row (e.g. after a search
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
@@ -56,6 +41,9 @@ type Props = {
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -96,125 +84,131 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
||||
|
||||
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||
// can offer the exact same column choices without duplicating the catalog.
|
||||
export const COL_CATALOG: ColEntry[] = [
|
||||
// A factory taking the i18n `t()` so headerName/label strings are localized;
|
||||
// hooks can't run at module level, so the component calls this with its own t.
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.submode'), colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_hz'), colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.freq_rx_hz'), colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_sent'), colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.rst_rcvd'), colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.tx_pwr'), colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
|
||||
// ── Contacted station ──
|
||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.name'), colId: 'name', headerName: t('rqg.c.name'), field: 'name' as any, width: 170, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.qth'), colId: 'qth', headerName: t('rqg.c.qth'), field: 'qth' as any, width: 200, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.address'), field: 'address' as any, width: 200 },
|
||||
{ group: 'Contacted', label: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.country'), field: 'country' as any, width: 150, defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 },
|
||||
{ group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.cont'), field: 'cont' as any, width: 60 },
|
||||
{ group: 'Contacted', label: t('rqg.c.grid'), colId: 'grid', headerName: t('rqg.c.grid'), field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'), field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.dxcc'), colId: 'dxcc', headerName: t('rqg.c.dxcc'), field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.cqz'), colId: 'cqz', headerName: t('rqg.c.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.ituz'), colId: 'ituz', headerName: t('rqg.c.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.iota'), colId: 'iota', headerName: t('rqg.c.iota'), field: 'iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'), field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'), field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
{ group: 'Contest', label: t('rqg.c.srx'), colId: 'srx', headerName: t('rqg.c.srx'), field: 'srx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.stx'), colId: 'stx', headerName: t('rqg.c.stx'), field: 'stx' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contest', label: t('rqg.c.srx_string'), colId: 'srx_string', headerName: t('rqg.h.srx_string'), field: 'srx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.stx_string'), colId: 'stx_string', headerName: t('rqg.h.stx_string'), field: 'stx_string' as any, width: 100 },
|
||||
{ group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 },
|
||||
{ group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.precedence'), field: 'precedence' as any, width: 90 },
|
||||
{ group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 },
|
||||
|
||||
// ── Propagation / antenna ──
|
||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||
{ group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'), field: 'prop_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_name'), colId: 'sat_name', headerName: t('rqg.h.sat_name'), field: 'sat_name' as any, width: 110 },
|
||||
{ group: 'Propagation', label: t('rqg.c.sat_mode'), colId: 'sat_mode', headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any, width: 80 },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_az'), colId: 'ant_az', headerName: t('rqg.h.ant_az'), field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_el'), colId: 'ant_el', headerName: t('rqg.h.ant_el'), field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'Propagation', label: t('rqg.c.ant_path'), colId: 'ant_path', headerName: t('rqg.h.ant_path'), field: 'ant_path' as any, width: 70 },
|
||||
|
||||
// ── My station (operator side) ──
|
||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||
{ group: 'My station', label: t('rqg.c.operator'), colId: 'operator', headerName: t('rqg.c.operator'),field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_grid'), colId: 'my_grid', headerName: t('rqg.c.my_grid'), field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_country'), colId: 'my_country', headerName: t('rqg.h.my_country'), field: 'my_country' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_state'), colId: 'my_state', headerName: t('rqg.c.my_state'),field: 'my_state' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_cnty'), colId: 'my_cnty', headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any, width: 110 },
|
||||
{ group: 'My station', label: t('rqg.c.my_iota'), colId: 'my_iota', headerName: t('rqg.c.my_iota'), field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_sota'), colId: 'my_sota_ref', headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_pota'), colId: 'my_pota_ref', headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_dxcc'), colId: 'my_dxcc', headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_cq_zone'), colId: 'my_cq_zone', headerName: t('rqg.h.my_cq_zone'), field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_itu_zone'), colId: 'my_itu_zone', headerName: t('rqg.h.my_itu_zone'), field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lat'), colId: 'my_lat', headerName: t('rqg.c.my_lat'), field: 'my_lat' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_lon'), colId: 'my_lon', headerName: t('rqg.c.my_lon'), field: 'my_lon' as any, width: 90, type: 'rightAligned' },
|
||||
{ group: 'My station', label: t('rqg.c.my_street'), colId: 'my_street', headerName: t('rqg.h.my_street'), field: 'my_street' as any, width: 160 },
|
||||
{ group: 'My station', label: t('rqg.c.my_city'), colId: 'my_city', headerName: t('rqg.h.my_city'), field: 'my_city' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
{ group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.notes'), field: 'notes' as any, width: 240 },
|
||||
{ group: 'Misc', label: t('rqg.c.created'), colId: 'created_at', headerName: t('rqg.h.created'), field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'Misc', label: t('rqg.c.updated'), colId: 'updated_at', headerName: t('rqg.h.updated'), field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
];
|
||||
|
||||
export const GROUP_ORDER = [
|
||||
@@ -222,11 +216,25 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
||||
// Localized display label for a column group (identifier stays English so it
|
||||
// keeps working as a filter key).
|
||||
const GRP_KEYS: Record<string, string> = {
|
||||
QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl',
|
||||
LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads',
|
||||
Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp',
|
||||
'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc',
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
function onCellContextMenu(e: any) {
|
||||
@@ -248,7 +256,14 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
// Compute initial column defs: all columns defined, but those not marked
|
||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||
// overrides this so a previously toggled column wins.
|
||||
// While AG Grid rebuilds columns (award columns load async), it fires column
|
||||
// events that would otherwise trigger a save of the DEFAULT visibility before
|
||||
// we re-apply the user's saved state — clobbering it. This flag suppresses the
|
||||
// auto-save during that window. Set in the memo (runs at render, before the
|
||||
// column events) and cleared by the re-apply effect below.
|
||||
const restoringRef = useRef(true);
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
@@ -256,13 +271,19 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
||||
// them whenever the saved column state doesn't cover them — a newly-added
|
||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
||||
// re-applied — which is why "all award columns" kept reappearing. The user's
|
||||
// saved state (a column they explicitly showed) still overrides this.
|
||||
hide: true,
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols]);
|
||||
}, [awardCols, COL_CATALOG, t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
@@ -284,6 +305,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
@@ -295,9 +317,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
@@ -307,6 +331,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
useEffect(() => {
|
||||
if (selectAllSignal === undefined) return;
|
||||
gridRef.current?.api?.selectAll();
|
||||
}, [selectAllSignal]);
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||
@@ -350,11 +379,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('rqg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
@@ -395,15 +424,17 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Columns</DialogTitle>
|
||||
<DialogTitle>{t('rqg.columns')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Recent QSOs table.
|
||||
Your selection is saved.
|
||||
{t('rqg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
@@ -413,10 +444,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -436,10 +467,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -455,8 +486,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -75,22 +75,22 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Compass className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||
<div className="flex-1" />
|
||||
{pattern && (
|
||||
<span
|
||||
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
||||
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
||||
: 'bg-emerald-200 text-emerald-900')}
|
||||
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||
: 'bg-success-muted text-success-muted-foreground')}
|
||||
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||
: 'Ultrabeam normal'}>
|
||||
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</span>
|
||||
{onClose && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface RecentSpotQSO {
|
||||
callsign: string;
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||
// contact into the form.
|
||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [call, setCall] = useState('');
|
||||
const [freqKHz, setFreqKHz] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -56,8 +58,8 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError('Callsign required'); return; }
|
||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
||||
if (!c) { setError(t('spm.callRequired')); return; }
|
||||
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -84,25 +86,25 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> Send DX Spot
|
||||
<Satellite className="size-4 text-primary" /> {t('spm.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Label className="mb-1">Callsign</Label>
|
||||
<Label className="mb-1">{t('spm.callsign')}</Label>
|
||||
<Input
|
||||
ref={callRef}
|
||||
className="font-mono uppercase font-bold"
|
||||
value={call}
|
||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="DX call"
|
||||
placeholder={t('spm.callPh')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">Frequency (kHz)</Label>
|
||||
<Label className="mb-1">{t('spm.frequency')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
@@ -113,18 +115,18 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Message</Label>
|
||||
<Label className="mb-1">{t('spm.message')}</Label>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="e.g. CW · TNX QSO"
|
||||
placeholder={t('spm.messagePh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">Latest QSOs</Label>
|
||||
<Label className="mb-1 block">{t('spm.latestQsos')}</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||
{recent.map((q, i) => (
|
||||
<button
|
||||
@@ -142,17 +144,17 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
||||
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('spm.cancel')}</Button>
|
||||
<Button onClick={send} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
||||
{busy ? 'Sending…' : 'Send spot'}
|
||||
{busy ? t('spm.sending') : t('spm.sendSpot')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,15 +37,15 @@ export function ShutdownProgress() {
|
||||
) : steps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
|
||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-success" />}
|
||||
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
|
||||
{s.status === 'error' && <XCircle className="size-4 text-danger" />}
|
||||
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={
|
||||
s.status === 'done' ? 'text-foreground'
|
||||
: s.status === 'error' ? 'text-rose-700 font-medium'
|
||||
: s.status === 'error' ? 'text-danger font-medium'
|
||||
: s.status === 'pending' ? 'text-muted-foreground'
|
||||
: 'text-foreground font-medium'
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Statistics dashboard.
|
||||
//
|
||||
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||
// charts readable and colour-blind-safe):
|
||||
//
|
||||
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||
// encodes identity, never a magnitude the bar's length already shows.
|
||||
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||
// the categories ARE the subject (the continent donut). The order is the
|
||||
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||
// makes them interesting. Close values belong in bars.
|
||||
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||
// a table view gives the WCAG-clean twin.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Bucket = { key: string; count: number };
|
||||
type Gap = { start: string; end: string; minutes: number };
|
||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||
type Stats = {
|
||||
total: number; unique_calls: number; entities: number; continents: number;
|
||||
first_qso: string; last_qso: string;
|
||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
// Period / contest metrics.
|
||||
window_start: string; window_end: string; window_hours: number;
|
||||
avg_per_hour: number; avg_per_active: number;
|
||||
on_air_minutes: number; off_air_minutes: number;
|
||||
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||
gaps: Gap[]; rate: Bucket[];
|
||||
rate_ops: string[]; rate_by_op: number[][];
|
||||
};
|
||||
|
||||
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||
|
||||
const CONTINENT_NAME: Record<string, string> = {
|
||||
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||
};
|
||||
|
||||
const nf = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||
const top = max ? data.slice(0, max) : data;
|
||||
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||
{share && (
|
||||
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||
// information.
|
||||
|
||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||
// (the value is in the tooltip, and every number is in the table view).
|
||||
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||
return (
|
||||
// The container includes the x-axis band — a fixed height that excludes it is
|
||||
// how cards end up with a tiny nested scrollbar.
|
||||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||
{nf(d.count)}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
/>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||
// when the filter changes is a chart nobody can trust.
|
||||
|
||||
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||
}) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||
{rate.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||
{/* The column is the hour's total; the segments are who made it. */}
|
||||
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||
<div key={o} style={{
|
||||
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||
rate sheet below, so nothing is reachable by hover alone. */}
|
||||
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||
{hov !== null && (
|
||||
<>
|
||||
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||
{' · '}{nf(rate[hov].count)} QSO
|
||||
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{!single && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||
{ops.map((op, o) => (
|
||||
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||
{op}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||
|
||||
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||
if (rate.length === 0) return null;
|
||||
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||
{ops.map((op, o) => (
|
||||
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||
{op}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map(({ d, i }) => (
|
||||
<tr key={d.key} className="border-t border-border/50">
|
||||
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||
{ops.map((_, o) => (
|
||||
<td key={o} className="px-2 py-0.5 text-right">
|
||||
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||
<tr className="border-t border-border font-semibold">
|
||||
<td className="px-2 py-1">Total</td>
|
||||
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||
// is chaos and goes unread.
|
||||
|
||||
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||
const y = (v: number) => H - (v / peak) * H;
|
||||
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||
const h = hover ?? -1;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="relative" style={{ height }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
{/* Recessive hairline grid — solid, never dashed. */}
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<defs>
|
||||
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#statsArea)" />
|
||||
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
{h >= 0 && (
|
||||
<>
|
||||
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
</>
|
||||
)}
|
||||
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||
{data.map((_, i) => (
|
||||
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||
))}
|
||||
</svg>
|
||||
{h >= 0 && (
|
||||
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>{data[0].key}</span>
|
||||
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||
<span>{data[data.length - 1].key}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||
// donut would hide precisely the fact that makes them interesting).
|
||||
//
|
||||
// The categories ARE the subject here, so this is the one chart using the
|
||||
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||
|
||||
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||
const [hov, setHov] = useState<number | null>(null);
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
|
||||
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||
let acc = 0;
|
||||
const arcs = slots.map((d, i) => {
|
||||
const frac = d.count / total;
|
||||
// A 2px surface gap between segments — the correct separator, not a border.
|
||||
const len = Math.max(0, frac * C - 2);
|
||||
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||
acc += frac;
|
||||
return a;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||
{arcs.map((a) => (
|
||||
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||
className="transition-[stroke-width] duration-150 cursor-default"
|
||||
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||
))}
|
||||
</svg>
|
||||
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
{hov === null ? (
|
||||
<>
|
||||
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
{slots.map((d, i) => (
|
||||
<li key={d.key}
|
||||
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||
hov === i && 'bg-muted')}>
|
||||
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||
{((d.count / total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||
|
||||
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||
<span className="text-[11px] tabular-nums">
|
||||
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||
<div className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Period ───────────────────────────────────────────────────────────────────
|
||||
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||
// the charts would each show a different slice of time.
|
||||
|
||||
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
const now = new Date();
|
||||
switch (p) {
|
||||
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||
case 'y12': {
|
||||
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'd30': {
|
||||
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||
return [iso(d), ''];
|
||||
}
|
||||
case 'custom': return [from, to];
|
||||
default: return ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{data.map((d) => (
|
||||
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function StatsPanel() {
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||
const [period, setPeriod] = useState<Period>('all');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||
// that contest AND lets the window derive from its own span — no date typing.
|
||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||
const [contest, setContest] = useState('');
|
||||
|
||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||
// with it — the backend derives them. Sending a period as well would be two
|
||||
// filters fighting over the same axis.
|
||||
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||
// once, here, rather than guarding at every use site and missing one.
|
||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||
setStats({
|
||||
...raw,
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||
useEffect(() => {
|
||||
if (!contest && period === 'custom' && !(from && to)) return;
|
||||
load(period, from, to, contest);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest]);
|
||||
|
||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||
// between two ordinary evenings — numbers that are true and completely useless.
|
||||
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||
// real operating session (which is exactly when an hourly rate chart exists).
|
||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||
|
||||
const span = useMemo(() => {
|
||||
if (!stats?.first_qso) return '';
|
||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||
}, [stats]);
|
||||
|
||||
if (busy && !stats) {
|
||||
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||
</div>;
|
||||
}
|
||||
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||
if (!stats) return null;
|
||||
|
||||
const empty = t('stats.noData');
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||
|
||||
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||
never from a static list — so it can't offer a contest you never entered.
|
||||
Choosing one supersedes the period: it brings its own window. */}
|
||||
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||
<option value="">{t('stats.noContest')}</option>
|
||||
{runs.map((r) => (
|
||||
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||
{r.id} {r.year} — {nf(r.count)} QSO
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||
{([
|
||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||
] as [Period, string][]).map(([p, label], i) => (
|
||||
<button key={p} onClick={() => setPeriod(p)}
|
||||
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!contest && period === 'custom' && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<button onClick={() => setView('charts')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||
</button>
|
||||
<button onClick={() => setView('table')}
|
||||
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||
<Table2 className="size-3.5" />{t('stats.table')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||
</div>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||
{windowed && stats.total > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||
<div title={t('stats.avgWindowTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.avgActiveTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||
</div>
|
||||
<div title={t('stats.best60Tip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||
</div>
|
||||
{/* On-air + off-air = the window, by construction. The first version
|
||||
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||
<div title={t('stats.onAirTip')}>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||
{dur(stats.on_air_minutes)}
|
||||
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.rate.length > 1
|
||||
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||
{stats.gaps.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 min-w-0">
|
||||
{stats.gaps.map((g, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||
<span className="tabular-nums text-muted-foreground truncate">
|
||||
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||
</span>
|
||||
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||
style={{
|
||||
background: 'var(--chart-seq-1)',
|
||||
}}>
|
||||
<span className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||
background: 'var(--chart-1)',
|
||||
}} />
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||
by operator. That is a table's job, not a chart's — and it's how
|
||||
contesters actually read a run. Every value here is also what the
|
||||
stacked chart above draws, so the two can never disagree. */}
|
||||
{stats.rate.length > 1 && (
|
||||
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'table' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||
<AreaTrend data={stats.by_month} empty={empty} />
|
||||
</Card>
|
||||
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
instead of truncating. */}
|
||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')}>
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Local mirror of the Go struct — we duplicate the type rather than depend
|
||||
// on the generated Wails model because the inline `as any` casts are
|
||||
@@ -23,7 +24,7 @@ type UDPConfig = {
|
||||
direction: 'inbound' | 'outbound';
|
||||
name: string;
|
||||
port: number;
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated';
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
||||
multicast: boolean;
|
||||
multicast_group: string;
|
||||
destination_ip: string;
|
||||
@@ -44,43 +45,58 @@ const SERVICE_TYPES: Array<{
|
||||
{
|
||||
id: 'wsjt',
|
||||
direction: 'inbound',
|
||||
label: 'WSJT-X / JTDX / MSHV',
|
||||
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
|
||||
label: 'udpp.svcWsjtLabel',
|
||||
hint: 'udpp.svcWsjtHint',
|
||||
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'adif',
|
||||
direction: 'inbound',
|
||||
label: 'ADIF message (JTAlert, GridTracker)',
|
||||
hint: 'Receives a single ADIF record per packet and logs it.',
|
||||
label: 'udpp.svcAdifLabel',
|
||||
hint: 'udpp.svcAdifHint',
|
||||
defaults: { port: 2333, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'n1mm',
|
||||
direction: 'inbound',
|
||||
label: 'N1MM Logger+ (contest XML)',
|
||||
hint: 'Receives contest QSOs as XML messages.',
|
||||
label: 'udpp.svcN1mmLabel',
|
||||
hint: 'udpp.svcN1mmHint',
|
||||
defaults: { port: 12060, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'remote_call',
|
||||
direction: 'inbound',
|
||||
label: 'Remote callsign (DXHunter, custom)',
|
||||
hint: 'A short text packet containing just a callsign — fills the entry field.',
|
||||
label: 'udpp.svcRemoteLabel',
|
||||
hint: 'udpp.svcRemoteHint',
|
||||
defaults: { port: 12090, multicast: false },
|
||||
},
|
||||
{
|
||||
id: 'db_updated',
|
||||
direction: 'outbound',
|
||||
label: 'DB updated → notify other apps',
|
||||
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||
label: 'udpp.svcDbLabel',
|
||||
hint: 'udpp.svcDbHint',
|
||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'pstrotator_freq',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcPstLabel',
|
||||
hint: 'udpp.svcPstHint',
|
||||
defaults: { port: 12040, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
id: 'n1mm_radioinfo',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcN1mmRadioLabel',
|
||||
hint: 'udpp.svcN1mmRadioHint',
|
||||
defaults: { port: 12060, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
];
|
||||
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
||||
@@ -123,7 +139,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this UDP connection?')) return;
|
||||
if (!confirm(t('udpp.deleteConfirm'))) return;
|
||||
try {
|
||||
await DeleteUDPIntegration(id);
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
@@ -143,7 +159,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||
if (loading) return <div className="text-xs text-muted-foreground italic">{t('udpp.loading')}</div>;
|
||||
|
||||
const inbound = items.filter((i) => i.direction === 'inbound');
|
||||
const outbound = items.filter((i) => i.direction === 'outbound');
|
||||
@@ -151,15 +167,11 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
UDP connections let OpsLog talk to other ham radio software. Inbound
|
||||
connections receive QSOs or callsigns and update the logbook live;
|
||||
outbound connections notify other apps when you log a QSO locally.
|
||||
Enable multicast to share a port with another listener without
|
||||
conflict — required for the typical WSJT-X 2237 setup.
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Inbound — OpsLog listens"
|
||||
title={t('udpp.inboundTitle')}
|
||||
icon={<ArrowDownToLine className="size-4" />}
|
||||
items={inbound}
|
||||
onAdd={() => addNew('inbound')}
|
||||
@@ -168,7 +180,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
onToggle={toggleEnabled}
|
||||
/>
|
||||
<Section
|
||||
title="Outbound — OpsLog sends"
|
||||
title={t('udpp.outboundTitle')}
|
||||
icon={<ArrowUpFromLine className="size-4" />}
|
||||
items={outbound}
|
||||
onAdd={() => addNew('outbound')}
|
||||
@@ -179,10 +191,10 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" onClick={reloadServers}>
|
||||
<RefreshCcw className="size-3.5" /> Reload all
|
||||
<RefreshCcw className="size-3.5" /> {t('udpp.reloadAll')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Restarts every enabled listener after a manual change.
|
||||
{t('udpp.reloadHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +222,7 @@ function Section({
|
||||
onDelete: (id: number) => void;
|
||||
onToggle: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
@@ -217,11 +230,11 @@ function Section({
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
||||
<Plus className="size-3" /> Add
|
||||
<Plus className="size-3" /> {t('udpp.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground italic">{t('udpp.noConnection')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{items.map((c) => {
|
||||
@@ -231,9 +244,9 @@ function Section({
|
||||
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
|
||||
<span className="font-semibold text-sm truncate">{c.name || t('udpp.unnamed')}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{svc?.label ?? c.service_type}
|
||||
{svc ? t(svc.label) : c.service_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
@@ -269,6 +282,7 @@ function EditDialog({
|
||||
onCancel: () => void;
|
||||
onSave: (c: UDPConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
||||
// Service-type list filtered to this connection's direction.
|
||||
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
||||
@@ -292,31 +306,33 @@ function EditDialog({
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
|
||||
{t('udpp.dialogTitle', {
|
||||
action: cfg.id === 0 ? t('udpp.new') : t('udpp.edit'),
|
||||
direction: draft.direction === 'inbound' ? t('udpp.directionInbound') : t('udpp.directionOutbound'),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentService?.hint}
|
||||
{currentService ? t(currentService.hint) : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Name</Label>
|
||||
<Label>{t('udpp.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Service type</Label>
|
||||
<Label>{t('udpp.serviceType')}</Label>
|
||||
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
<SelectItem key={s.id} value={s.id}>{t(s.label)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -324,7 +340,7 @@ function EditDialog({
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Label>{t('udpp.port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1} max={65535}
|
||||
@@ -341,13 +357,13 @@ function EditDialog({
|
||||
checked={draft.multicast}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
||||
/>
|
||||
<span>Multicast</span>
|
||||
<span>{t('udpp.multicast')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.multicast && (
|
||||
<div className="space-y-1">
|
||||
<Label>Multicast group</Label>
|
||||
<Label>{t('udpp.multicastGroup')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="224.0.0.1"
|
||||
@@ -355,14 +371,14 @@ function EditDialog({
|
||||
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
|
||||
{t('udpp.multicastHint')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.direction === 'outbound' && (
|
||||
<div className="space-y-1">
|
||||
<Label>Destination IP</Label>
|
||||
<Label>{t('udpp.destinationIp')}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="127.0.0.1"
|
||||
@@ -377,17 +393,17 @@ function EditDialog({
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
<span>{t('udpp.enabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
|
||||
<Button
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!draft.name.trim() || !draft.port}
|
||||
>
|
||||
Save
|
||||
{t('udpp.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WKMacro { label: string; text: string }
|
||||
export interface WKStatus {
|
||||
@@ -25,6 +26,9 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
onRefreshPorts: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -48,12 +52,13 @@ interface Props {
|
||||
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||
export function WinkeyerPanel({
|
||||
status, ports, port, wpm, macros, sent,
|
||||
status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
|
||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||
onSend, onSendMacro, onStop, onClose,
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
// Step the speed (compact +/- control replaces the old slider).
|
||||
@@ -94,32 +99,39 @@ export function WinkeyerPanel({
|
||||
{/* Header / connection bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{!connected ? (
|
||||
{source === 'icom' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
|
||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
|
||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('wkp.noPorts')}</SelectItem>}
|
||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.refreshPorts')} onClick={onRefreshPorts}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
||||
<Plug className="size-3.5" /> Connect
|
||||
<Plug className="size-3.5" /> {t('wkp.connect')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
||||
<Power className="size-3.5" /> Disconnect
|
||||
<Power className="size-3.5" /> {t('wkp.disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.hide')} onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -130,50 +142,70 @@ export function WinkeyerPanel({
|
||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
||||
status.busy ? 'text-success' : 'text-muted-foreground',
|
||||
)}>
|
||||
{sent || <span className="opacity-50">—</span>}
|
||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||
</div>
|
||||
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
|
||||
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.cwSpeed')}>
|
||||
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
||||
<span className="text-[9px] text-muted-foreground">wpm</span>
|
||||
<div className="flex flex-col -my-0.5">
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title={t('wkp.faster')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
|
||||
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title={t('wkp.slower')}
|
||||
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
|
||||
is on — SEMI or FULL. OFF keys the sidetone but stays in RX. */}
|
||||
{source === 'icom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
|
||||
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
|
||||
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
|
||||
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CW text */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
CW text
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title="Key each character live as you type (backspace removes un-sent chars)">
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
send on type
|
||||
</label>
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||
{t('wkp.sendOnType')}
|
||||
</label>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? 'Type — sent live…' : 'Type and press Enter to send…'}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
|
||||
<Square className="size-3.5" /> Stop
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -181,19 +213,19 @@ export function WinkeyerPanel({
|
||||
someone answers. The seconds box is the gap AFTER the message. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
|
||||
title={t('wkp.autoCallHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||
Auto-call
|
||||
{t('wkp.autoCall')}
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||
<span className="text-[11px] text-muted-foreground">{t('wkp.gap')}</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.gapHint')}>
|
||||
<input type="number" min={0} max={120}
|
||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
|
||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
@@ -211,11 +243,11 @@ export function WinkeyerPanel({
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
|
||||
<span className="text-xs font-medium truncate">{m.label || t('wkp.macroN', { n: i + 1 })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
||||
{status.error && <div className="text-[11px] text-danger">{status.error}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||
import {
|
||||
@@ -12,33 +13,14 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
const hamlogTheme = hamlogGridTheme;
|
||||
|
||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||
|
||||
@@ -53,6 +35,7 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
@@ -67,11 +50,15 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
@@ -95,7 +82,12 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
const count = wb?.count ?? 0;
|
||||
const entries = wb?.entries ?? [];
|
||||
|
||||
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
|
||||
// so the default visibility doesn't clobber the user's saved state. See the
|
||||
// matching note in RecentQSOsGrid.
|
||||
const restoringRef = useRef(true);
|
||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
@@ -103,13 +95,13 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||
headerTooltip: t('wbg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols]);
|
||||
}, [awardCols, COL_CATALOG, t]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -126,10 +118,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
// Re-apply the saved column state after the award columns load (they rebuild
|
||||
// the column set), so the user's visibility choices win over the defaults.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
@@ -168,14 +171,14 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
if (!hasCall) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
Type a callsign in the entry strip to see prior contacts.
|
||||
{t('wbg.typeCall')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (busy && count === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||
checking…
|
||||
{t('wbg.checking')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,8 +186,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||
<Star className="size-8 text-primary fill-current" />
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
||||
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
|
||||
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -193,29 +196,29 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.workedBefore')}</span>
|
||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
|
||||
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||
</div>
|
||||
{wb?.dxcc_name && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{t('wbg.dxcc')} <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
|
||||
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
|
||||
<span> · {wb.dxcc_count} entity QSOs</span>
|
||||
<span> · {t('wbg.entityQsos', { n: wb.dxcc_count })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title="Clear all column filters">
|
||||
<FilterX className="size-3.5" /> Clear filters
|
||||
title={t('wbg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||
<Columns3 className="size-3.5" /> Columns
|
||||
<Columns3 className="size-3.5" /> {t('wbg.columns')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -253,20 +256,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
{count > entries.length && (
|
||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
||||
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||
{t('wbg.olderQsos', { n: count - entries.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Worked-before columns</DialogTitle>
|
||||
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the columns you want visible in the Worked-before table.
|
||||
{t('wbg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
@@ -276,10 +280,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -299,10 +303,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
{awardCols && awardCols.length > 0 && (
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('wbg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('wbg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -318,8 +322,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
|
||||
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
||||
|
||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||
const avail = box.w - 56;
|
||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||
let cursor = 28;
|
||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
||||
return (
|
||||
<>
|
||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{box.title}
|
||||
</text>
|
||||
{cols.map(({ f, x }) => (
|
||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
||||
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||
</text>
|
||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
||||
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{values[f] ?? ''}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{box.footer && (
|
||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||
{box.footer}
|
||||
</text>
|
||||
|
||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
||||
onChange={(w) => onPatchBox({ w })} />
|
||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||
onChange={(h) => onPatchBox({ h })} />
|
||||
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||
onChange={(radius) => onPatchBox({ radius })} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||
|
||||
@@ -233,7 +233,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
<DialogContent className="max-w-[1260px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
<Sparkles className="size-5 text-warning" />
|
||||
QSL card designer
|
||||
{view !== 'home' && (
|
||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||
@@ -246,7 +246,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
|
||||
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{view === 'home' && (
|
||||
@@ -284,7 +284,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-warning text-warning" />}
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
@@ -299,7 +299,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-destructive' : 'w-7'}`}
|
||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||
</Button>
|
||||
@@ -323,7 +323,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
||||
{proposals.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-info"
|
||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||
>
|
||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||
|
||||
@@ -75,13 +75,13 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
<DialogContent className="max-w-[820px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
||||
<Mail className="size-5 text-danger" /> Send OpsLog QSL by e-mail
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 px-6 py-5">
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
||||
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||
)}
|
||||
|
||||
{open && !templateId && !error && (
|
||||
@@ -107,7 +107,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
||||
|
||||
<DialogFooter>
|
||||
{sent ? (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
||||
h: number;
|
||||
bg: string;
|
||||
bg_opacity: number;
|
||||
fg?: string; // text colour (default dark); set it when using a dark background
|
||||
radius: number;
|
||||
title: string;
|
||||
fields: string[];
|
||||
|
||||
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||
function removeTokens(existing: string | undefined, refs: string): string {
|
||||
let out = existing ?? '';
|
||||
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
return out.replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||
// The award engine honours these regardless of how the award matches, so a
|
||||
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||
// data lives in its conventional place and exports correctly. Awards that match
|
||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||
// on the override — we don't pollute the text field with a code.
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||
const byCode = parseAwardRefs(awardRefs);
|
||||
// References the operator REMOVED since the editor opened: for in-field awards
|
||||
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||
if (prevAwardRefs != null) {
|
||||
const prev = parseAwardRefs(prevAwardRefs);
|
||||
for (const [code, prevRef] of Object.entries(prev)) {
|
||||
const field = fieldOf[code] || code.toLowerCase();
|
||||
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||
if (removed.length === 0) continue;
|
||||
const rm = removed.join(',');
|
||||
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||
else payload.notes = removeTokens(payload.notes, rm);
|
||||
}
|
||||
}
|
||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||
const overrides: string[] = [];
|
||||
for (const [code, ref] of Object.entries(byCode)) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { themeQuartz } from 'ag-grid-community';
|
||||
|
||||
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
|
||||
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
|
||||
// variables (see style.css) instead of hardcoded hex, so the grids follow the
|
||||
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
|
||||
// flipping <html data-theme> re-resolves these var() references with no
|
||||
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
|
||||
// height) chain a `.withParams({...})` on top.
|
||||
export const hamlogGridTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: 'var(--card)',
|
||||
foregroundColor: 'var(--foreground)',
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--muted-foreground)',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
|
||||
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
|
||||
selectedRowBackgroundColor: 'var(--accent)',
|
||||
borderColor: 'var(--border)',
|
||||
rowBorder: { color: 'var(--border)', width: 1 },
|
||||
columnBorder: { color: 'var(--border)', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: 'var(--primary)',
|
||||
iconSize: 12,
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
|
||||
// Lightweight i18n: a flat key→string dictionary per language, a context that
|
||||
// holds the active language, and a t() lookup with {var} interpolation. English
|
||||
// is the source/fallback; a missing French key falls back to English, then to
|
||||
// the key itself. Language is persisted (localStorage + portable UI pref) and
|
||||
// chosen once at first run via the flag gate below.
|
||||
export type Lang = 'en' | 'fr';
|
||||
const LS_KEY = 'opslog.lang';
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const en: Dict = {
|
||||
// Menu bar
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
|
||||
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
// Duplicates modal
|
||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
||||
'dup.hint': 'Each group is the same contact logged more than once (callsign + band + mode). The first (oldest) is left unchecked; tick the ones to delete.',
|
||||
'dup.window': 'Within', 'dup.minutes': 'min', 'dup.sameDay': 'same day', 'dup.summary': '{groups} groups · {qsos} duplicate QSOs · {sel} to delete',
|
||||
'dup.selectExtras': 'Select all but the first', 'dup.deselectAll': 'Deselect all', 'dup.deleteSel': 'Delete {n} selected', 'dup.deleteConfirm': 'Delete {n} QSO(s)? This cannot be undone.', 'dup.deleted': '{n} duplicate(s) deleted', 'dup.keep': 'oldest', 'dup.close': 'Close',
|
||||
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
|
||||
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
|
||||
// Tabs
|
||||
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
|
||||
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
|
||||
// Entry form
|
||||
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
|
||||
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
|
||||
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
||||
'field.txFreq': 'TX Freq (MHz)', 'field.freqTuneHint': 'Type a frequency and press Enter to tune the radio here.', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
|
||||
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
|
||||
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||
// Language chooser
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
||||
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||
|
||||
'stats.byContinentSub': 'Share of the log',
|
||||
'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom',
|
||||
'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included',
|
||||
'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)',
|
||||
'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.',
|
||||
'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.',
|
||||
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
|
||||
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
|
||||
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
|
||||
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||
'msg.expand': 'Click to read the full message',
|
||||
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
||||
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
||||
'offline.title': 'Offline — {n} QSO(s) waiting',
|
||||
'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.",
|
||||
'offline.empty': 'Nothing waiting.',
|
||||
'offline.retry': 'Retry now',
|
||||
'offline.synced': '{n} QSO(s) added to the logbook',
|
||||
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
|
||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
||||
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
||||
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||
// Settings navigation (sidebar groups + section names)
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
// General panel
|
||||
'gen.hint': 'App behaviour (saved instantly).',
|
||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
|
||||
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
||||
'email.title': 'E-mail',
|
||||
// Station panel
|
||||
'station.loading': 'Loading profile…',
|
||||
'station.hint': 'Editing the active profile: {name}. Switch profiles in the Profiles section to edit a different one.',
|
||||
'station.stationCall': 'Station callsign', 'station.stationCallHint': "What's transmitted (ADIF STATION_CALLSIGN).",
|
||||
'station.opCall': 'Operator callsign', 'station.opCallHint': "Who's at the radio (ADIF OPERATOR).",
|
||||
'station.ownerCall': 'Owner callsign', 'station.ownerBlank': '(leave blank if same as station)',
|
||||
'station.ownerHint': 'Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).',
|
||||
'station.opName': 'Operator name', 'station.opNameHint': 'Your first name — used as the signature on QSL cards.',
|
||||
'station.autofill': 'Auto-filled from the callsign — editable (stamped as MY_* on each QSO)',
|
||||
'station.myGrid': 'My grid', 'station.myCountry': 'My country', 'station.dxcc': 'DXCC #', 'station.cqz': 'CQ zone', 'station.ituz': 'ITU zone',
|
||||
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'State / pref', 'station.county': 'County',
|
||||
'station.street': 'Street address', 'station.postal': 'Postal code', 'station.city': 'City', 'station.sota': 'SOTA ref', 'station.pota': 'POTA ref',
|
||||
// Confirmations panel
|
||||
'conf.hint': 'Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty.',
|
||||
'conf.sent': 'Sent', 'conf.rcvd': 'Rcvd', 'conf.paperQsl': 'Paper QSL',
|
||||
'conf.note': '"Sent" = the QSO\'s upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.',
|
||||
'conf.blank': '— leave blank —', 'conf.y': 'Y (yes)', 'conf.n': 'N (no)', 'conf.r': 'R (requested)', 'conf.q': 'Q (queued)', 'conf.i': 'I (ignore)',
|
||||
'udp.hint': 'Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||
'operating.hint': "Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on.",
|
||||
'op.intro1': 'Define your rigs (stations) and the antennas connected to each one. For every antenna, tick the bands it covers. ',
|
||||
'op.intro2': ' marks the default antenna for that band — when you change the band in the entry strip, the matching rig + antenna auto-fill the MY_RIG and MY_ANTENNA ADIF fields. Only one antenna can be the default per band; setting one clears the previous default.',
|
||||
'op.addRig': 'Add rig', 'op.noRig': 'No rig configured yet. Click "Add rig" to get started.',
|
||||
'op.rigNamePh': 'Rig name (also stamped as MY_RIG)', 'op.powerW': 'Power (W)',
|
||||
'op.antenna': 'Antenna', 'op.deleteRig': 'Delete rig', 'op.noAntenna': 'No antenna yet — click "Antenna" above to add one.',
|
||||
'op.antNamePh': 'Antenna name (also stamped as MY_ANTENNA)', 'op.default': 'Default', 'op.set': 'Set',
|
||||
'op.defaultOn': 'Default antenna for this band — click to unset', 'op.defaultOff': 'Click to make this antenna the default for this band',
|
||||
'op.collapse': 'Collapse', 'op.expand': 'Expand', 'op.deleteAntConfirm': 'Delete this antenna?', 'op.edit': 'Edit',
|
||||
// Lookup panel
|
||||
'lk.hint': 'Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving.',
|
||||
'lk.provider': 'Provider', 'lk.primary': 'Primary', 'lk.failsafe': 'Failsafe', 'lk.user': 'User', 'lk.password': 'Password', 'lk.result': 'Result',
|
||||
'lk.testing': 'Testing…', 'lk.test': 'Test', 'lk.testTitle': "Run a sample lookup against the active profile's callsign to verify credentials",
|
||||
'lk.failsafeNote': 'Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.',
|
||||
'lk.display': 'Display', 'lk.showPics': 'Show QRZ profile pictures',
|
||||
'lk.showPicsHint': 'Display the photo from QRZ.com next to the worked-before matrix. May noticeably slow lookups during busy contest days; turn off if you operate fast.',
|
||||
'lk.cache': 'Cache', 'lk.cacheHint': "Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.",
|
||||
'lk.ttl': 'TTL (days)', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
|
||||
// Bands panel
|
||||
'bnd.hint': "Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order.",
|
||||
'bnd.available': 'Available', 'bnd.allSelected': 'All catalog bands selected.', 'bnd.customPh': 'Custom band (e.g. 4m)',
|
||||
'bnd.selected': 'Selected ({n})', 'bnd.none': 'No band selected — pick from the left.',
|
||||
// Modes panel
|
||||
'mds.hint': 'Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you\'ve typed something).',
|
||||
'mds.allSelected': 'All catalog modes selected.', 'mds.customPh': 'Custom mode', 'mds.order': 'Order', 'mds.mode': 'Mode',
|
||||
'mds.rstSnt': 'RST snt', 'mds.rstRcv': 'RST rcv', 'mds.none': 'No mode selected — pick from the left.', 'mds.addBlank': 'Add blank row',
|
||||
'mds.defaultRstTitle': 'Default RST: {sent} / {rcvd}',
|
||||
'mds.rstLists': 'RST report lists', 'mds.rstListsHint': "The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.",
|
||||
'mds.phone': 'Phone (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Digital (FT8/FT4/JT…) — dB',
|
||||
// Cluster panel
|
||||
'clu.hint': 'Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it.',
|
||||
'clu.name': 'Name', 'clu.hostPort': 'Host:port', 'clu.status': 'Status', 'clu.actions': 'Actions',
|
||||
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
|
||||
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
||||
'clu.freeNodes': 'Free public nodes:',
|
||||
// Backup panel
|
||||
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
||||
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
||||
'bk.auto': 'Automatic backup when closing OpsLog (max once per day)', 'bk.folder': 'Backup folder', 'bk.folderPh': 'leave empty for default', 'bk.browse': 'Browse…',
|
||||
'bk.effective': 'Effective folder:', 'bk.defaultUse': 'If empty, OpsLog uses the default:', 'bk.rotation': 'Rotation (copies to keep)', 'bk.zip': 'ZIP backup (smaller file)',
|
||||
'bk.lastRun': 'Last run:', 'bk.never': 'never', 'bk.backupNow': 'Back up now', 'bk.backingUp': 'Backing up…', 'bk.writtenTo': 'Backup written to',
|
||||
// Section hints (hardware/software panel headers)
|
||||
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
||||
'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).',
|
||||
'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 Ultrabeam 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.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||
// Profiles panel
|
||||
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
||||
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
|
||||
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
|
||||
// Database panel
|
||||
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
||||
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||
'db.activeBackend': 'Active backend:', 'db.logbook': 'Logbook:', 'db.config': 'Config:', 'db.mysqlConnected': 'shared MySQL connected ✓', 'db.localSqlite': 'local SQLite',
|
||||
'db.fallback': 'MySQL is enabled but the connection failed at startup — OpsLog is running on the local SQLite database.',
|
||||
'db.mysqlNote': "Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.",
|
||||
// Email panel
|
||||
'em.none': 'None', 'em.smtpAuth': 'SMTP requires authorization', 'em.username': 'Username', 'em.fromAddr': 'From address', 'em.replyTo': 'Reply-To address', 'em.replyToPh': '(optional — where replies go)', 'em.replyToHint': 'Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.',
|
||||
'em.sendTest': 'Send test e-mail', 'em.sendingTest': 'Sending test…', 'em.testSent': 'Test e-mail sent ✓', 'em.testFailed': 'Test failed: ',
|
||||
'em.qslCardEmail': 'OpsLog QSL card e-mail', 'em.qslVarsHint': 'Message sent with the QSL card. Variables:', 'em.subject': 'Subject', 'em.body': 'Body', 'em.autoSend': 'Auto-send OpsLog QSL when a QSO is logged', 'em.autoSendHint': 'Sends automatically only when the contact has an e-mail address and a default QSL template exists.',
|
||||
'settings.title': 'Preferences',
|
||||
'btn.cancel': 'Cancel', 'btn.save': 'Save', 'btn.saveClose': 'Save and close', 'btn.savingLong': 'Saving…',
|
||||
// Component keys (chat / call history / band map / first-run / contest / adif extras)
|
||||
'chatp.chat': 'Chat', 'chatp.online': 'Online', 'chatp.close': 'Close', 'chatp.noMessages': 'No messages yet.', 'chatp.messagePh': 'Message…',
|
||||
'chp.workedBefore': 'Worked before', 'chp.typeCallsign': 'Type a callsign to see prior contacts.', 'chp.checking': 'checking…', 'chp.new': 'NEW',
|
||||
'chp.noPriorQso': 'No prior QSO with this callsign.', 'chp.first': 'First', 'chp.last': 'Last', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Band', 'chp.mode': 'Mode',
|
||||
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
|
||||
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
||||
'ctp.contestSetup': 'Contest setup', 'ctp.live': 'Live', 'ctp.chooseContest': '— choose a contest —', 'ctp.serial': 'Serial', 'ctp.fixedExchange': 'Fixed exchange', 'ctp.nextSerial': 'Next serial', 'ctp.yourExchangePh': 'your exchange', 'ctp.startUtc': 'Start (UTC)', 'ctp.endUtc': 'End (UTC)', 'ctp.windowHint': 'empty end = open · leave blank to count all',
|
||||
'ctp.stopContest': 'Stop contest', 'ctp.startContest': 'Start contest', 'ctp.activeHint': 'The entry form shows Snt/Rcv and a DUPE badge; QSOs are stamped with this contest.', 'ctp.inactiveHint': 'Pick a contest, set the window, then Start.', 'ctp.scoreboard': 'Scoreboard', 'ctp.estimate': 'estimate', 'ctp.qsos': 'QSOs', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Last 60 min', 'ctp.band': 'Band',
|
||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
|
||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||
'awed.exportOne': 'Share {code}',
|
||||
'awed.onlyHere': 'local',
|
||||
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
||||
'awed.builtin': 'Built-in',
|
||||
'awed.awardsFolder': 'Awards folder',
|
||||
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||
'awed.protectedFlag': 'Protected',
|
||||
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||
'awed.exportOneTitle': 'Export THIS award on its own (definition + references + their regexes) — the file you send someone',
|
||||
'awed.importCollisionTitle': 'Some of these awards already exist',
|
||||
'awed.importCollisionHint': 'Nothing is replaced unless you say so. Import as a copy installs theirs alongside yours, so you can compare before deleting one.',
|
||||
'awed.importRefs': '{n} reference(s)',
|
||||
'awed.importNew': 'New — will be added.',
|
||||
'awed.importExists': 'You already have "{name}" with {n} reference(s).',
|
||||
'awed.importProtected': 'built-in',
|
||||
'awed.importKeepMine': 'Keep mine',
|
||||
'awed.importReplace': 'Replace mine',
|
||||
'awed.importCopy': 'Import as {code}-2',
|
||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
||||
// Grids column headers (recent / worked-before / cluster)
|
||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date off', 'rqg.c.callsign': 'Callsign', 'rqg.c.band': 'Band', 'rqg.c.band_rx': 'Band RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Submode', 'rqg.c.freq_hz': 'Freq (TX)', 'rqg.h.freq_hz': 'Freq', 'rqg.c.freq_rx_hz': 'Freq (RX)', 'rqg.h.freq_rx_hz': 'Freq RX', 'rqg.c.rst_sent': 'RST sent', 'rqg.c.rst_rcvd': 'RST rcvd', 'rqg.c.tx_pwr': 'TX Power', 'rqg.c.name': 'Name', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Address', 'rqg.c.country': 'Country', 'rqg.c.state': 'State', 'rqg.c.cnty': 'County', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Grid', 'rqg.c.gridsquare_ext': 'Grid Ext', 'rqg.h.gridsquare_ext': 'GridExt', 'rqg.c.vucc_grids': 'VUCC grids', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'SOTA ref', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'POTA ref', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Age', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'Email', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL sent', 'rqg.c.qsl_rcvd': 'QSL rcvd', 'rqg.c.qsl_sent_date': 'QSL sent date', 'rqg.h.qsl_sent_date': 'QSL S date', 'rqg.c.qsl_rcvd_date': 'QSL rcvd date', 'rqg.h.qsl_rcvd_date': 'QSL R date', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'QSL msg', 'rqg.c.qslmsg_rcvd': 'QSL msg rcvd', 'rqg.c.lotw_sent': 'LoTW sent', 'rqg.c.lotw_rcvd': 'LoTW rcvd', 'rqg.c.lotw_sent_date': 'LoTW sent date', 'rqg.h.lotw_sent_date': 'LoTW S date', 'rqg.c.lotw_rcvd_date': 'LoTW rcvd date', 'rqg.h.lotw_rcvd_date': 'LoTW R date', 'rqg.c.eqsl_sent': 'eQSL sent', 'rqg.c.eqsl_rcvd': 'eQSL rcvd', 'rqg.c.eqsl_sent_date': 'eQSL sent date', 'rqg.h.eqsl_sent_date': 'eQSL S date', 'rqg.c.eqsl_rcvd_date': 'eQSL rcvd date', 'rqg.h.eqsl_rcvd_date': 'eQSL R date', 'rqg.c.opslog_qsl_card_sent': 'OpsLog QSL', 'rqg.c.opslog_recording_sent': 'Recording sent', 'rqg.h.opslog_recording_sent': 'Rec sent', 'rqg.c.clublog_sent': 'ClubLog sent', 'rqg.c.clublog_sent_date': 'ClubLog sent date', 'rqg.h.clublog_sent_date': 'ClubLog S date', 'rqg.c.hrdlog_sent': 'HRDLog sent', 'rqg.c.hrdlog_sent_date': 'HRDLog sent date', 'rqg.h.hrdlog_sent_date': 'HRDLog S date', 'rqg.c.qrz_sent': 'QRZ.com sent', 'rqg.c.qrz_rcvd': 'QRZ.com rcvd', 'rqg.c.qrz_sent_date': 'QRZ.com sent date', 'rqg.h.qrz_sent_date': 'QRZ.com S date', 'rqg.c.qrz_rcvd_date': 'QRZ.com rcvd date', 'rqg.h.qrz_rcvd_date': 'QRZ.com R date', 'rqg.c.contest_id': 'Contest ID', 'rqg.h.contest_id': 'Contest', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'SRX string', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'STX string', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Precedence', 'rqg.c.arrl_sect': 'ARRL section', 'rqg.h.arrl_sect': 'ARRL sect', 'rqg.c.prop_mode': 'Prop mode', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Sat name', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Sat mode', 'rqg.c.ant_az': 'Ant az', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Ant el', 'rqg.h.ant_el': 'El', 'rqg.c.ant_path': 'Ant path', 'rqg.h.ant_path': 'Path', 'rqg.c.station_callsign': 'Station call', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Operator', 'rqg.c.my_grid': 'My grid', 'rqg.c.my_country': 'My country', 'rqg.h.my_country': 'My ctry', 'rqg.c.my_state': 'My state', 'rqg.c.my_cnty': 'My county', 'rqg.h.my_cnty': 'My cnty', 'rqg.c.my_iota': 'My IOTA', 'rqg.c.my_sota': 'My SOTA', 'rqg.c.my_pota': 'My POTA', 'rqg.c.my_dxcc': 'My DXCC', 'rqg.h.my_dxcc': 'My DXCC#', 'rqg.c.my_cq_zone': 'My CQ zone', 'rqg.h.my_cq_zone': 'My CQZ', 'rqg.c.my_itu_zone': 'My ITU zone', 'rqg.h.my_itu_zone': 'My ITU', 'rqg.c.my_lat': 'My lat', 'rqg.c.my_lon': 'My lon', 'rqg.c.my_street': 'My street', 'rqg.h.my_street': 'Street', 'rqg.c.my_city': 'My city', 'rqg.h.my_city': 'City', 'rqg.c.my_zip': 'My ZIP', 'rqg.h.my_zip': 'ZIP', 'rqg.c.my_rig': 'My rig', 'rqg.c.my_antenna': 'My antenna', 'rqg.h.my_antenna': 'My ant', 'rqg.c.comment': 'Comment', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Created', 'rqg.h.created': 'Created at', 'rqg.c.updated': 'Updated', 'rqg.h.updated': 'Updated at', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Contacted', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Uploads', 'rqg.grpContest': 'Contest', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'My station', 'rqg.grpMisc': 'Misc', 'rqg.grpAwards': 'Awards', 'rqg.awardTip': '{name} — reference this QSO counts for', 'rqg.clearFiltersTitle': 'Clear all column filters', 'rqg.clearFilters': 'Clear filters', 'rqg.columns': 'Columns', 'rqg.pickerDesc': 'Pick the columns you want visible in the Recent QSOs table. Your selection is saved.', 'rqg.all': 'all', 'rqg.none': 'none', 'rqg.resetDefaults': 'Reset to defaults', 'rqg.done': 'Done',
|
||||
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
|
||||
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.wkdCall': 'WKD CALL', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
|
||||
};
|
||||
|
||||
const fr: Dict = {
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
|
||||
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
||||
'dup.window': 'En moins de', 'dup.minutes': 'min', 'dup.sameDay': 'même jour', 'dup.summary': '{groups} groupes · {qsos} QSO en double · {sel} à supprimer',
|
||||
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
|
||||
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
|
||||
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
|
||||
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
|
||||
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
|
||||
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
||||
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
|
||||
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
||||
'field.txFreq': 'Fréq TX (MHz)', 'field.freqTuneHint': 'Tape une fréquence et appuie sur Entrée pour y accorder la radio.', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
|
||||
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
||||
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
||||
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||
|
||||
'stats.byContinentSub': 'Part du journal',
|
||||
'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé',
|
||||
'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris',
|
||||
'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)',
|
||||
'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.',
|
||||
'stats.avgActive': 'Moy. / heure en l’air', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, c’est vendre 8 h d’effort comme un score de 48 h.',
|
||||
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
|
||||
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
|
||||
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
|
||||
'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
|
||||
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
||||
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
||||
'offline.title': 'Hors ligne — {n} QSO en attente',
|
||||
'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.",
|
||||
'offline.empty': 'Rien en attente.',
|
||||
'offline.retry': 'Réessayer maintenant',
|
||||
'offline.synced': '{n} QSO ajoutés au journal',
|
||||
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
|
||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
||||
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
||||
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
|
||||
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
|
||||
'email.title': 'E-mail',
|
||||
'station.loading': 'Chargement du profil…',
|
||||
'station.hint': 'Édition du profil actif : {name}. Change de profil dans la section Profils pour en éditer un autre.',
|
||||
'station.stationCall': 'Indicatif station', 'station.stationCallHint': "Ce qui est émis (ADIF STATION_CALLSIGN).",
|
||||
'station.opCall': 'Indicatif opérateur', 'station.opCallHint': "Qui est à la radio (ADIF OPERATOR).",
|
||||
'station.ownerCall': 'Indicatif propriétaire', 'station.ownerBlank': '(vide si identique à la station)',
|
||||
'station.ownerHint': 'Propriétaire légal — ne diffère que pour les stations de club ou setups distants (ADIF STATION_OWNER).',
|
||||
'station.opName': "Nom de l'opérateur", 'station.opNameHint': 'Ton prénom — utilisé comme signature sur les cartes QSL.',
|
||||
'station.autofill': "Auto-rempli depuis l'indicatif — éditable (inscrit en MY_* sur chaque QSO)",
|
||||
'station.myGrid': 'Mon locator', 'station.myCountry': 'Mon pays', 'station.dxcc': 'DXCC #', 'station.cqz': 'Zone CQ', 'station.ituz': 'Zone ITU',
|
||||
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'État / préf', 'station.county': 'Comté',
|
||||
'station.street': 'Adresse', 'station.postal': 'Code postal', 'station.city': 'Ville', 'station.sota': 'Réf SOTA', 'station.pota': 'Réf POTA',
|
||||
'conf.hint': 'Statut QSL / eQSL / LoTW / upload par défaut appliqué à chaque QSO enregistré — manuellement ou via auto-log UDP depuis WSJT-X / JTDX / MSHV. Laisse un champ vide pour garder la colonne du QSO vide.',
|
||||
'conf.sent': 'Envoyée', 'conf.rcvd': 'Reçue', 'conf.paperQsl': 'QSL papier',
|
||||
'conf.note': '« Envoyée » = statut d\'upload du QSO vers le service ; « N » est typique pour qu\'OpsLog suive les QSO restant à uploader. Club Log & HRDLog sont upload-only (pas de « reçue » en ADIF) ; QRZ.com a aussi « Reçue » = confirmé en retour.',
|
||||
'conf.blank': '— laisser vide —', 'conf.y': 'Y (oui)', 'conf.n': 'N (non)', 'conf.r': 'R (demandée)', 'conf.q': 'Q (en file)', 'conf.i': 'I (ignorer)',
|
||||
'udp.hint': 'Écoute les logs QSO de WSJT-X / JTDX / MSHV, les messages ADIF de JTAlert/GridTracker, ou de simples paquets d\'indicatif d\'outils externes. Les connexions sortantes transmettent chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).',
|
||||
'operating.hint': "Définis tes stations et les antennes utilisées sur chaque bande. Le bandeau de saisie remplit auto MY_RIG et MY_ANTENNA selon l'antenne par défaut de la bande.",
|
||||
'op.intro1': 'Définis tes stations et les antennes connectées à chacune. Pour chaque antenne, coche les bandes qu\'elle couvre. ',
|
||||
'op.intro2': ' marque l\'antenne par défaut de cette bande — quand tu changes de bande dans le bandeau, la station + antenne correspondantes remplissent auto les champs MY_RIG et MY_ANTENNA. Une seule antenne peut être par défaut par bande ; en définir une retire la précédente.',
|
||||
'op.addRig': 'Ajouter station', 'op.noRig': 'Aucune station configurée. Clique « Ajouter station » pour commencer.',
|
||||
'op.rigNamePh': 'Nom de la station (inscrit en MY_RIG)', 'op.powerW': 'Puissance (W)',
|
||||
'op.antenna': 'Antenne', 'op.deleteRig': 'Supprimer la station', 'op.noAntenna': 'Aucune antenne — clique « Antenne » ci-dessus pour en ajouter.',
|
||||
'op.antNamePh': "Nom de l'antenne (inscrit en MY_ANTENNA)", 'op.default': 'Défaut', 'op.set': 'Définir',
|
||||
'op.defaultOn': 'Antenne par défaut pour cette bande — clic pour retirer', 'op.defaultOff': 'Clic pour définir comme antenne par défaut pour cette bande',
|
||||
'op.collapse': 'Réduire', 'op.expand': 'Développer', 'op.deleteAntConfirm': 'Supprimer cette antenne ?', 'op.edit': 'Éditer',
|
||||
'lk.hint': 'Choisis un fournisseur Principal et un Secours optionnel (interrogé seulement si le Principal ne renvoie rien). Clique Test pour vérifier les identifiants sans enregistrer.',
|
||||
'lk.provider': 'Fournisseur', 'lk.primary': 'Principal', 'lk.failsafe': 'Secours', 'lk.user': 'Utilisateur', 'lk.password': 'Mot de passe', 'lk.result': 'Résultat',
|
||||
'lk.testing': 'Test…', 'lk.test': 'Test', 'lk.testTitle': "Lance une recherche test sur l'indicatif du profil actif pour vérifier les identifiants",
|
||||
'lk.failsafeNote': 'Le Secours n\'est consulté que si le Principal ne trouve rien ou échoue. Décoche les deux en contest pour éviter tout accès réseau.',
|
||||
'lk.display': 'Affichage', 'lk.showPics': 'Afficher les photos de profil QRZ',
|
||||
'lk.showPicsHint': 'Affiche la photo de QRZ.com à côté de la matrice « déjà contacté ». Peut ralentir les recherches en contest ; désactive si tu opères vite.',
|
||||
'lk.cache': 'Cache', 'lk.cacheHint': "Les recherches réussies sont mises en cache localement pour ne pas re-interroger le même indicatif. Le TTL contrôle la durée avant une nouvelle requête.",
|
||||
'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
|
||||
'bnd.hint': "Choisis les bandes que tu utilises vraiment. Le bandeau de saisie, la grille de bandes et le sélecteur de carte n'affichent que celles de droite. L'ordre à droite = ordre d'affichage.",
|
||||
'bnd.available': 'Disponibles', 'bnd.allSelected': 'Toutes les bandes du catalogue sont sélectionnées.', 'bnd.customPh': 'Bande perso (ex. 4m)',
|
||||
'bnd.selected': 'Sélectionnés ({n})', 'bnd.none': 'Aucune bande — choisis à gauche.',
|
||||
'mds.hint': 'Choisis à droite les modes que tu utilises. Partout où l\'interface propose un mode, elle parcourt la colonne de droite. En sélectionnant un mode dans le formulaire, RST envoyé/reçu se remplissent avec les valeurs par défaut ci-dessous (sauf si tu as tapé quelque chose).',
|
||||
'mds.allSelected': 'Tous les modes du catalogue sont sélectionnés.', 'mds.customPh': 'Mode perso', 'mds.order': 'Ordre', 'mds.mode': 'Mode',
|
||||
'mds.rstSnt': 'RST env', 'mds.rstRcv': 'RST reç', 'mds.none': 'Aucun mode — choisis à gauche.', 'mds.addBlank': 'Ajouter une ligne',
|
||||
'mds.defaultRstTitle': 'RST par défaut : {sent} / {rcvd}',
|
||||
'mds.rstLists': 'Listes de report RST', 'mds.rstListsHint': "Les choix proposés dans les menus RST du formulaire, par famille de mode. Une valeur par ligne (ou séparées par espaces). La première est en haut de la liste.",
|
||||
'mds.phone': 'Phonie (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Numérique (FT8/FT4/JT…) — dB',
|
||||
'clu.hint': 'Connecte-toi à un ou plusieurs nœuds DX cluster (telnet). Le premier serveur activé est le maître — les commandes tapées et d\'init passent par lui.',
|
||||
'clu.name': 'Nom', 'clu.hostPort': 'Hôte:port', 'clu.status': 'Statut', 'clu.actions': 'Actions',
|
||||
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
|
||||
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
||||
'clu.freeNodes': 'Nœuds publics gratuits :',
|
||||
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
||||
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
||||
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
||||
'bk.effective': 'Dossier effectif :', 'bk.defaultUse': 'Si vide, OpsLog utilise le défaut :', 'bk.rotation': 'Rotation (copies à garder)', 'bk.zip': 'Sauvegarde ZIP (fichier plus petit)',
|
||||
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
|
||||
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||
'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.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'Ultrabeam 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.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
||||
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
||||
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||
'db.activeBackend': 'Backend actif :', 'db.logbook': 'Journal :', 'db.config': 'Config :', 'db.mysqlConnected': 'MySQL partagé connecté ✓', 'db.localSqlite': 'SQLite local',
|
||||
'db.fallback': "MySQL est activé mais la connexion a échoué au démarrage — OpsLog tourne sur la base SQLite locale.",
|
||||
'db.mysqlNote': "Seuls les QSO vont dans MySQL ; tes réglages, profils, stations et cluster restent locaux (et rapides). Les QSO locaux existants ne sont pas copiés — importe-les dans le journal partagé si tu veux ton historique là-bas.",
|
||||
'em.none': 'Aucun', 'em.smtpAuth': 'Le SMTP requiert une authentification', 'em.username': 'Utilisateur', 'em.fromAddr': 'Adresse expéditeur', 'em.replyTo': 'Adresse de réponse', 'em.replyToPh': '(optionnel — où vont les réponses)', 'em.replyToHint': "Vide = utilise l'adresse expéditeur. Renseigne-la pour que les correspondants répondent p. ex. sur ta boîte perso.",
|
||||
'em.sendTest': 'Envoyer un e-mail test', 'em.sendingTest': 'Envoi du test…', 'em.testSent': 'E-mail test envoyé ✓', 'em.testFailed': 'Échec du test : ',
|
||||
'em.qslCardEmail': 'E-mail de carte QSL OpsLog', 'em.qslVarsHint': 'Message envoyé avec la carte QSL. Variables :', 'em.subject': 'Objet', 'em.body': 'Corps', 'em.autoSend': "Envoyer auto la QSL OpsLog à l'enregistrement d'un QSO", 'em.autoSendHint': "Envoi automatique uniquement si le contact a une adresse e-mail et qu'un modèle QSL par défaut existe.",
|
||||
'settings.title': 'Préférences',
|
||||
'btn.cancel': 'Annuler', 'btn.save': 'Enregistrer', 'btn.saveClose': 'Enregistrer et fermer', 'btn.savingLong': 'Enregistrement…',
|
||||
'chatp.chat': 'Chat', 'chatp.online': 'En ligne', 'chatp.close': 'Fermer', 'chatp.noMessages': 'Aucun message pour le moment.', 'chatp.messagePh': 'Message…',
|
||||
'chp.workedBefore': 'Déjà contacté', 'chp.typeCallsign': 'Saisis un indicatif pour voir les contacts précédents.', 'chp.checking': 'vérification…', 'chp.new': 'NOUVEAU',
|
||||
'chp.noPriorQso': 'Aucun QSO antérieur avec cet indicatif.', 'chp.first': 'Premier', 'chp.last': 'Dernier', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Bande', 'chp.mode': 'Mode',
|
||||
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
|
||||
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
||||
'ctp.contestSetup': 'Configuration du contest', 'ctp.live': 'En direct', 'ctp.chooseContest': '— choisir un contest —', 'ctp.serial': 'Numéro de série', 'ctp.fixedExchange': 'Échange fixe', 'ctp.nextSerial': 'Prochain numéro', 'ctp.yourExchangePh': 'ton échange', 'ctp.startUtc': 'Début (UTC)', 'ctp.endUtc': 'Fin (UTC)', 'ctp.windowHint': 'fin vide = ouvert · laisser vide pour tout compter',
|
||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
|
||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||
'awed.exportOne': 'Partager {code}',
|
||||
'awed.onlyHere': 'local',
|
||||
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
||||
'awed.builtin': 'Intégré',
|
||||
'awed.awardsFolder': 'Dossier awards',
|
||||
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||
'awed.protectedFlag': 'Protégé',
|
||||
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||
'awed.exportOneTitle': 'Exporter CE diplôme seul (définition + références + leurs regex) — le fichier que tu envoies à quelqu’un',
|
||||
'awed.importCollisionTitle': 'Certains de ces diplômes existent déjà',
|
||||
'awed.importCollisionHint': 'Rien n’est remplacé sans ton accord. « Importer en copie » installe le sien à côté du tien : tu compares, puis tu supprimes l’un des deux.',
|
||||
'awed.importRefs': '{n} référence(s)',
|
||||
'awed.importNew': 'Nouveau — sera ajouté.',
|
||||
'awed.importExists': 'Tu as déjà « {name} » avec {n} référence(s).',
|
||||
'awed.importProtected': 'intégré',
|
||||
'awed.importKeepMine': 'Garder le mien',
|
||||
'awed.importReplace': 'Remplacer le mien',
|
||||
'awed.importCopy': 'Importer en {code}-2',
|
||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
||||
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
|
||||
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.wkdCall': 'DÉJÀ CTC', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
|
||||
};
|
||||
|
||||
const dicts: Record<Lang, Dict> = { en, fr };
|
||||
|
||||
function translate(lang: Lang, key: string, vars?: Record<string, string | number>): string {
|
||||
let s = dicts[lang][key] ?? dicts.en[key] ?? key;
|
||||
if (vars) for (const k of Object.keys(vars)) s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k]));
|
||||
return s;
|
||||
}
|
||||
|
||||
type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (key: string, vars?: Record<string, string | number>) => string; chosen: boolean };
|
||||
const I18nContext = createContext<Ctx>({ lang: 'en', setLang: () => {}, t: (k) => k, chosen: true });
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(() => ((localStorage.getItem(LS_KEY) as Lang) || 'en'));
|
||||
const [chosen, setChosen] = useState<boolean>(() => !!localStorage.getItem(LS_KEY));
|
||||
const setLang = (l: Lang) => {
|
||||
setLangState(l);
|
||||
setChosen(true);
|
||||
localStorage.setItem(LS_KEY, l);
|
||||
writeUiPref(LS_KEY, l);
|
||||
};
|
||||
const t = useCallback((key: string, vars?: Record<string, string | number>) => translate(lang, key, vars), [lang]);
|
||||
return (
|
||||
<I18nContext.Provider value={{ lang, setLang, t, chosen }}>
|
||||
{children}
|
||||
{!chosen && <LanguageGate onPick={setLang} />}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useI18n = () => useContext(I18nContext);
|
||||
|
||||
// ── Flags (inline SVG — Windows doesn't render flag emoji) ──────────────────
|
||||
|
||||
export function FlagFR({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 3 2" className={className} aria-hidden>
|
||||
<rect width="1" height="2" x="0" fill="#0055A4" />
|
||||
<rect width="1" height="2" x="1" fill="#fff" />
|
||||
<rect width="1" height="2" x="2" fill="#EF4135" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlagGB({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 60 30" className={className} aria-hidden>
|
||||
<clipPath id="ujs"><path d="M0,0 v30 h60 v-30 z" /></clipPath>
|
||||
<clipPath id="ujt"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z" /></clipPath>
|
||||
<g clipPath="url(#ujs)">
|
||||
<path d="M0,0 v30 h60 v-30 z" fill="#012169" />
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#ujt)" stroke="#C8102E" strokeWidth="4" />
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10" />
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// LanguageGate — first-run modal to pick the interface language.
|
||||
function LanguageGate({ onPick }: { onPick: (l: Lang) => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="rounded-2xl border border-border bg-card shadow-2xl p-8 w-[420px] max-w-[90vw] text-center">
|
||||
<div className="text-lg font-bold mb-1">Choose your language</div>
|
||||
<div className="text-sm text-muted-foreground mb-6">Choisissez votre langue</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button type="button" onClick={() => onPick('en')}
|
||||
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
|
||||
<FlagGB className="w-16 rounded shadow-sm" />
|
||||
<span className="font-bold">English</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => onPick('fr')}
|
||||
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
|
||||
<FlagFR className="w-16 rounded shadow-sm border border-border/40" />
|
||||
<span className="font-bold">Français</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// sMeterRST turns an S-meter reading into the RST report you SEND to the other
|
||||
// station (RST tx / rst_sent) — because your S-meter is how strong THEY are.
|
||||
//
|
||||
// s : S-unit 0-9 (used when below S9)
|
||||
// overDb : dB over S9 (0 when at/below S9)
|
||||
// mode : operating mode — CW/digital get a 3-digit RST, phone a 2-digit RS
|
||||
//
|
||||
// For S9+ the dB is rounded UP to the nearest 5 (a 9+3 reading reports 59+5,
|
||||
// 9+12 reports 59+15, …), matching how operators give strong-signal reports.
|
||||
export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
||||
const cw = /CW|RTTY|PSK|FT[0-9]|JT|JS8|MFSK|OLIVIA|DATA|DIG/i.test(mode || '');
|
||||
const strength = Math.max(1, Math.min(9, Math.round(s)));
|
||||
if (cw) return overDb > 0 ? '599' : `5${strength}9`;
|
||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||
return `5${strength}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
|
||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
||||
}
|
||||
|
||||
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
||||
function resolve(choice: ThemeChoice): string {
|
||||
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
||||
return choice;
|
||||
}
|
||||
|
||||
function readStored(): ThemeChoice {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
||||
if (v && ALL.includes(v)) return v;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// Stamp the resolved theme onto <html data-theme>.
|
||||
export function applyThemeToDom(choice: ThemeChoice): void {
|
||||
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
||||
}
|
||||
|
||||
// Call before the first render (main.tsx) so there is no flash of the default
|
||||
// palette while React boots.
|
||||
export function initTheme(): void { applyThemeToDom(readStored()); }
|
||||
|
||||
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
||||
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
||||
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||
|
||||
const setTheme = useCallback((t: ThemeChoice) => {
|
||||
setThemeState(t);
|
||||
applyThemeToDom(t);
|
||||
writeUiPref(LS_KEY, t);
|
||||
}, []);
|
||||
|
||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||
useEffect(() => {
|
||||
if (theme !== 'auto') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => applyThemeToDom('auto');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
// Keep the DOM attribute in sync with state.
|
||||
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
||||
|
||||
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
const PORTABLE_KEYS = [
|
||||
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||
'hamlog.qsoLimit', // QSO list page size
|
||||
'bandmap.side', // band map docked left / right
|
||||
'bandmap.show', // band map open / closed
|
||||
|
||||
+10
-1
@@ -3,6 +3,8 @@ import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -12,9 +14,16 @@ const root = createRoot(container!)
|
||||
// app's synchronous reads see the values copied along with the data/ folder.
|
||||
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||
syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
+607
-35
@@ -3,43 +3,616 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ===== Warm taupe & burnt-orange palette =====
|
||||
A step darker than pure cream: the body is a soft taupe so white-ish
|
||||
cards lift off the surface, giving real depth without going dark.
|
||||
Borders are more defined; muted surfaces sit between bg and card so
|
||||
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
||||
*/
|
||||
@theme {
|
||||
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--color-foreground: #1c1917; /* stone-900 */
|
||||
/* ============================================================================
|
||||
THEME SYSTEM
|
||||
----------------------------------------------------------------------------
|
||||
Every color is a semantic CSS variable declared per theme below. The
|
||||
`@theme inline` block points Tailwind's color utilities (bg-*, text-*,
|
||||
border-*, ring-*, …) directly at those variables, so switching the
|
||||
`data-theme` attribute on <html> reskins the whole UI at runtime with no
|
||||
rebuild. Four themes ship: two light-neutral surfaces and status families
|
||||
for OK / warning / caution / danger / info that adapt to each surface.
|
||||
|
||||
--color-card: #faf6ea; /* lifted off-white cream */
|
||||
--color-card-foreground: #1c1917;
|
||||
Semantic status vocabulary (use these, NOT raw palette colors):
|
||||
success — worked / confirmed / data / OK (was emerald)
|
||||
warning — new-band / attention (was amber)
|
||||
caution — new-mode / new-slot / lighter alert (was yellow)
|
||||
danger — new / split / negative (was rose)
|
||||
info — informational (was sky/blue)
|
||||
destructive — hard errors / live REC / delete (was red)
|
||||
Each family has: base (dots/bars/solid), -foreground (text on base),
|
||||
-muted (soft pill fill), -muted-foreground (text on pill), -border.
|
||||
========================================================================= */
|
||||
|
||||
--color-popover: #faf6ea;
|
||||
--color-popover-foreground: #1c1917;
|
||||
/* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
|
||||
:root,
|
||||
[data-theme="light-warm"] {
|
||||
--background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||
--foreground: #1c1917; /* stone-900 */
|
||||
--card: #faf6ea; /* lifted off-white cream */
|
||||
--card-foreground: #1c1917;
|
||||
--popover: #faf6ea;
|
||||
--popover-foreground: #1c1917;
|
||||
--primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--primary-foreground: #fff7ed;
|
||||
--secondary: #ddd2b8;
|
||||
--secondary-foreground: #1c1917;
|
||||
--muted: #ddd2b8; /* toolbars / table headers */
|
||||
--muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--accent: #f8d6a9; /* warm amber-cream */
|
||||
--accent-foreground: #7c2d12; /* orange-900 */
|
||||
--destructive: #a51c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #c8b994; /* deeper warm beige border */
|
||||
--input: #c8b994;
|
||||
--ring: #d97706; /* amber-600 focus ring */
|
||||
|
||||
--color-primary: #b8410c; /* burnt orange, slightly deeper */
|
||||
--color-primary-foreground: #fff7ed;
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--color-secondary: #ddd2b8; /* a touch under the bg */
|
||||
--color-secondary-foreground: #1c1917;
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--color-muted: #ddd2b8; /* toolbars / table headers */
|
||||
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--color-accent: #f8d6a9; /* warm amber-cream */
|
||||
--color-accent-foreground: #7c2d12; /* orange-900 */
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--color-destructive: #a51c1c;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
--color-border: #c8b994; /* deeper warm beige border */
|
||||
--color-input: #c8b994;
|
||||
--color-ring: #d97706; /* amber-600 focus ring */
|
||||
/* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
|
||||
--mx-call-conf: #15803d; /* call confirmed */
|
||||
--mx-call-work: #6ee7b7; /* call worked */
|
||||
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||
--mx-none: #e7e5e4; /* never worked */
|
||||
|
||||
--color-ok: #15803d;
|
||||
--color-warn: #b45309;
|
||||
--scrollbar-thumb: #b8a880;
|
||||
--scrollbar-thumb-hover: #968455;
|
||||
--card-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||
[data-theme="light-cool"] {
|
||||
--background: #f4f6f8; /* cool blue-white */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0f172a;
|
||||
--primary: #2563eb; /* blue-600 */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e2e8f0; /* slate-200 */
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #e6ebf1; /* toolbars / table headers */
|
||||
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||
--accent: #dbeafe; /* blue-100 */
|
||||
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #d3dae2; /* cool slate border */
|
||||
--input: #d3dae2;
|
||||
--ring: #3b82f6; /* blue-500 focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #e2e8f0;
|
||||
|
||||
--scrollbar-thumb: #b6c2d1;
|
||||
--scrollbar-thumb-hover: #8fa1b5;
|
||||
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||
[data-theme="light-sage"] {
|
||||
--background: #eef1ec; /* pale sage */
|
||||
--foreground: #1a2420; /* deep pine-ink */
|
||||
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||
--card-foreground: #1a2420;
|
||||
--popover: #f8faf6;
|
||||
--popover-foreground: #1a2420;
|
||||
--primary: #2f855a; /* deep sage green */
|
||||
--primary-foreground: #f0fdf4;
|
||||
--secondary: #dde5db;
|
||||
--secondary-foreground: #1a2420;
|
||||
--muted: #dde5db; /* toolbars / table headers */
|
||||
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||
--accent: #d7e8d5; /* soft green */
|
||||
--accent-foreground: #1e4634; /* forest-900 */
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #cdd8ce; /* soft sage border */
|
||||
--input: #cdd8ce;
|
||||
--ring: #38a169; /* green focus ring */
|
||||
|
||||
--success: #16a34a;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #166534;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #ca8a04;
|
||||
--caution-foreground: #1c1917;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #e11d48;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #ffe4e6;
|
||||
--danger-muted-foreground: #9f1239;
|
||||
--danger-border: #fda4af;
|
||||
|
||||
--info: #0284c7;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #6ee7b7;
|
||||
--mx-dx-conf: #3730a3;
|
||||
--mx-dx-work: #a5b4fc;
|
||||
--mx-none: #dde5db;
|
||||
|
||||
--scrollbar-thumb: #b3c2b3;
|
||||
--scrollbar-thumb-hover: #91a591;
|
||||
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||
[data-theme="dim-slate"] {
|
||||
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||
--foreground: #eceef2;
|
||||
--card: #3d4552; /* lifted panel */
|
||||
--card-foreground: #eceef2;
|
||||
--popover: #3d4552;
|
||||
--popover-foreground: #eceef2;
|
||||
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||
--primary-foreground: #241206;
|
||||
--secondary: #454e5c;
|
||||
--secondary-foreground: #eceef2;
|
||||
--muted: #414a58; /* toolbars / table headers */
|
||||
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||
--accent: #495468; /* hover / selection tint */
|
||||
--accent-foreground: #dce3ef;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #1a0808;
|
||||
--destructive-muted: #3f1f1f;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #4e5867;
|
||||
--input: #4e5867;
|
||||
--ring: #fdba74; /* orange-300 focus ring */
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #1c3d34;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #2a5f4c;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #41371a;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5e4d22;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #403b1a;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #5b5222;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #421f28;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #63303c;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #16384a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #1d5670;
|
||||
|
||||
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #4a5262;
|
||||
|
||||
--scrollbar-thumb: #565f70;
|
||||
--scrollbar-thumb-hover: #6b7588;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||
[data-theme="dark-warm"] {
|
||||
--background: #221d18;
|
||||
--foreground: #ede6d8;
|
||||
--card: #2e2820;
|
||||
--card-foreground: #ede6d8;
|
||||
--popover: #2e2820;
|
||||
--popover-foreground: #ede6d8;
|
||||
--primary: #e07a2e; /* brighter burnt orange for dark */
|
||||
--primary-foreground: #1a1512;
|
||||
--secondary: #3a3229;
|
||||
--secondary-foreground: #ede6d8;
|
||||
--muted: #352e26;
|
||||
--muted-foreground: #bcae9a;
|
||||
--accent: #4a3a28;
|
||||
--accent-foreground: #f8d6a9;
|
||||
--destructive: #e5484d;
|
||||
--destructive-foreground: #1a1512;
|
||||
--destructive-muted: #3a1414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #473e33;
|
||||
--input: #473e33;
|
||||
--ring: #e8853a;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #0e2a20;
|
||||
--success-muted: #123024;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #1e5540;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #2a1f08;
|
||||
--warning-muted: #3a2e10;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #5c4a18;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #2a2607;
|
||||
--caution-muted: #38330f;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #574f18;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #2a0d13;
|
||||
--danger-muted: #3a1720;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #5c2530;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #08222e;
|
||||
--info-muted: #0c2a3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #164a5c;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked (clearly visible on dark),
|
||||
warm-gray not-worked lifted off the card. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #453d33;
|
||||
|
||||
--scrollbar-thumb: #5c5044;
|
||||
--scrollbar-thumb-hover: #7a6a58;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 240, 220, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 3: Graphite dark — cool slate, technical look --------------- */
|
||||
[data-theme="dark-graphite"] {
|
||||
--background: #16181d;
|
||||
--foreground: #e6e8ec;
|
||||
--card: #1f232b;
|
||||
--card-foreground: #e6e8ec;
|
||||
--popover: #1f232b;
|
||||
--popover-foreground: #e6e8ec;
|
||||
--primary: #f97316; /* orange-500 pops on slate */
|
||||
--primary-foreground: #0a0c10;
|
||||
--secondary: #272c35;
|
||||
--secondary-foreground: #e6e8ec;
|
||||
--muted: #232830;
|
||||
--muted-foreground: #9aa2b1;
|
||||
--accent: #2a3240;
|
||||
--accent-foreground: #cbd5e5;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #0a0c10;
|
||||
--destructive-muted: #331414;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #2e343f;
|
||||
--input: #2e343f;
|
||||
--ring: #fb8c3c;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #06231a;
|
||||
--success-muted: #0f2e26;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #17513f;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #241a06;
|
||||
--warning-muted: #33290f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3f16;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #242006;
|
||||
--caution-muted: #322e0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4c4416;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #260a11;
|
||||
--danger-muted: #34141d;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #532430;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #061f2b;
|
||||
--info-muted: #0b2938;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #124659;
|
||||
|
||||
/* Matrix: bright confirmed, medium-solid worked, cool-gray not-worked. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #6366f1;
|
||||
--mx-dx-work: #3f3f8a;
|
||||
--mx-none: #333a45;
|
||||
|
||||
--scrollbar-thumb: #3a4150;
|
||||
--scrollbar-thumb-hover: #4e5768;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(230, 232, 236, 0.04);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 4: High contrast — pure black / white, max legibility ------- */
|
||||
[data-theme="high-contrast"] {
|
||||
--background: #000000;
|
||||
--foreground: #ffffff;
|
||||
--card: #0d0d0d;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #0d0d0d;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ff7a1a;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #141414;
|
||||
--muted-foreground: #d4d4d4;
|
||||
--accent: #262626;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff4d4d;
|
||||
--destructive-foreground: #000000;
|
||||
--destructive-muted: #2a0808;
|
||||
--destructive-muted-foreground: #ff9999;
|
||||
--border: #3f3f3f;
|
||||
--input: #3f3f3f;
|
||||
--ring: #ffab5e;
|
||||
|
||||
--success: #22e37a;
|
||||
--success-foreground: #002313;
|
||||
--success-muted: #06231a;
|
||||
--success-muted-foreground: #6affb0;
|
||||
--success-border: #0f6b4a;
|
||||
|
||||
--warning: #ffcc33;
|
||||
--warning-foreground: #201900;
|
||||
--warning-muted: #2a2205;
|
||||
--warning-muted-foreground: #ffe08a;
|
||||
--warning-border: #6b5510;
|
||||
|
||||
--caution: #ffe14d;
|
||||
--caution-foreground: #201d00;
|
||||
--caution-muted: #2a2705;
|
||||
--caution-muted-foreground: #fff08a;
|
||||
--caution-border: #6b6210;
|
||||
|
||||
--danger: #ff6b7d;
|
||||
--danger-foreground: #23000a;
|
||||
--danger-muted: #2a0a12;
|
||||
--danger-muted-foreground: #ffb3bd;
|
||||
--danger-border: #6b1f2c;
|
||||
|
||||
--info: #4dc4ff;
|
||||
--info-foreground: #001a26;
|
||||
--info-muted: #052330;
|
||||
--info-muted-foreground: #a3e0ff;
|
||||
--info-border: #0f5a6b;
|
||||
|
||||
/* Matrix: max-contrast ramp on pure black. */
|
||||
--mx-call-conf: #2ee87f;
|
||||
--mx-call-work: #17a85a;
|
||||
--mx-dx-conf: #7d97ff;
|
||||
--mx-dx-work: #3646b0;
|
||||
--mx-none: #3a3a3a;
|
||||
|
||||
--scrollbar-thumb: #4a4a4a;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
--card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||
automatic inversion.
|
||||
|
||||
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||
shows.
|
||||
|
||||
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||
recedes toward the surface instead of glowing. */
|
||||
:root,
|
||||
[data-theme="light-warm"],
|
||||
[data-theme="light-cool"],
|
||||
[data-theme="light-sage"] {
|
||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||
--chart-2: #1baf7a; /* aqua */
|
||||
--chart-3: #eda100; /* yellow */
|
||||
--chart-4: #008300; /* green */
|
||||
--chart-5: #4a3aa7; /* violet */
|
||||
--chart-6: #e34948; /* red */
|
||||
--chart-7: #e87ba4; /* magenta */
|
||||
--chart-8: #eb6834; /* orange */
|
||||
|
||||
--chart-seq-1: #cde2fb;
|
||||
--chart-seq-2: #9ec5f4;
|
||||
--chart-seq-3: #6da7ec;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #256abf;
|
||||
--chart-seq-6: #104281;
|
||||
}
|
||||
|
||||
[data-theme="dim-slate"],
|
||||
[data-theme="dark-warm"],
|
||||
[data-theme="dark-graphite"],
|
||||
[data-theme="high-contrast"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
--chart-4: #008300;
|
||||
--chart-5: #9085e9;
|
||||
--chart-6: #e66767;
|
||||
--chart-7: #d55181;
|
||||
--chart-8: #d95926;
|
||||
|
||||
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||
--chart-seq-1: #0d366b;
|
||||
--chart-seq-2: #184f95;
|
||||
--chart-seq-3: #256abf;
|
||||
--chart-seq-4: #3987e5;
|
||||
--chart-seq-5: #6da7ec;
|
||||
--chart-seq-6: #9ec5f4;
|
||||
}
|
||||
|
||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||
the generated utilities reference var(--…) directly, so overriding a var in
|
||||
a [data-theme] block re-skins every utility at runtime. */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive-muted: var(--destructive-muted);
|
||||
--color-destructive-muted-foreground: var(--destructive-muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-success-muted: var(--success-muted);
|
||||
--color-success-muted-foreground: var(--success-muted-foreground);
|
||||
--color-success-border: var(--success-border);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-warning-muted: var(--warning-muted);
|
||||
--color-warning-muted-foreground: var(--warning-muted-foreground);
|
||||
--color-warning-border: var(--warning-border);
|
||||
|
||||
--color-caution: var(--caution);
|
||||
--color-caution-foreground: var(--caution-foreground);
|
||||
--color-caution-muted: var(--caution-muted);
|
||||
--color-caution-muted-foreground: var(--caution-muted-foreground);
|
||||
--color-caution-border: var(--caution-border);
|
||||
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-foreground: var(--danger-foreground);
|
||||
--color-danger-muted: var(--danger-muted);
|
||||
--color-danger-muted-foreground: var(--danger-muted-foreground);
|
||||
--color-danger-border: var(--danger-border);
|
||||
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-info-muted: var(--info-muted);
|
||||
--color-info-muted-foreground: var(--info-muted-foreground);
|
||||
--color-info-border: var(--info-border);
|
||||
|
||||
--color-mx-call-conf: var(--mx-call-conf);
|
||||
--color-mx-call-work: var(--mx-call-work);
|
||||
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||
--color-mx-dx-work: var(--mx-dx-work);
|
||||
--color-mx-none: var(--mx-none);
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
@@ -65,18 +638,17 @@
|
||||
}
|
||||
|
||||
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
||||
top of the taupe background — paper-on-paper feel. */
|
||||
top of the background — paper-on-paper feel, tuned per theme. */
|
||||
.bg-card {
|
||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
||||
0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* Warm scrollbar tuned to the deeper bg */
|
||||
/* Scrollbar tuned to each theme's surface */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #b8a880;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--color-background);
|
||||
border: 2px solid var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #968455; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
|
||||
@@ -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.12';
|
||||
export const APP_VERSION = '0.19.7';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+266
-1
@@ -5,15 +5,22 @@ import {qso} from '../models';
|
||||
import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {alerts} from '../models';
|
||||
import {audio} from '../models';
|
||||
import {contest} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
|
||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||
|
||||
@@ -23,10 +30,30 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
|
||||
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||
|
||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
|
||||
export function AudioStartTX():Promise<void>;
|
||||
|
||||
export function AudioStopMonitor():Promise<void>;
|
||||
|
||||
export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
@@ -35,6 +62,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||
|
||||
export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BrowseExecutable():Promise<string>;
|
||||
|
||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||
@@ -59,6 +88,10 @@ export function ConnectAllClusters():Promise<void>;
|
||||
|
||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function ContestDupe(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string):Promise<boolean>;
|
||||
|
||||
export function ContestStats(arg1:string,arg2:string,arg3:string,arg4:string):Promise<main.ContestStatsResult>;
|
||||
|
||||
export function CountQSO():Promise<number>;
|
||||
|
||||
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
||||
@@ -81,6 +114,8 @@ export function DXCCForCountry(arg1:string):Promise<number>;
|
||||
|
||||
export function DXCCName(arg1:number):Promise<string>;
|
||||
|
||||
export function DeleteAlertRule(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteAllQSO():Promise<number>;
|
||||
|
||||
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -105,25 +140,41 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
|
||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function FlexATUBypass():Promise<void>;
|
||||
|
||||
@@ -131,6 +182,8 @@ export function FlexATUStart():Promise<void>;
|
||||
|
||||
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
@@ -147,6 +200,8 @@ export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||
@@ -159,12 +214,18 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMute(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||
@@ -179,8 +240,22 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||
@@ -189,10 +264,24 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
|
||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||
|
||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||
|
||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
@@ -207,6 +296,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||
|
||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||
|
||||
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||
|
||||
export function GetAwards():Promise<Array<award.Result>>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
@@ -217,6 +308,8 @@ export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
@@ -225,6 +318,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||
@@ -243,24 +338,40 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||
|
||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||
|
||||
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||
|
||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||
|
||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||
|
||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||
|
||||
export function GetPOTAToken():Promise<string>;
|
||||
|
||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
@@ -271,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationSettings():Promise<main.StationSettings>;
|
||||
@@ -289,16 +402,106 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
|
||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||
|
||||
export function IcomSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAntenna(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetBreakIn(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetComp(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetCompLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetManualNotch(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetMonitor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNotchPos(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTInner(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPBTOuter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPTT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScopeEdges(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetVOXGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
|
||||
export function ListAlertRules():Promise<Array<alerts.Rule>>;
|
||||
|
||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||
@@ -307,6 +510,8 @@ export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
@@ -323,20 +528,60 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
|
||||
export function NetActiveList():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function NetClose():Promise<void>;
|
||||
|
||||
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||
|
||||
export function NetDeactivate(arg1:number):Promise<number>;
|
||||
|
||||
export function NetDelete(arg1:string):Promise<void>;
|
||||
|
||||
export function NetDiscardActive(arg1:number):Promise<void>;
|
||||
|
||||
export function NetList():Promise<Array<netctl.Net>>;
|
||||
|
||||
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||
|
||||
export function NetOpen(arg1:string):Promise<void>;
|
||||
|
||||
export function NetOpenID():Promise<string>;
|
||||
|
||||
export function NetRename(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
|
||||
|
||||
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
||||
|
||||
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenAwardsFolder():Promise<void>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -385,12 +630,16 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
@@ -407,8 +656,12 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RestartApp():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -419,6 +672,10 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||
@@ -431,12 +688,16 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||
|
||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
|
||||
export function SaveCabrilloFile():Promise<string>;
|
||||
|
||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||
|
||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||
|
||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -447,6 +708,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
|
||||
|
||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||
|
||||
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||
|
||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
@@ -475,6 +738,8 @@ export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
@@ -18,14 +18,54 @@ export function AddQSO(arg1) {
|
||||
return window['go']['main']['App']['AddQSO'](arg1);
|
||||
}
|
||||
|
||||
export function AntGeniusActivate(arg1, arg2) {
|
||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AntGeniusDeselect(arg1) {
|
||||
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||
}
|
||||
|
||||
export function ApplyAwardImport(arg1, arg2) {
|
||||
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ApplyAwardPreset(arg1, arg2) {
|
||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ApplyAwardUpdate(arg1) {
|
||||
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AudioMonitorActive() {
|
||||
return window['go']['main']['App']['AudioMonitorActive']();
|
||||
}
|
||||
|
||||
export function AudioStartMonitor() {
|
||||
return window['go']['main']['App']['AudioStartMonitor']();
|
||||
}
|
||||
|
||||
export function AudioStartTX() {
|
||||
return window['go']['main']['App']['AudioStartTX']();
|
||||
}
|
||||
|
||||
export function AudioStopMonitor() {
|
||||
return window['go']['main']['App']['AudioStopMonitor']();
|
||||
}
|
||||
|
||||
export function AudioStopTX() {
|
||||
return window['go']['main']['App']['AudioStopTX']();
|
||||
}
|
||||
|
||||
export function AudioTXActive() {
|
||||
return window['go']['main']['App']['AudioTXActive']();
|
||||
}
|
||||
|
||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -42,6 +82,10 @@ export function AwardRefsForQSOs(arg1) {
|
||||
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||
}
|
||||
|
||||
export function AwardsFolder() {
|
||||
return window['go']['main']['App']['AwardsFolder']();
|
||||
}
|
||||
|
||||
export function BrowseExecutable() {
|
||||
return window['go']['main']['App']['BrowseExecutable']();
|
||||
}
|
||||
@@ -90,6 +134,14 @@ export function ConnectClusterServer(arg1) {
|
||||
return window['go']['main']['App']['ConnectClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function ContestDupe(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['ContestDupe'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function ContestStats(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ContestStats'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CountQSO() {
|
||||
return window['go']['main']['App']['CountQSO']();
|
||||
}
|
||||
@@ -134,6 +186,10 @@ export function DXCCName(arg1) {
|
||||
return window['go']['main']['App']['DXCCName'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteAlertRule(arg1) {
|
||||
return window['go']['main']['App']['DeleteAlertRule'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteAllQSO() {
|
||||
return window['go']['main']['App']['DeleteAllQSO']();
|
||||
}
|
||||
@@ -182,6 +238,10 @@ export function DiscoverFlexRadios() {
|
||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||
}
|
||||
|
||||
export function DismissAwardUpdate(arg1) {
|
||||
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
@@ -194,10 +254,18 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadLoTWUsers() {
|
||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||
}
|
||||
|
||||
export function DuplicateProfile(arg1, arg2) {
|
||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExplainAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportADIF(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||
}
|
||||
@@ -210,14 +278,34 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExportAward(arg1) {
|
||||
return window['go']['main']['App']['ExportAward'](arg1);
|
||||
}
|
||||
|
||||
export function ExportAwards() {
|
||||
return window['go']['main']['App']['ExportAwards']();
|
||||
}
|
||||
|
||||
export function ExportCabrillo(arg1) {
|
||||
return window['go']['main']['App']['ExportCabrillo'](arg1);
|
||||
}
|
||||
|
||||
export function ExportCabrilloFiltered(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportCabrilloSelected(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FilterFields() {
|
||||
return window['go']['main']['App']['FilterFields']();
|
||||
}
|
||||
|
||||
export function FindDuplicates(arg1) {
|
||||
return window['go']['main']['App']['FindDuplicates'](arg1);
|
||||
}
|
||||
|
||||
export function FindQSOsForUpload(arg1, arg2) {
|
||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||
}
|
||||
@@ -234,6 +322,10 @@ export function FlexAmpOperate(arg1) {
|
||||
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
||||
}
|
||||
|
||||
export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexMox(arg1) {
|
||||
return window['go']['main']['App']['FlexMox'](arg1);
|
||||
}
|
||||
@@ -266,6 +358,10 @@ export function FlexSetATUMemories(arg1) {
|
||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetActiveSlice(arg1) {
|
||||
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAudioLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||
}
|
||||
@@ -290,10 +386,18 @@ export function FlexSetCWSpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMicProfile(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMon(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||
}
|
||||
@@ -302,6 +406,10 @@ export function FlexSetMonLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMute(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMute'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetNB(arg1) {
|
||||
return window['go']['main']['App']['FlexSetNB'](arg1);
|
||||
}
|
||||
@@ -330,10 +438,38 @@ export function FlexSetProcessorLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetRIT(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRIT'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetRITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetRXAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSplit(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTXAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTXFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetTXSlice(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTunePower(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||
}
|
||||
@@ -350,6 +486,22 @@ export function FlexSetVoxLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetWNB(arg1) {
|
||||
return window['go']['main']['App']['FlexSetWNB'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetWNBLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetXIT(arg1) {
|
||||
return window['go']['main']['App']['FlexSetXIT'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetXITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
@@ -358,6 +510,18 @@ export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
|
||||
export function GetAlertEmailTo() {
|
||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||
}
|
||||
|
||||
export function GetAntGeniusSettings() {
|
||||
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||
}
|
||||
|
||||
export function GetAntGeniusStatus() {
|
||||
return window['go']['main']['App']['GetAntGeniusStatus']();
|
||||
}
|
||||
|
||||
export function GetAudioSettings() {
|
||||
return window['go']['main']['App']['GetAudioSettings']();
|
||||
}
|
||||
@@ -386,6 +550,10 @@ export function GetAwardStats(arg1) {
|
||||
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||
}
|
||||
|
||||
export function GetAwardUpdates() {
|
||||
return window['go']['main']['App']['GetAwardUpdates']();
|
||||
}
|
||||
|
||||
export function GetAwards() {
|
||||
return window['go']['main']['App']['GetAwards']();
|
||||
}
|
||||
@@ -406,6 +574,10 @@ export function GetCWDecoderPitch() {
|
||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||
}
|
||||
|
||||
export function GetCatalogCodes() {
|
||||
return window['go']['main']['App']['GetCatalogCodes']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||
}
|
||||
@@ -422,6 +594,10 @@ export function GetClusterStatus() {
|
||||
return window['go']['main']['App']['GetClusterStatus']();
|
||||
}
|
||||
|
||||
export function GetContestRuns() {
|
||||
return window['go']['main']['App']['GetContestRuns']();
|
||||
}
|
||||
|
||||
export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
@@ -458,10 +634,18 @@ export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
|
||||
export function GetFlexBandAntennas() {
|
||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
|
||||
export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
|
||||
export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
@@ -470,10 +654,18 @@ export function GetLiveStatusEnabled() {
|
||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||
}
|
||||
|
||||
export function GetLoTWUsersStatus() {
|
||||
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||
}
|
||||
|
||||
export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GetLogbookRevision() {
|
||||
return window['go']['main']['App']['GetLogbookRevision']();
|
||||
}
|
||||
@@ -486,14 +678,30 @@ export function GetMySQLSettings() {
|
||||
return window['go']['main']['App']['GetMySQLSettings']();
|
||||
}
|
||||
|
||||
export function GetOfflineStatus() {
|
||||
return window['go']['main']['App']['GetOfflineStatus']();
|
||||
}
|
||||
|
||||
export function GetOnlineOperators() {
|
||||
return window['go']['main']['App']['GetOnlineOperators']();
|
||||
}
|
||||
|
||||
export function GetPGXLSettings() {
|
||||
return window['go']['main']['App']['GetPGXLSettings']();
|
||||
}
|
||||
|
||||
export function GetPGXLStatus() {
|
||||
return window['go']['main']['App']['GetPGXLStatus']();
|
||||
}
|
||||
|
||||
export function GetPOTAToken() {
|
||||
return window['go']['main']['App']['GetPOTAToken']();
|
||||
}
|
||||
|
||||
export function GetPendingQSOs() {
|
||||
return window['go']['main']['App']['GetPendingQSOs']();
|
||||
}
|
||||
|
||||
export function GetQSLDefaults() {
|
||||
return window['go']['main']['App']['GetQSLDefaults']();
|
||||
}
|
||||
@@ -514,6 +722,10 @@ export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
|
||||
export function GetSolarData() {
|
||||
return window['go']['main']['App']['GetSolarData']();
|
||||
}
|
||||
|
||||
export function GetStartupStatus() {
|
||||
return window['go']['main']['App']['GetStartupStatus']();
|
||||
}
|
||||
@@ -550,6 +762,178 @@ export function HasBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function IcomRefresh() {
|
||||
return window['go']['main']['App']['IcomRefresh']();
|
||||
}
|
||||
|
||||
export function IcomScopeData() {
|
||||
return window['go']['main']['App']['IcomScopeData']();
|
||||
}
|
||||
|
||||
export function IcomSendCW(arg1) {
|
||||
return window['go']['main']['App']['IcomSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAFGain(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAGC(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAGC'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetANF(arg1) {
|
||||
return window['go']['main']['App']['IcomSetANF'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAPF(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAPF'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAntenna(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAntiVOX(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAntiVOX'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetAtt(arg1) {
|
||||
return window['go']['main']['App']['IcomSetAtt'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetBreakIn(arg1) {
|
||||
return window['go']['main']['App']['IcomSetBreakIn'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetComp(arg1) {
|
||||
return window['go']['main']['App']['IcomSetComp'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetCompLevel(arg1) {
|
||||
return window['go']['main']['App']['IcomSetCompLevel'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetFilter(arg1) {
|
||||
return window['go']['main']['App']['IcomSetFilter'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['IcomSetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetManualNotch(arg1) {
|
||||
return window['go']['main']['App']['IcomSetManualNotch'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetMicGain(arg1) {
|
||||
return window['go']['main']['App']['IcomSetMicGain'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetMonLevel(arg1) {
|
||||
return window['go']['main']['App']['IcomSetMonLevel'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetMonitor(arg1) {
|
||||
return window['go']['main']['App']['IcomSetMonitor'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetNB(arg1) {
|
||||
return window['go']['main']['App']['IcomSetNB'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetNBLevel(arg1) {
|
||||
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetNR(arg1) {
|
||||
return window['go']['main']['App']['IcomSetNR'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetNRLevel(arg1) {
|
||||
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetNotchPos(arg1) {
|
||||
return window['go']['main']['App']['IcomSetNotchPos'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetPBTInner(arg1) {
|
||||
return window['go']['main']['App']['IcomSetPBTInner'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetPBTOuter(arg1) {
|
||||
return window['go']['main']['App']['IcomSetPBTOuter'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetPTT(arg1) {
|
||||
return window['go']['main']['App']['IcomSetPTT'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetPower(arg1) {
|
||||
return window['go']['main']['App']['IcomSetPower'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetPreamp(arg1) {
|
||||
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRFGain(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRFGain'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRFPower(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRFPower'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRIT(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRIT'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRITOn(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRITOn'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetScope(arg1) {
|
||||
return window['go']['main']['App']['IcomSetScope'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetScopeEdges(arg1, arg2) {
|
||||
return window['go']['main']['App']['IcomSetScopeEdges'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function IcomSetScopeMode(arg1) {
|
||||
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetSplit(arg1) {
|
||||
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetSquelch(arg1) {
|
||||
return window['go']['main']['App']['IcomSetSquelch'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetVOX(arg1) {
|
||||
return window['go']['main']['App']['IcomSetVOX'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetVOXGain(arg1) {
|
||||
return window['go']['main']['App']['IcomSetVOXGain'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetXITOn(arg1) {
|
||||
return window['go']['main']['App']['IcomSetXITOn'](arg1);
|
||||
}
|
||||
|
||||
export function IcomStopCW() {
|
||||
return window['go']['main']['App']['IcomStopCW']();
|
||||
}
|
||||
|
||||
export function IcomTune() {
|
||||
return window['go']['main']['App']['IcomTune']();
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -562,6 +946,10 @@ export function ImportAwards() {
|
||||
return window['go']['main']['App']['ImportAwards']();
|
||||
}
|
||||
|
||||
export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
|
||||
export function LaunchAutostartProgram(arg1) {
|
||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||
}
|
||||
@@ -570,6 +958,10 @@ export function LaunchAutostartPrograms() {
|
||||
return window['go']['main']['App']['LaunchAutostartPrograms']();
|
||||
}
|
||||
|
||||
export function ListAlertRules() {
|
||||
return window['go']['main']['App']['ListAlertRules']();
|
||||
}
|
||||
|
||||
export function ListAudioInputDevices() {
|
||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||
}
|
||||
@@ -586,6 +978,10 @@ export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
|
||||
export function ListContests() {
|
||||
return window['go']['main']['App']['ListContests']();
|
||||
}
|
||||
|
||||
export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
@@ -618,6 +1014,10 @@ export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function LoTWUserInfo(arg1) {
|
||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||
}
|
||||
|
||||
export function LogUDPLoggedADIF(arg1) {
|
||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||
}
|
||||
@@ -630,10 +1030,82 @@ export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function NetActivate(arg1) {
|
||||
return window['go']['main']['App']['NetActivate'](arg1);
|
||||
}
|
||||
|
||||
export function NetActiveList() {
|
||||
return window['go']['main']['App']['NetActiveList']();
|
||||
}
|
||||
|
||||
export function NetClose() {
|
||||
return window['go']['main']['App']['NetClose']();
|
||||
}
|
||||
|
||||
export function NetCreate(arg1) {
|
||||
return window['go']['main']['App']['NetCreate'](arg1);
|
||||
}
|
||||
|
||||
export function NetDeactivate(arg1) {
|
||||
return window['go']['main']['App']['NetDeactivate'](arg1);
|
||||
}
|
||||
|
||||
export function NetDelete(arg1) {
|
||||
return window['go']['main']['App']['NetDelete'](arg1);
|
||||
}
|
||||
|
||||
export function NetDiscardActive(arg1) {
|
||||
return window['go']['main']['App']['NetDiscardActive'](arg1);
|
||||
}
|
||||
|
||||
export function NetList() {
|
||||
return window['go']['main']['App']['NetList']();
|
||||
}
|
||||
|
||||
export function NetLookup(arg1) {
|
||||
return window['go']['main']['App']['NetLookup'](arg1);
|
||||
}
|
||||
|
||||
export function NetOpen(arg1) {
|
||||
return window['go']['main']['App']['NetOpen'](arg1);
|
||||
}
|
||||
|
||||
export function NetOpenID() {
|
||||
return window['go']['main']['App']['NetOpenID']();
|
||||
}
|
||||
|
||||
export function NetRename(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRename'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetRoster(arg1) {
|
||||
return window['go']['main']['App']['NetRoster'](arg1);
|
||||
}
|
||||
|
||||
export function NetRosterRemove(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetRosterUpsert(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function NetUpdateActive(arg1) {
|
||||
return window['go']['main']['App']['NetUpdateActive'](arg1);
|
||||
}
|
||||
|
||||
export function OpenADIFFile() {
|
||||
return window['go']['main']['App']['OpenADIFFile']();
|
||||
}
|
||||
|
||||
export function OpenAwardsFolder() {
|
||||
return window['go']['main']['App']['OpenAwardsFolder']();
|
||||
}
|
||||
|
||||
export function OpenDatabase(arg1) {
|
||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||
}
|
||||
@@ -646,6 +1118,10 @@ export function OperatingDefaultForBand(arg1) {
|
||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||
}
|
||||
|
||||
export function PGXLSetFanMode(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
@@ -742,6 +1218,10 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
|
||||
export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
@@ -754,6 +1234,10 @@ export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
|
||||
export function RefreshSolar() {
|
||||
return window['go']['main']['App']['RefreshSolar']();
|
||||
}
|
||||
|
||||
export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
@@ -786,10 +1270,18 @@ export function ResetDatabaseToDefault() {
|
||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||
}
|
||||
|
||||
export function RestartApp() {
|
||||
return window['go']['main']['App']['RestartApp']();
|
||||
}
|
||||
|
||||
export function RestartQSORecorder() {
|
||||
return window['go']['main']['App']['RestartQSORecorder']();
|
||||
}
|
||||
|
||||
export function RetryOfflineSync() {
|
||||
return window['go']['main']['App']['RetryOfflineSync']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
@@ -810,6 +1302,14 @@ export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveAlertRule(arg1) {
|
||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAntGeniusSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAudioSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||
}
|
||||
@@ -834,6 +1334,10 @@ export function SaveCATSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveCabrilloFile() {
|
||||
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||
}
|
||||
|
||||
export function SaveClusterServer(arg1) {
|
||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||
}
|
||||
@@ -846,6 +1350,10 @@ export function SaveExternalServices(arg1) {
|
||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexBandAntennas(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -866,6 +1374,10 @@ export function SaveOperatingStation(arg1) {
|
||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||
}
|
||||
|
||||
export function SavePGXLSettings(arg1) {
|
||||
return window['go']['main']['App']['SavePGXLSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SavePOTAToken(arg1) {
|
||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||
}
|
||||
@@ -922,6 +1434,10 @@ export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetAlertEmailTo(arg1) {
|
||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||
}
|
||||
|
||||
export function SetCATFrequency(arg1) {
|
||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||
}
|
||||
|
||||
+1060
-20
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ require (
|
||||
github.com/braheezy/shine-mp3 v0.1.0
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/moutend/go-wca v0.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
@@ -22,7 +23,6 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
|
||||
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||
// so it can be written out and read back with nothing dropped. Used by the
|
||||
// offline queue: a QSO parked in the safety file must come back identical
|
||||
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||
func FullRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
writeRecord(bw, q, true)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
|
||||
// and reports which rules fire, so the app can notify the operator (sound /
|
||||
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
|
||||
// Management. Rules are persisted as JSON (global, machine-local).
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rule is one alert definition. Every filter dimension is optional: an empty
|
||||
// list/string matches ANY value, and the dimensions are ANDed together, so a
|
||||
// rule with Countries=[France] and Bands=[20m] fires only for French stations
|
||||
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// DX filters.
|
||||
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
|
||||
Countries []string `json:"countries,omitempty"` // DXCC entity names
|
||||
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
|
||||
|
||||
// Band / mode filters.
|
||||
Bands []string `json:"bands,omitempty"`
|
||||
Modes []string `json:"modes,omitempty"`
|
||||
|
||||
// Spotter (origin) filters.
|
||||
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
|
||||
SpotterContinents []string `json:"spotter_continents,omitempty"`
|
||||
SpotterCountries []string `json:"spotter_countries,omitempty"`
|
||||
|
||||
// Actions.
|
||||
Sound bool `json:"sound"`
|
||||
Visual bool `json:"visual"`
|
||||
Email bool `json:"email"`
|
||||
|
||||
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
|
||||
// once per session, -1 = always, >0 = that many minutes.
|
||||
AgainAfterMin int `json:"again_after_min"`
|
||||
|
||||
// Skip a spot whose DX call is already worked on this band+mode.
|
||||
SkipWorked bool `json:"skip_worked"`
|
||||
}
|
||||
|
||||
// Spot is the subset of a cluster spot the engine matches against.
|
||||
type Spot struct {
|
||||
DXCall string
|
||||
Band string
|
||||
Mode string // inferred (see InferMode)
|
||||
Country string // DXCC entity name
|
||||
Continent string
|
||||
Spotter string
|
||||
SpotterCountry string
|
||||
SpotterContinent string
|
||||
}
|
||||
|
||||
// Store persists the rule set as a JSON file.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
rules []Rule
|
||||
// last fired time per (ruleID|call), for the AgainAfter throttle.
|
||||
lastFired map[string]time.Time
|
||||
}
|
||||
|
||||
// Open loads the store (empty when the file is absent).
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path, lastFired: map[string]time.Time{}}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) save() error {
|
||||
b, err := json.MarshalIndent(s.rules, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// List returns a copy of all rules.
|
||||
func (s *Store) List() []Rule {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Rule, len(s.rules))
|
||||
copy(out, s.rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
|
||||
|
||||
// Save upserts a rule (creating an id when empty) and returns it.
|
||||
func (s *Store) Save(r Rule) (Rule, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(r.ID) == "" {
|
||||
r.ID = newID()
|
||||
s.rules = append(s.rules, r)
|
||||
} else {
|
||||
found := false
|
||||
for i := range s.rules {
|
||||
if s.rules[i].ID == r.ID {
|
||||
s.rules[i] = r
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.rules = append(s.rules, r)
|
||||
}
|
||||
}
|
||||
return r, s.save()
|
||||
}
|
||||
|
||||
// Delete removes a rule by id.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.rules {
|
||||
if s.rules[i].ID == id {
|
||||
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match is a rule that fired for a spot (returned by Evaluate).
|
||||
type Match struct {
|
||||
Rule Rule
|
||||
Spot Spot
|
||||
}
|
||||
|
||||
// Evaluate returns every enabled rule that matches the spot AND isn't currently
|
||||
// throttled. It records the fire time for matched rules so the AgainAfter window
|
||||
// is honoured. workedFn (may be nil) reports whether the DX call is already
|
||||
// worked on this band+mode — used by rules with SkipWorked.
|
||||
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []Match
|
||||
for _, r := range s.rules {
|
||||
if !r.Enabled || !ruleMatches(r, sp) {
|
||||
continue
|
||||
}
|
||||
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
|
||||
continue
|
||||
}
|
||||
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
|
||||
if r.AgainAfterMin >= 0 {
|
||||
last, seen := s.lastFired[key]
|
||||
if seen {
|
||||
if r.AgainAfterMin == 0 {
|
||||
continue // once per session
|
||||
}
|
||||
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
s.lastFired[key] = now
|
||||
out = append(out, Match{Rule: r, Spot: sp})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ruleMatches reports whether every specified filter dimension matches the spot.
|
||||
func ruleMatches(r Rule, sp Spot) bool {
|
||||
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
|
||||
return false
|
||||
}
|
||||
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
|
||||
return false
|
||||
}
|
||||
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
|
||||
return false
|
||||
}
|
||||
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
|
||||
return false
|
||||
}
|
||||
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
|
||||
return false
|
||||
}
|
||||
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
|
||||
return false
|
||||
}
|
||||
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsFold(list []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyWildcard(patterns []string, v string) bool {
|
||||
for _, p := range patterns {
|
||||
if matchWildcard(p, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchWildcard does a case-insensitive full-string match where '*' matches any
|
||||
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
|
||||
func matchWildcard(pattern, v string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
v = strings.TrimSpace(v)
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("(?i)^")
|
||||
for _, r := range pattern {
|
||||
switch r {
|
||||
case '*':
|
||||
b.WriteString(".*")
|
||||
case '?':
|
||||
b.WriteString(".")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(string(r)))
|
||||
}
|
||||
}
|
||||
b.WriteString("$")
|
||||
re, err := regexp.Compile(b.String())
|
||||
if err != nil {
|
||||
return strings.EqualFold(pattern, v)
|
||||
}
|
||||
return re.MatchString(v)
|
||||
}
|
||||
|
||||
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
|
||||
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
|
||||
// back to the digital watering holes and the band-plan CW/phone split.
|
||||
func InferMode(comment string, freqHz int64) string {
|
||||
c := strings.ToUpper(comment)
|
||||
switch {
|
||||
case strings.Contains(c, "FT8"):
|
||||
return "FT8"
|
||||
case strings.Contains(c, "FT4"):
|
||||
return "FT4"
|
||||
case strings.Contains(c, "RTTY"):
|
||||
return "RTTY"
|
||||
case strings.Contains(c, "PSK"):
|
||||
return "PSK"
|
||||
case strings.Contains(c, "JS8"):
|
||||
return "JS8"
|
||||
case strings.Contains(c, "CW"):
|
||||
return "CW"
|
||||
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
|
||||
return "SSB"
|
||||
}
|
||||
khz := float64(freqHz) / 1000
|
||||
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
|
||||
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
|
||||
if khz >= f-1 && khz <= f+3 {
|
||||
return "FT8"
|
||||
}
|
||||
}
|
||||
// Band-plan CW segments (bottom of each band).
|
||||
switch {
|
||||
case khz >= 1810 && khz <= 1840,
|
||||
khz >= 3500 && khz <= 3570,
|
||||
khz >= 7000 && khz <= 7040,
|
||||
khz >= 10100 && khz <= 10130,
|
||||
khz >= 14000 && khz <= 14070,
|
||||
khz >= 18068 && khz <= 18095,
|
||||
khz >= 21000 && khz <= 21070,
|
||||
khz >= 24890 && khz <= 24910,
|
||||
khz >= 28000 && khz <= 28070:
|
||||
return "CW"
|
||||
}
|
||||
return "SSB"
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
|
||||
// text API (default port 9007). On connect the device sends a banner line
|
||||
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
|
||||
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
|
||||
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
|
||||
//
|
||||
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
|
||||
// by pre-v4 firmware and is NOT what v4 speaks.)
|
||||
package antgenius
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 9007
|
||||
dialTimeout = 5 * time.Second
|
||||
writeTimeout = 3 * time.Second
|
||||
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
|
||||
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Antenna is one configured antenna (index + name as stored on the device).
|
||||
// Bands is the device's band bitmask for the antenna (which bands it covers);
|
||||
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
|
||||
// the native 4O3A app.
|
||||
type Antenna struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Bands int `json:"bands"`
|
||||
}
|
||||
|
||||
// Status is the snapshot the UI renders.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Host string `json:"host,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
|
||||
PortB int `json:"port_b"` // active antenna index on port B
|
||||
TxA bool `json:"tx_a"` // port A is transmitting
|
||||
TxB bool `json:"tx_b"` // port B is transmitting
|
||||
Antennas []Antenna `json:"antennas"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
|
||||
|
||||
mu sync.Mutex // guards conn + writes
|
||||
conn net.Conn
|
||||
|
||||
// Auth handshake state (touched only on the runLoop/readLoop goroutine).
|
||||
awaitingAuth bool
|
||||
authTries int
|
||||
ready atomic.Bool // init commands sent → keepalive may run
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
password: strings.TrimSpace(password),
|
||||
stop: make(chan struct{}),
|
||||
antennas: map[int]string{},
|
||||
antBands: map[int]int{},
|
||||
status: Status{Host: host},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
go c.runLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
c.running = false
|
||||
close(c.stop)
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *Client) setStatus(fn func(*Status)) {
|
||||
c.statusMu.Lock()
|
||||
fn(&c.status)
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
|
||||
// the port to "None"). We set both RX and TX antennas and force manual mode so
|
||||
// the choice sticks regardless of the device's auto band-following.
|
||||
func (c *Client) Activate(port, antenna int) error {
|
||||
if port != 1 && port != 2 {
|
||||
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
|
||||
}
|
||||
if antenna < 0 {
|
||||
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
|
||||
}
|
||||
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
|
||||
// to the TX antenna automatically. Forcing txant too can be rejected on the
|
||||
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
|
||||
// selection and deselection.
|
||||
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ask for the new port state so the snapshot reflects it promptly (the
|
||||
// subscription also pushes it, but this makes the change deterministic).
|
||||
_ = c.send(fmt.Sprintf("port get %d", port))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) runLoop() {
|
||||
for {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||
if err != nil {
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||
if c.sleep(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||
applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||
|
||||
// Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
|
||||
// password we send "auth code=<pw>" and only send the subscribe/get commands
|
||||
// once it's accepted (retrying on FF); without AUTH they go out immediately.
|
||||
c.awaitingAuth, c.authTries = false, 0
|
||||
c.ready.Store(false)
|
||||
|
||||
done := make(chan struct{})
|
||||
go c.keepalive(conn, done)
|
||||
err = c.readLoop(conn) // blocks until the link errors
|
||||
close(done)
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
c.setStatus(func(s *Status) {
|
||||
s.Connected = false
|
||||
if err != nil {
|
||||
s.LastError = "read: " + err.Error()
|
||||
}
|
||||
})
|
||||
if c.sleep(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keepalive periodically re-reads a port so an idle-but-dead link is detected
|
||||
// (the read loop's idle timeout fires if these stop producing replies).
|
||||
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
||||
t := time.NewTicker(keepaliveEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if !c.ready.Load() {
|
||||
continue // not authenticated / subscribed yet
|
||||
}
|
||||
_ = c.send("port get 1")
|
||||
_ = c.send("port get 2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendInit subscribes to live updates and pulls the initial state. Called once
|
||||
// the link is ready (authenticated, or immediately when no AUTH is required).
|
||||
// Command set/order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||
func (c *Client) sendInit() {
|
||||
c.ready.Store(true)
|
||||
for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
|
||||
if err := c.send(cmd); err != nil {
|
||||
applog.Printf("antgenius: init send %q failed: %v", cmd, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(conn net.Conn) error {
|
||||
r := bufio.NewReader(conn)
|
||||
var sb strings.Builder
|
||||
start := time.Now()
|
||||
lines, bytesRx := 0, 0
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
// Log where the link died: 0 lines after connect points at the device
|
||||
// refusing/closing (e.g. a client-slot limit or source-IP rejection —
|
||||
// common when reaching it remotely); an EOF after the banner/replies
|
||||
// points at something later in the exchange.
|
||||
applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v",
|
||||
time.Since(start).Round(time.Millisecond), lines, bytesRx, err)
|
||||
return err
|
||||
}
|
||||
bytesRx++
|
||||
if b == '\r' || b == '\n' {
|
||||
if sb.Len() > 0 {
|
||||
lines++
|
||||
if lines <= 10 {
|
||||
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
|
||||
}
|
||||
c.handleLine(sb.String())
|
||||
sb.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
}
|
||||
|
||||
// send writes a "C<seq>|<command>\r" line to the device.
|
||||
func (c *Client) send(command string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("antgenius: not connected")
|
||||
}
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
// The device only accepts the constant "C1|" sequence prefix for every
|
||||
// command (using incrementing sequence numbers makes it drop the link);
|
||||
// commands are LF-terminated.
|
||||
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleLine parses one response/status/banner line and updates the snapshot.
|
||||
func (c *Client) handleLine(line string) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
// Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we
|
||||
// send next — authenticate first when AUTH is announced, else go straight to
|
||||
// the subscribe/get commands.
|
||||
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
||||
if strings.Contains(line, "AUTH") && c.password != "" {
|
||||
c.awaitingAuth, c.authTries = true, 1
|
||||
applog.Printf("antgenius: AUTH required — authenticating")
|
||||
_ = c.send("auth code=" + c.password)
|
||||
} else {
|
||||
if strings.Contains(line, "AUTH") {
|
||||
applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)")
|
||||
}
|
||||
c.sendInit()
|
||||
}
|
||||
return
|
||||
}
|
||||
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||
var hexCode, msg string
|
||||
switch {
|
||||
case strings.HasPrefix(line, "R"):
|
||||
p := strings.SplitN(line, "|", 3)
|
||||
if len(p) >= 2 {
|
||||
hexCode = p[1]
|
||||
}
|
||||
if len(p) == 3 {
|
||||
msg = p[2]
|
||||
}
|
||||
case strings.HasPrefix(line, "S"):
|
||||
p := strings.SplitN(line, "|", 2)
|
||||
if len(p) == 2 {
|
||||
msg = p[1]
|
||||
}
|
||||
}
|
||||
msg = strings.TrimSpace(msg)
|
||||
// Auth result: the reply to "auth code=" carries an empty message; hex "0" =
|
||||
// accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry,
|
||||
// so resend a few times before giving up.
|
||||
if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" {
|
||||
switch {
|
||||
case hexCode == "0":
|
||||
c.awaitingAuth = false
|
||||
applog.Printf("antgenius: authenticated")
|
||||
c.sendInit()
|
||||
case c.authTries < 4:
|
||||
c.authTries++
|
||||
applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries)
|
||||
_ = c.send("auth code=" + c.password)
|
||||
default:
|
||||
c.awaitingAuth = false
|
||||
applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "antenna "):
|
||||
c.parseAntenna(msg)
|
||||
case strings.HasPrefix(msg, "port "):
|
||||
c.parsePort(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
|
||||
// The name may contain spaces, so it's extracted up to the " tx=" field.
|
||||
func (c *Client) parseAntenna(msg string) {
|
||||
fields := strings.Fields(msg)
|
||||
if len(fields) < 2 {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if i := strings.Index(msg, "name="); i >= 0 {
|
||||
name = msg[i+len("name="):]
|
||||
if j := strings.Index(name, " tx="); j >= 0 {
|
||||
name = name[:j]
|
||||
}
|
||||
// The device stores spaces as underscores in names.
|
||||
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||
}
|
||||
// Band bitmask: which bands this antenna is configured for. The device sends
|
||||
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
|
||||
// antennas (like the native app). Log the first few raw antenna lines so the
|
||||
// exact field name + bit values can be confirmed on real hardware.
|
||||
bands := kvInt(msg, "band")
|
||||
c.statusMu.Lock()
|
||||
if c.antRawN < 8 {
|
||||
c.antRawN++
|
||||
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
|
||||
}
|
||||
if name != "" && !isPlaceholderName(name) {
|
||||
c.antennas[id] = name
|
||||
c.antBands[id] = bands
|
||||
} else {
|
||||
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||
delete(c.antBands, id)
|
||||
}
|
||||
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
|
||||
c.status.Connected = true
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
|
||||
// shown is the TX antenna, falling back to the RX antenna when TX is none.
|
||||
func (c *Client) parsePort(msg string) {
|
||||
fields := strings.Fields(msg)
|
||||
if len(fields) < 2 {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(fields[1])
|
||||
if err != nil || (id != 1 && id != 2) {
|
||||
return
|
||||
}
|
||||
tx := kvInt(msg, "txant")
|
||||
rx := kvInt(msg, "rxant")
|
||||
active := tx
|
||||
if active == 0 {
|
||||
active = rx
|
||||
}
|
||||
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||
c.setStatus(func(s *Status) {
|
||||
s.Connected = true
|
||||
if id == 1 {
|
||||
s.PortA, s.TxA = active, txOn
|
||||
} else {
|
||||
s.PortB, s.TxB = active, txOn
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) sleep(d time.Duration) (stopped bool) {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return true
|
||||
case <-time.After(d):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// kvInt extracts the integer value of a "key=<int>" token from a space-
|
||||
// separated string (returns 0 if absent).
|
||||
func kvInt(s, key string) int {
|
||||
i := strings.Index(s, key+"=")
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
v := s[i+len(key)+1:]
|
||||
if sp := strings.IndexByte(v, ' '); sp >= 0 {
|
||||
v = v[:sp]
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
}
|
||||
|
||||
// isPlaceholderName reports whether name is an unconfigured-slot default like
|
||||
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
|
||||
// "antenna" followed by a number, which the UI shouldn't list.
|
||||
func isPlaceholderName(name string) bool {
|
||||
f := strings.Fields(strings.ToLower(name))
|
||||
if len(f) != 2 || f[0] != "antenna" {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(f[1])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
|
||||
out := make([]Antenna, 0, len(m))
|
||||
for idx, name := range m {
|
||||
out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package antgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHandleAntennaList(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
// Names may contain spaces — must be captured up to " tx=".
|
||||
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||
st := c.GetStatus()
|
||||
if len(st.Antennas) != 2 {
|
||||
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
|
||||
}
|
||||
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
|
||||
t.Errorf("antenna 1 = %+v", st.Antennas[0])
|
||||
}
|
||||
if st.Antennas[1].Name != "DX Commander" {
|
||||
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
st := c.GetStatus()
|
||||
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
|
||||
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePortStatus(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||
st := c.GetStatus()
|
||||
if st.PortA != 2 {
|
||||
t.Errorf("PortA = %d, want 2", st.PortA)
|
||||
}
|
||||
if st.PortB != 0 {
|
||||
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
|
||||
}
|
||||
// A "port get" reply (R-line) must parse the same way.
|
||||
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
|
||||
if st = c.GetStatus(); st.PortB != 5 {
|
||||
t.Errorf("PortB after port get = %d, want 5", st.PortB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortTxFallbackToRx(t *testing.T) {
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||
if st := c.GetStatus(); st.PortA != 3 {
|
||||
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKvInt(t *testing.T) {
|
||||
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
|
||||
if v := kvInt(s, "txant"); v != 7 {
|
||||
t.Errorf("txant = %d, want 7", v)
|
||||
}
|
||||
if v := kvInt(s, "rxant"); v != 2 {
|
||||
t.Errorf("rxant = %d, want 2", v)
|
||||
}
|
||||
if v := kvInt(s, "missing"); v != 0 {
|
||||
t.Errorf("missing = %d, want 0", v)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,16 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
||||
)
|
||||
|
||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
|
||||
file = f
|
||||
path = logPath
|
||||
|
||||
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
|
||||
// recover()s, or a runtime-fatal error like a concurrent map write, or a
|
||||
// Windows access violation routed through the Go signal handler) into a
|
||||
// dedicated file the runtime writes directly — so otherwise-silent process
|
||||
// deaths leave a stack we can read.
|
||||
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
|
||||
crashFile = cf
|
||||
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
|
||||
}
|
||||
|
||||
// Redirect log.Print* and the standard logger to the file too, so
|
||||
// any third-party output stays consistent.
|
||||
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package audio
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Codec converts between the wire payload of a network audio stream (Icom 50003)
|
||||
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
|
||||
// capture/render engine and the pcmRing use). It exists so the transport code
|
||||
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
|
||||
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
|
||||
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
|
||||
type Codec interface {
|
||||
// Name is a short label for logs/UI.
|
||||
Name() string
|
||||
// Decode turns one received audio payload into internal PCM. The returned
|
||||
// slice is freshly allocated (the caller may retain it).
|
||||
Decode(payload []byte) ([]byte, error)
|
||||
// Encode turns internal PCM into a payload to transmit.
|
||||
Encode(pcm []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
|
||||
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
|
||||
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
|
||||
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
|
||||
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
|
||||
//
|
||||
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
|
||||
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
|
||||
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
|
||||
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
|
||||
// in one place.
|
||||
type pcm16Codec struct{}
|
||||
|
||||
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
|
||||
func NewPCM16Codec() Codec { return pcm16Codec{} }
|
||||
|
||||
func (pcm16Codec) Name() string { return "pcm16" }
|
||||
|
||||
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
|
||||
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
|
||||
// packet — trim the stray byte rather than emit a half-sample click.
|
||||
n := len(payload) &^ 1
|
||||
if n != len(payload) {
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
|
||||
}
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, payload[:n])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
|
||||
out := make([]byte, len(pcm))
|
||||
copy(out, pcm)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package audio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPCM16CodecRoundTrip(t *testing.T) {
|
||||
c := NewPCM16Codec()
|
||||
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
|
||||
enc, err := c.Encode(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
dec, err := c.Decode(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if string(dec) != string(in) {
|
||||
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCM16CodecTrimsOddByte(t *testing.T) {
|
||||
c := NewPCM16Codec()
|
||||
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(dec) != 2 {
|
||||
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
|
||||
c := NewPCM16Codec()
|
||||
if _, err := c.Decode([]byte{0x10}); err == nil {
|
||||
t.Fatalf("expected an error for a sub-sample payload")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package audio
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
||||
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
||||
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
||||
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
||||
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
||||
// without touching the render side, mirroring the civTransport split on the CAT
|
||||
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
||||
// underrun Pull simply returns short and the render loop pads with silence.
|
||||
type pcmRing struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
||||
}
|
||||
|
||||
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
||||
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
||||
func newPCMRing(maxBytes int) *pcmRing {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = bytesPerSec // 1 s default
|
||||
}
|
||||
return &pcmRing{max: maxBytes}
|
||||
}
|
||||
|
||||
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
||||
// the cap (a slow/absent consumer never makes the producer block or grow without
|
||||
// bound). A short glitch beats runaway latency for live monitoring.
|
||||
func (r *pcmRing) Push(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.buf = append(r.buf, p...)
|
||||
if len(r.buf) > r.max {
|
||||
drop := len(r.buf) - r.max
|
||||
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
||||
// when empty. The render loop pads any shortfall with silence.
|
||||
func (r *pcmRing) pull(maxBytes int) []byte {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.buf) == 0 || maxBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
n := maxBytes
|
||||
if n > len(r.buf) {
|
||||
n = len(r.buf)
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, r.buf[:n])
|
||||
r.buf = append(r.buf[:0], r.buf[n:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
||||
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
|
||||
// COM-initialised, OS-locked thread.
|
||||
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
if err := coInit(); err != nil {
|
||||
return fmt.Errorf("CoInitialize: %w", err)
|
||||
}
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
dev, err := openDevice(wca.ERender, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dev.Release()
|
||||
|
||||
var ac *wca.IAudioClient
|
||||
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||
return fmt.Errorf("activate render: %w", err)
|
||||
}
|
||||
defer ac.Release()
|
||||
|
||||
frameBytes := ch * bits / 8
|
||||
if frameBytes <= 0 {
|
||||
return fmt.Errorf("bad audio format")
|
||||
}
|
||||
wfx := &wca.WAVEFORMATEX{
|
||||
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
||||
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
||||
WBitsPerSample: uint16(bits), CbSize: 0,
|
||||
}
|
||||
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
||||
return fmt.Errorf("initialize render: %w", err)
|
||||
}
|
||||
var bufFrames uint32
|
||||
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
||||
return err
|
||||
}
|
||||
var arc *wca.IAudioRenderClient
|
||||
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
||||
return fmt.Errorf("get render service: %w", err)
|
||||
}
|
||||
defer arc.Release()
|
||||
|
||||
// feed fills up to `frames` render frames: as much real audio as the ring
|
||||
// has, the remainder silence (so the buffer stays full and the clock steady).
|
||||
feed := func(frames int) error {
|
||||
if frames <= 0 {
|
||||
return nil
|
||||
}
|
||||
var data *byte
|
||||
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
|
||||
return err
|
||||
}
|
||||
dst := unsafe.Slice(data, frames*frameBytes)
|
||||
got := src.pull(frames * frameBytes)
|
||||
n := copy(dst, got)
|
||||
for i := n; i < len(dst); i++ {
|
||||
dst[i] = 0 // silence-fill the shortfall
|
||||
}
|
||||
arc.ReleaseBuffer(uint32(frames), 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
|
||||
return err
|
||||
}
|
||||
if err := ac.Start(); err != nil {
|
||||
return fmt.Errorf("start render: %w", err)
|
||||
}
|
||||
defer ac.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
var padding uint32
|
||||
ac.GetCurrentPadding(&padding)
|
||||
if err := feed(int(bufFrames - padding)); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(8 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
+131
-1
@@ -15,7 +15,10 @@ type Manager struct {
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
onChange func() // fired on any record/playback state transition
|
||||
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||
onChange func() // fired on any record/playback state transition
|
||||
}
|
||||
|
||||
type recResult struct {
|
||||
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||
//
|
||||
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
|
||||
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
|
||||
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
|
||||
// is later fed by the network 50003 stream instead of a USB capture — the render
|
||||
// half is transport-agnostic. inputDev "" = system default capture.
|
||||
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
||||
return m.startMonitor(inputDev, outputDev, true)
|
||||
}
|
||||
|
||||
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
||||
// producer — the network 50003 stream — can feed decoded RX PCM via
|
||||
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
||||
func (m *Manager) StartMonitorSink(outputDev string) error {
|
||||
return m.startMonitor("", outputDev, false)
|
||||
}
|
||||
|
||||
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
|
||||
// when capture is true it also captures inputDev into that ring (USB monitor).
|
||||
// When false the ring is fed only by PushMonitorAudio (network audio).
|
||||
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Lock()
|
||||
if m.monStop != nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("monitor already running")
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
|
||||
m.monStop, m.monRing = stop, ring
|
||||
m.mu.Unlock()
|
||||
|
||||
if capture {
|
||||
// Producer: capture the rig's USB audio into the ring.
|
||||
go func() {
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
}()
|
||||
}
|
||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||
go func() {
|
||||
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||
}()
|
||||
m.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopMonitor stops the RX monitor passthrough.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
stop := m.monStop
|
||||
m.monStop, m.monRing = nil, nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// MonitorActive reports whether the RX monitor passthrough is running.
|
||||
func (m *Manager) MonitorActive() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.monStop != nil
|
||||
}
|
||||
|
||||
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
|
||||
// active monitor's output — the hook the network 50003 audio stream uses to play
|
||||
// decoded RX through the very same output path a USB capture feeds. No-op when no
|
||||
// monitor is running. Keeps the unexported ring inside the package.
|
||||
func (m *Manager) PushMonitorAudio(pcm []byte) {
|
||||
m.mu.Lock()
|
||||
ring := m.monRing
|
||||
m.mu.Unlock()
|
||||
if ring != nil {
|
||||
ring.Push(pcm)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
||||
//
|
||||
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
|
||||
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
|
||||
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
|
||||
// capture + render primitives, source/sink swapped). PTT keying is the caller's
|
||||
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
|
||||
// audio route. The captured 16 kHz mono stream is also the exact shape the future
|
||||
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
|
||||
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
||||
m.mu.Lock()
|
||||
if m.txStop != nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("TX audio already running")
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
|
||||
m.txStop = stop
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
}()
|
||||
go func() {
|
||||
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||
}()
|
||||
m.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopTXAudio stops the TX mic→rig passthrough.
|
||||
func (m *Manager) StopTXAudio() {
|
||||
m.mu.Lock()
|
||||
stop := m.txStop
|
||||
m.txStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// TXAudioActive reports whether the TX mic→rig passthrough is running.
|
||||
func (m *Manager) TXAudioActive() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.txStop != nil
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
|
||||
for i, v := range mono32 {
|
||||
stereo[2*i], stereo[2*i+1] = v, v
|
||||
}
|
||||
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
|
||||
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
|
||||
// regardless of how short the trailing chunk is. If the buffer isn't an exact
|
||||
// multiple of a frame, the final pass reads past the slice and the process
|
||||
// dies with an access violation (0xc0000005) inside windowFilterSubband.
|
||||
// Pad with trailing silence to a whole number of frames so no partial pass
|
||||
// exists. (~36 ms of silence at most — inaudible.)
|
||||
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
|
||||
if rem := len(stereo) % frameInterleaved; rem != 0 {
|
||||
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
|
||||
}
|
||||
if len(stereo) == 0 {
|
||||
return nil // nothing to encode (empty recording)
|
||||
}
|
||||
enc := mp3.NewEncoder(mp3Rate, 2)
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
@@ -239,6 +239,22 @@ func (r *Recorder) RestartQSO() {
|
||||
r.active = true
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
|
||||
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
|
||||
// contain only audio from this moment onward. Used when the contact you entered
|
||||
// was already in a long QSO and you want to record just your own exchange.
|
||||
// No-op if not running; if no take is active it begins one (empty).
|
||||
func (r *Recorder) ResetQSOClock() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.running {
|
||||
return
|
||||
}
|
||||
r.acc = nil
|
||||
r.active = true
|
||||
}
|
||||
|
||||
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
||||
|
||||
+412
-89
@@ -13,6 +13,10 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -55,23 +59,33 @@ type Def struct {
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
Alias string `json:"alias,omitempty"`
|
||||
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
|
||||
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
|
||||
// the prefecture name), "both" = "ref — name". DXCC always shows the country
|
||||
// name under "ref" (unchanged).
|
||||
RefDisplay string `json:"ref_display,omitempty"`
|
||||
|
||||
// --- Type & matching ---
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||
|
||||
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
||||
// earns a reference if the primary match OR any of these match. Lets a
|
||||
// French department (DDFM) be found from "D74" in the note AND from a postal
|
||||
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||
// yields a reference wins and the rest are skipped (short-circuit, like a
|
||||
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
|
||||
// province NAME in the address, and only if that fails, from a city regex
|
||||
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
|
||||
// re-tagged, possibly differently, by a later rule.
|
||||
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||
|
||||
// --- Scope ---
|
||||
@@ -83,11 +97,45 @@ type Def struct {
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||
// controls have been removed from the editor — a checkbox that quietly does
|
||||
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
|
||||
// internal/adif) before showing them again.
|
||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||
|
||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
|
||||
// --- Catalog updates ---
|
||||
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
|
||||
// you fix a definition (a better OR chain, a corrected reference list) and want
|
||||
// that fix to reach operators who already run the award: on startup, a catalog
|
||||
// award whose Version is higher than the stored one REPLACES it, definition and
|
||||
// references. Awards created by the operator have no version and are never touched.
|
||||
Version int `json:"version,omitempty"`
|
||||
// UserEdited marks an award the operator has changed. A catalog update then
|
||||
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
|
||||
// list) is saved to something other than what the catalog ships.
|
||||
UserEdited bool `json:"user_edited,omitempty"`
|
||||
}
|
||||
|
||||
// SameContent reports whether two definitions describe the same award — ignoring
|
||||
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
|
||||
// which say where a definition came from, not what it does. Used to decide whether
|
||||
// a save actually changed anything.
|
||||
func (d Def) SameContent(o Def) bool {
|
||||
a, b := d, o
|
||||
a.Version, b.Version = 0, 0
|
||||
a.UserEdited, b.UserEdited = false, false
|
||||
a.Builtin, b.Builtin = false, false
|
||||
ja, err1 := json.Marshal(a)
|
||||
jb, err2 := json.Marshal(b)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||
@@ -104,26 +152,124 @@ type OrRule struct {
|
||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
||||
// accepts it (WAC).
|
||||
lq := []string{"lotw", "qsl"}
|
||||
lo := []string{"lotw"}
|
||||
return []Def{
|
||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||
//
|
||||
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||
// recompile and a release for every new one, which is absurd for something that
|
||||
// changes far more often than the engine that reads it. They now live one JSON per
|
||||
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||
//
|
||||
// This is the SEED only. Once a user has awards in their database, that database
|
||||
// is the source of truth — the catalog never overwrites their edits behind their
|
||||
// back.
|
||||
//
|
||||
//go:embed catalog/*.json
|
||||
var catalogFS embed.FS
|
||||
|
||||
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||
//
|
||||
// The references are the point. An award's definition is a few lines; what makes
|
||||
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||
// A catalog that shipped definitions only would hand every user an empty shell —
|
||||
// which is exactly the trap this design is meant to avoid.
|
||||
//
|
||||
// References stay as raw JSON so this package (the matching ENGINE) never has to
|
||||
// import the reference-store package. The caller decodes them.
|
||||
type CatalogEntry struct {
|
||||
Def Def `json:"def"`
|
||||
References json.RawMessage `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
|
||||
//
|
||||
// {"def": {...}, "references": [...]} — a catalog entry
|
||||
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
|
||||
//
|
||||
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
|
||||
// can be dropped straight into catalog/ and shipped to everyone, with no
|
||||
// conversion step. That is the whole loop — create an award, export it, drop it in,
|
||||
// everybody has it.
|
||||
type catalogFile struct {
|
||||
Def Def `json:"def"`
|
||||
References json.RawMessage `json:"references,omitempty"`
|
||||
Awards []CatalogEntry `json:"awards,omitempty"`
|
||||
// A bare Def (the original catalog shape) is still read via the fields above
|
||||
// being empty and Code being set at the top level.
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Catalog returns every built-in award: definition + references, sorted by code.
|
||||
//
|
||||
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
|
||||
// how a user's award list quietly reorders itself on a rebuild.
|
||||
func Catalog() []CatalogEntry {
|
||||
entries, err := catalogFS.ReadDir("catalog")
|
||||
if err != nil {
|
||||
return nil // embedded: can only fail if the build is broken
|
||||
}
|
||||
out := make([]CatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
b, err := catalogFS.ReadFile("catalog/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var f catalogFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
// A malformed file must not take the other awards down with it.
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
|
||||
for _, a := range f.Awards {
|
||||
if strings.TrimSpace(a.Def.Code) != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
|
||||
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
|
||||
case strings.TrimSpace(f.Code) != "": // a bare Def
|
||||
var d Def
|
||||
if err := json.Unmarshal(b, &d); err == nil {
|
||||
out = append(out, CatalogEntry{Def: d})
|
||||
}
|
||||
}
|
||||
}
|
||||
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||
// quietly miss every future catalog correction. Making the author remember to
|
||||
// flip a flag is exactly the kind of step nobody can guess.
|
||||
for i := range out {
|
||||
out[i].Def.Builtin = true
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||
return out
|
||||
}
|
||||
|
||||
// Defaults are the built-in award definitions seeded on first run.
|
||||
func Defaults() []Def {
|
||||
cat := Catalog()
|
||||
out := make([]Def, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, e.Def)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
|
||||
// any. Awards whose list is seeded from code (DXCC entities, French departments)
|
||||
// or fetched online (POTA/SOTA/WWFF) carry none.
|
||||
func CatalogRefs(code string) (json.RawMessage, bool) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
for _, e := range Catalog() {
|
||||
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
|
||||
return e.References, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Migrate upgrades award definitions saved before the richer model existed.
|
||||
@@ -252,6 +398,21 @@ type RefMeta struct {
|
||||
}
|
||||
|
||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||
// compileAwardRE compiles an award / reference regex. Award patterns are run
|
||||
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
|
||||
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
|
||||
// default. An author who set their own flag group (e.g. "(?s)") is respected.
|
||||
func compileAwardRE(p string) (*regexp.Regexp, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return nil, errors.New("empty pattern")
|
||||
}
|
||||
if !strings.HasPrefix(p, "(?") {
|
||||
p = "(?i)" + p
|
||||
}
|
||||
return regexp.Compile(p)
|
||||
}
|
||||
|
||||
func NewRefList(metas []RefMeta) refList {
|
||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||
for _, m := range metas {
|
||||
@@ -260,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
|
||||
continue
|
||||
}
|
||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||
if re, err := regexp.Compile(p); err == nil {
|
||||
if re, err := compileAwardRE(p); err == nil {
|
||||
m.re = re
|
||||
rl.withPattern = append(rl.withPattern, code)
|
||||
}
|
||||
@@ -292,7 +453,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
perr := make([]string, len(defs))
|
||||
for i := range defs {
|
||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||
re, err := regexp.Compile(p)
|
||||
re, err := compileAwardRE(p)
|
||||
if err != nil {
|
||||
perr[i] = "bad pattern: " + err.Error()
|
||||
} else {
|
||||
@@ -427,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
||||
}
|
||||
var re *regexp.Regexp
|
||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||
if c, err := regexp.Compile(p); err == nil {
|
||||
if c, err := compileAwardRE(p); err == nil {
|
||||
re = c
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rl := NewRefList(metas)
|
||||
found := candidates(&d, re, q, rl, len(metas) > 0)
|
||||
// Merge operator-assigned references (manual override). These let the
|
||||
// operator tag a QSO for an award whose field/description matching can't
|
||||
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
||||
// province NAME, so a contact whose address doesn't spell it out needs the
|
||||
// province picked by hand. For a predefined award the override is still
|
||||
// validated against its reference list.
|
||||
if man := manualRefs(q, d.Code); len(man) > 0 {
|
||||
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
||||
}
|
||||
return found
|
||||
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||
}
|
||||
|
||||
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||
@@ -479,30 +630,6 @@ func manualRefs(q *qso.QSO, code string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
||||
// When the award is predefined, only references present and valid in its list
|
||||
// are kept (so a typo can't invent a reference).
|
||||
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
seen[normalizeRef(c)] = struct{}{}
|
||||
}
|
||||
for _, c := range manual {
|
||||
c = normalizeRef(c)
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
if predefined {
|
||||
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
found = append(found, c)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||
@@ -560,6 +687,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
found = append(found, nc.code)
|
||||
}
|
||||
}
|
||||
// A reference may also declare its own regex to broaden matching beyond
|
||||
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
|
||||
// those too — the name match and the pattern are OR'd.
|
||||
for _, code := range rl.withPattern {
|
||||
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||
found = append(found, code)
|
||||
}
|
||||
}
|
||||
case predefined && !exact:
|
||||
// "Search reference inside the field": look up each token of the field in
|
||||
// the list — O(tokens), not O(all references) — plus test the few
|
||||
@@ -588,32 +723,207 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
return found
|
||||
}
|
||||
|
||||
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
|
||||
//
|
||||
// An award that silently matches nothing is the hardest kind of bug to see: the
|
||||
// UI shows an empty column and the operator has no way to tell whether the QSO is
|
||||
// out of scope, the field is empty, the rule looked in the wrong place, or the
|
||||
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
|
||||
// reports every step it took.
|
||||
|
||||
// Rejected is a candidate a rule produced that did not survive.
|
||||
type Rejected struct {
|
||||
Candidate string `json:"candidate"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Step is one matching rule as it actually ran.
|
||||
type Step struct {
|
||||
Rule string `json:"rule"` // "primary", "OR 1", …
|
||||
Field string `json:"field"` // the QSO field it scanned
|
||||
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
|
||||
Exact bool `json:"exact,omitempty"` // whole field IS the reference
|
||||
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
|
||||
FieldValue string `json:"field_value,omitempty"` // what the field actually held
|
||||
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
|
||||
Kept []string `json:"kept,omitempty"` // what survived the reference list
|
||||
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
|
||||
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
|
||||
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
|
||||
}
|
||||
|
||||
// Explanation is the full account of one QSO against one award.
|
||||
type Explanation struct {
|
||||
Code string `json:"code"`
|
||||
InScope bool `json:"in_scope"`
|
||||
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
|
||||
Predefined bool `json:"predefined"` // matches are validated against a reference list
|
||||
RefCount int `json:"ref_count"` // size of that list
|
||||
Steps []Step `json:"steps"`
|
||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
}
|
||||
|
||||
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||
// through the SAME code path as Compute — not a re-implementation — so what it
|
||||
// shows is what actually happens.
|
||||
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
|
||||
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
|
||||
rl := NewRefList(metas)
|
||||
ex.Predefined = len(metas) > 0 && !d.Dynamic
|
||||
ex.RefCount = len(metas)
|
||||
|
||||
var why string
|
||||
if !inScopeWhy(&d, q, &why) {
|
||||
ex.ScopeError = why
|
||||
return ex // out of scope: no rule ever runs, and saying so IS the answer
|
||||
}
|
||||
ex.InScope = true
|
||||
|
||||
var re *regexp.Regexp
|
||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||
c, err := compileAwardRE(p)
|
||||
if err != nil {
|
||||
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
|
||||
Error: "bad regex: " + err.Error()})
|
||||
return ex
|
||||
}
|
||||
re = c
|
||||
}
|
||||
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
|
||||
return ex
|
||||
}
|
||||
|
||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||
return candidatesTrace(d, re, q, rl, hasList, nil)
|
||||
}
|
||||
|
||||
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
|
||||
// passes it) each rule records what it scanned, what it produced and what was
|
||||
// rejected. The tracing MUST live inside the real matcher rather than in a
|
||||
// parallel "explain" implementation — a trace that can drift from the code it
|
||||
// describes is worse than no trace, because it is believed.
|
||||
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||
predefined := hasList && !d.Dynamic
|
||||
|
||||
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
||||
// run executes one rule and, when tracing, records it.
|
||||
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||
kept := keepRefs(predefined, rl, raw)
|
||||
if ex != nil {
|
||||
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||
Candidates: raw, Kept: kept}
|
||||
keptSet := map[string]struct{}{}
|
||||
for _, k := range kept {
|
||||
keptSet[k] = struct{}{}
|
||||
}
|
||||
for _, c := range raw {
|
||||
n := normalizeRef(c)
|
||||
if _, ok := keptSet[n]; ok {
|
||||
continue
|
||||
}
|
||||
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||
}
|
||||
ex.Steps = append(ex.Steps, s)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||
// only while nothing has matched yet, and stop at the first that yields a
|
||||
// reference (short-circuit). So a province already found by NAME isn't also
|
||||
// re-derived, possibly differently, from a later city-regex rule.
|
||||
//
|
||||
// The short-circuit tests what a rule REALLY yielded — the references that
|
||||
// survive the predefined list — not its raw candidates. A rule can always
|
||||
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
|
||||
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
|
||||
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||
// next rule ("find the code inside the QTH") would have found BG.
|
||||
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||
for i := range d.OrRules {
|
||||
r := &d.OrRules[i]
|
||||
label := fmt.Sprintf("OR %d", i+1)
|
||||
if len(found) > 0 {
|
||||
if ex != nil {
|
||||
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
|
||||
Pattern: r.Pattern, Skipped: true})
|
||||
}
|
||||
continue // an earlier rule already matched — fallbacks short-circuit
|
||||
}
|
||||
var rre *regexp.Regexp
|
||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||
c, err := regexp.Compile(p)
|
||||
c, err := compileAwardRE(p)
|
||||
if err != nil {
|
||||
if ex != nil {
|
||||
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
|
||||
Error: "bad regex: " + err.Error()})
|
||||
}
|
||||
continue // skip a rule with a bad regex rather than failing the award
|
||||
}
|
||||
rre = c
|
||||
}
|
||||
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
||||
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
|
||||
}
|
||||
|
||||
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||
// the operator tag a QSO for an award whose field/description matching can't
|
||||
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
|
||||
// a contact whose address doesn't spell it out needs the province picked by
|
||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||
// predefined award the ref is still validated against the list below.
|
||||
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||
if ex != nil {
|
||||
ex.Manual = manual
|
||||
}
|
||||
found = append(found, manual...)
|
||||
out := dedupe(found)
|
||||
if ex != nil {
|
||||
ex.Result = out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rejection explains why a candidate the operator can SEE in the trace did not
|
||||
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||
// that nothing was printing.
|
||||
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||
switch {
|
||||
case code == "":
|
||||
return Rejected{Candidate: code, Reason: "empty"}
|
||||
case !predefined:
|
||||
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||
}
|
||||
m, ok := rl.byCode[code]
|
||||
if !ok {
|
||||
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||
}
|
||||
if !m.Valid {
|
||||
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||
}
|
||||
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||
}
|
||||
|
||||
// keepRefs reduces a rule's raw candidates to the references that actually count.
|
||||
// For a predefined award that means the codes on the list, enabled. The
|
||||
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
|
||||
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||
// 291). Per-reference DXCC stays metadata for the picker.
|
||||
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||
if !predefined {
|
||||
return dedupe(found)
|
||||
out := make([]string, 0, len(found))
|
||||
for _, c := range found {
|
||||
if c = normalizeRef(c); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return dedupe(out)
|
||||
}
|
||||
// Enforce the predefined list: keep only listed, valid references. The
|
||||
// award-level DXCCFilter already scopes which QSOs are considered (see
|
||||
// inScope), so we do NOT additionally require the QSO's entity to match the
|
||||
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
||||
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
@@ -754,24 +1064,37 @@ func natLess(a, b string) bool {
|
||||
|
||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission category, validity dates).
|
||||
func inScope(d *Def, q *qso.QSO) bool {
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
|
||||
|
||||
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
|
||||
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
|
||||
// which runs this for every QSO × every award, passes nil and pays nothing).
|
||||
// Keeping both behind one function is the point: a scope check that disagrees with
|
||||
// the scope check it explains would be worse than no explanation at all.
|
||||
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
|
||||
fail := func(format string, args ...any) bool {
|
||||
if why != nil {
|
||||
*why = fmt.Sprintf(format, args...)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
|
||||
}
|
||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||
return false
|
||||
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
|
||||
}
|
||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||
return false
|
||||
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
|
||||
}
|
||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||
return false
|
||||
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
|
||||
}
|
||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||
return false
|
||||
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
|
||||
}
|
||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||
return false
|
||||
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
@@ -128,6 +131,146 @@ func TestComputeMatchByDescription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
|
||||
// rules; when the primary finds nothing, the first OR rule that hits wins and
|
||||
// later rules are skipped. DDFM-style: a French department from the NOTE first,
|
||||
// else captured from a postal code in the address (Prefix "D" → "D74").
|
||||
func TestComputeOrRuleFallback(t *testing.T) {
|
||||
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
|
||||
DXCCFilter: []int{227}, Valid: true,
|
||||
OrRules: []OrRule{
|
||||
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
|
||||
},
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"DDFM": {
|
||||
{Code: "D74", Name: "Haute-Savoie", Valid: true},
|
||||
{Code: "D75", Name: "Paris", Valid: true},
|
||||
}}
|
||||
worked := func(q qso.QSO) []string {
|
||||
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||
var out []string
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Worked {
|
||||
out = append(out, rf.Ref)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Primary (note "D74") matches → the postal OR rule is skipped, so the
|
||||
// address's 75001 does NOT also add D75. Exactly the user's ask: first
|
||||
// condition wins, no fall-through.
|
||||
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
|
||||
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
|
||||
}
|
||||
// No note dept → fall through to the OR rule → postal 74140 → D74.
|
||||
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
|
||||
t.Errorf("OR fallback: got %v, want [D74]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
|
||||
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
|
||||
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
|
||||
// rule with an empty rule-pattern triggers the per-reference regexes.
|
||||
func TestComputeProvinceCascade(t *testing.T) {
|
||||
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||
DXCCFilter: []int{318}, Valid: true,
|
||||
OrRules: []OrRule{
|
||||
{Field: "address", MatchBy: "description"},
|
||||
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
|
||||
},
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"WAPC": {
|
||||
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
|
||||
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
|
||||
}}
|
||||
worked := func(q qso.QSO) []string {
|
||||
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||
var out []string
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Worked {
|
||||
out = append(out, rf.Ref)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name, qth, addr string
|
||||
want string
|
||||
}{
|
||||
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
|
||||
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
|
||||
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
|
||||
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
|
||||
if len(got) != 1 || got[0] != c.want {
|
||||
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
|
||||
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
|
||||
// no QSO field) sticks after the operator picks the province by hand. The engine
|
||||
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
|
||||
// the ref exists solely as the override.
|
||||
func TestComputeManualOverride(t *testing.T) {
|
||||
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
|
||||
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||
qsos := []qso.QSO{
|
||||
// Address doesn't name the province → only the override tags it.
|
||||
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
|
||||
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"WAPC": {
|
||||
{Code: "JS", Name: "Jiangsu", Valid: true},
|
||||
{Code: "GD", Name: "Guangdong", Valid: true},
|
||||
}}
|
||||
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||
if r.Worked != 1 {
|
||||
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
|
||||
}
|
||||
var worked []string
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Worked {
|
||||
worked = append(worked, rf.Ref)
|
||||
}
|
||||
}
|
||||
if len(worked) != 1 || worked[0] != "JS" {
|
||||
t.Errorf("worked refs = %v, want [JS]", worked)
|
||||
}
|
||||
|
||||
// An override for a ref NOT in the predefined list is rejected (no typo refs).
|
||||
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
|
||||
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
|
||||
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
|
||||
}
|
||||
}
|
||||
|
||||
// A description-match award must also honour a REFERENCE's own regex (used to
|
||||
// broaden matching past the plain name) AND match case-insensitively — a log
|
||||
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
|
||||
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
|
||||
func TestComputeDescriptionRefPattern(t *testing.T) {
|
||||
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
|
||||
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"WAJA": {
|
||||
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
|
||||
{Code: "27", Name: "Osaka", Valid: true},
|
||||
{Code: "01", Name: "Hokkaido", Valid: true},
|
||||
}}
|
||||
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||
if r.Worked != 2 {
|
||||
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
|
||||
}
|
||||
}
|
||||
|
||||
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
||||
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||
// 4-char prefix of GRIDSQUARE.
|
||||
@@ -146,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||
// fails on the count, the catalog file is wrong, not the test.
|
||||
func TestCatalogFFMA(t *testing.T) {
|
||||
raw, ok := CatalogRefs("FFMA")
|
||||
if !ok {
|
||||
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||
}
|
||||
var refs []struct {
|
||||
Code string `json:"code"`
|
||||
DXCC int `json:"dxcc"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||
t.Fatalf("FFMA references: %v", err)
|
||||
}
|
||||
if len(refs) != 488 {
|
||||
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||
}
|
||||
|
||||
var def Def
|
||||
metas := make([]RefMeta, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||
if r.DXCC != 0 {
|
||||
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||
}
|
||||
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||
}
|
||||
for _, d := range Defaults() {
|
||||
if d.Code == "FFMA" {
|
||||
def = d
|
||||
}
|
||||
}
|
||||
if def.Total != 488 || def.Field != "grid4" {
|
||||
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||
}
|
||||
|
||||
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||
}
|
||||
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||
var got []string
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Worked {
|
||||
got = append(got, rf.Ref)
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||
}
|
||||
if r.Worked != len(want) || r.Total != 488 {
|
||||
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// WAIP: the primary rule takes the WHOLE address as the province code (exact
|
||||
// match), which can never be one — but it does produce a raw candidate. The OR
|
||||
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
|
||||
// and it must still run: a rule that yields nothing VALID is not a hit.
|
||||
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
|
||||
def := Def{
|
||||
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
|
||||
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
|
||||
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
|
||||
Confirm: []string{"lotw", "qsl"},
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "BG", Name: "Bergamo", Valid: true},
|
||||
{Code: "MI", Name: "Milano", Valid: true},
|
||||
}
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
|
||||
}
|
||||
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
|
||||
if r.Worked != 1 {
|
||||
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
|
||||
}
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Ref == "BG" && rf.Worked {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("BG not worked; refs = %v", refCodes(r))
|
||||
}
|
||||
|
||||
// Explain must account for the WAIP case exactly: the primary rule produces a
|
||||
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
|
||||
func TestExplainAccountsForEveryRule(t *testing.T) {
|
||||
def := Def{
|
||||
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
|
||||
Confirm: []string{"lotw"},
|
||||
}
|
||||
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
|
||||
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
|
||||
|
||||
ex := Explain(def, metas, q)
|
||||
if !ex.InScope || len(ex.Steps) != 2 {
|
||||
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
|
||||
}
|
||||
// The primary rule LOOKS like it found something. Saying only "no match" here is
|
||||
// what cost hours: the trace has to show the candidate and why it lost.
|
||||
p := ex.Steps[0]
|
||||
if len(p.Candidates) == 0 {
|
||||
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
|
||||
}
|
||||
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
|
||||
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
|
||||
}
|
||||
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
|
||||
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
|
||||
}
|
||||
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
|
||||
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
|
||||
}
|
||||
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
|
||||
t.Errorf("result = %v, want [BG]", ex.Result)
|
||||
}
|
||||
|
||||
// A trace that disagrees with the matcher is worse than none: it is believed.
|
||||
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
|
||||
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
|
||||
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
|
||||
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
|
||||
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
|
||||
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
|
||||
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
|
||||
if ex.InScope {
|
||||
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
|
||||
}
|
||||
if !strings.Contains(ex.ScopeError, "2m") {
|
||||
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
|
||||
}
|
||||
if len(ex.Steps) != 0 {
|
||||
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
|
||||
}
|
||||
}
|
||||
|
||||
func refCodes(r Result) []string {
|
||||
out := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
@@ -238,3 +535,152 @@ func TestComputePredefinedList(t *testing.T) {
|
||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||
}
|
||||
}
|
||||
|
||||
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
|
||||
// A refactor is only a refactor if the behaviour is identical, so pin down what
|
||||
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
|
||||
// disable an award, and nobody would notice until a QSO stopped counting.
|
||||
func TestCatalogDefaults(t *testing.T) {
|
||||
defs := Defaults()
|
||||
byCode := map[string]Def{}
|
||||
for _, d := range defs {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
|
||||
// assert the ten originals are still there, never that the count is exactly ten:
|
||||
// a test that fails the moment you add an award is a test that teaches you to
|
||||
// ignore it.
|
||||
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
|
||||
if len(defs) < len(want) {
|
||||
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
|
||||
}
|
||||
for _, c := range want {
|
||||
d, ok := byCode[c]
|
||||
if !ok {
|
||||
t.Errorf("%s missing from the embedded catalog", c)
|
||||
continue
|
||||
}
|
||||
// Valid=false would hide the award entirely; Builtin=false would let a
|
||||
// "reset to defaults" delete it. Both are silent failures.
|
||||
if !d.Valid || !d.Builtin {
|
||||
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
|
||||
}
|
||||
if d.Type == "" || d.Field == "" {
|
||||
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
|
||||
}
|
||||
if len(d.Confirm) == 0 {
|
||||
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Spot-check the two that carry real matching logic, since a mangled escape in
|
||||
// JSON is exactly the failure this test exists to catch.
|
||||
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
|
||||
}
|
||||
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
|
||||
t.Errorf("DDFM pattern does not compile: %v", err)
|
||||
}
|
||||
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
|
||||
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
|
||||
}
|
||||
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
|
||||
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
|
||||
}
|
||||
if !byCode["WPX"].Dynamic {
|
||||
t.Error("WPX must be dynamic — its references aren't a fixed list")
|
||||
}
|
||||
// Deterministic order: an embed walk that reorders would shuffle the user's list.
|
||||
for i := 1; i < len(defs); i++ {
|
||||
if defs[i-1].Code > defs[i].Code {
|
||||
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
|
||||
// straight into catalog/ and shipped to everyone — no conversion step. So the
|
||||
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
|
||||
// because a WAPC without its provinces and their city regexes is an empty shell.
|
||||
func TestCatalogAcceptsExportedBundle(t *testing.T) {
|
||||
// Exactly what ExportAward writes.
|
||||
exported := []byte(`{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T10:00:00Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
|
||||
"type":"QSOFIELDS","field":"address","match_by":"description",
|
||||
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
|
||||
"references": [
|
||||
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
|
||||
{"code":"ZJ","name":"Zhejiang","valid":true}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
var f catalogFile
|
||||
if err := json.Unmarshal(exported, &f); err != nil {
|
||||
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
|
||||
}
|
||||
if len(f.Awards) != 1 {
|
||||
t.Fatalf("got %d awards, want 1", len(f.Awards))
|
||||
}
|
||||
e := f.Awards[0]
|
||||
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
|
||||
t.Errorf("definition lost in the round-trip: %+v", e.Def)
|
||||
}
|
||||
if len(e.References) == 0 {
|
||||
t.Fatal("references dropped — this is the whole value of sharing an award")
|
||||
}
|
||||
// The per-reference regex must survive: it is what makes the award actually work.
|
||||
var refs []struct {
|
||||
Code string `json:"code"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
if err := json.Unmarshal(e.References, &refs); err != nil {
|
||||
t.Fatalf("references not decodable: %v", err)
|
||||
}
|
||||
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
|
||||
t.Errorf("reference regex did not survive: %+v", refs)
|
||||
}
|
||||
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
|
||||
t.Errorf("the shipped regex does not compile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A single malformed file in the catalog must not take the other awards down with
|
||||
// it — one bad JSON should cost you one award, not all of them.
|
||||
func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
||||
if len(Catalog()) < 10 {
|
||||
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
|
||||
}
|
||||
var f catalogFile
|
||||
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
|
||||
t.Error("expected a parse error on malformed JSON")
|
||||
}
|
||||
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||
}
|
||||
|
||||
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||
//
|
||||
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||
// built-in" and would then silently miss every future catalog correction. Making
|
||||
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||
// catalog derives it instead.
|
||||
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||
for _, e := range Catalog() {
|
||||
if !e.Def.Builtin {
|
||||
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||
}
|
||||
}
|
||||
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||
if len(Catalog()) == 0 {
|
||||
t.Fatal("empty catalog")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// A synthetic log with the shape a real one has: a spread of bands, modes,
|
||||
// entities, and the free-text fields the harder awards actually scan.
|
||||
func benchLog(n int) []qso.QSO {
|
||||
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
|
||||
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
|
||||
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
|
||||
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
|
||||
out := make([]qso.QSO, n)
|
||||
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range out {
|
||||
e := entities[i%len(entities)]
|
||||
out[i] = qso.QSO{
|
||||
ID: int64(i + 1),
|
||||
Callsign: fmt.Sprintf("I%dABC", i%10),
|
||||
Band: bands[i%len(bands)],
|
||||
Mode: modes[i%len(modes)],
|
||||
DXCC: &e,
|
||||
QSODate: base.Add(time.Duration(i) * time.Hour),
|
||||
QTH: towns[i%len(towns)],
|
||||
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
|
||||
Grid: "JN45AA",
|
||||
State: "CA",
|
||||
LOTWRcvd: "Y",
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
|
||||
// that always produces a doomed candidate, then a fallback chain that tokenises a
|
||||
// free-text field. If anything is slow, it is this.
|
||||
func benchWAIP() (Def, []RefMeta) {
|
||||
d := Def{
|
||||
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||
OrRules: []OrRule{
|
||||
{Field: "qth", MatchBy: "description"},
|
||||
{Field: "qth", MatchBy: "code"},
|
||||
{Field: "address", MatchBy: "code"},
|
||||
},
|
||||
DXCCFilter: []int{248},
|
||||
Confirm: []string{"lotw", "qsl"},
|
||||
}
|
||||
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
|
||||
metas := make([]RefMeta, 0, len(codes))
|
||||
for _, c := range codes {
|
||||
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
|
||||
}
|
||||
return d, metas
|
||||
}
|
||||
|
||||
// One award, whole log — what GetAward(code) does when the Awards panel opens.
|
||||
func BenchmarkComputeOneAward(b *testing.B) {
|
||||
d, metas := benchWAIP()
|
||||
for _, n := range []int{10_000, 50_000, 100_000} {
|
||||
log := benchLog(n)
|
||||
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
|
||||
func BenchmarkComputeWholeCatalog(b *testing.B) {
|
||||
defs := Defaults()
|
||||
metas := map[string][]RefMeta{}
|
||||
wd, wm := benchWAIP()
|
||||
defs = append(defs, wd)
|
||||
metas["WAIP"] = wm
|
||||
for _, c := range Catalog() {
|
||||
if raw, ok := CatalogRefs(c.Def.Code); ok {
|
||||
var refs []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
if json.Unmarshal(raw, &refs) == nil {
|
||||
m := make([]RefMeta, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
|
||||
}
|
||||
metas[c.Def.Code] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []int{10_000, 100_000} {
|
||||
log := benchLog(n)
|
||||
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Compute(defs, log, metas, nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "DDFM",
|
||||
"name": "Départements Français Métropolitains",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "note",
|
||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||
"dxcc_filter": [
|
||||
227
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 96,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "DXCC",
|
||||
"name": "DX Century Club",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "DXCC",
|
||||
"field": "dxcc",
|
||||
"pattern": "",
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 340,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "IOTA",
|
||||
"name": "Islands On The Air",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "REFERENCE",
|
||||
"field": "iota",
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "POTA",
|
||||
"name": "Parks On The Air",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "REFERENCE",
|
||||
"field": "pota_ref",
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T17:00:44Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "RAC",
|
||||
"name": "RAC Canadian Provinces",
|
||||
"description": "RAC Canadian Provinces",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"url": "https://www.rac.ca/canadaward/",
|
||||
"valid_from": "1977-01-07",
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "state",
|
||||
"match_by": "code",
|
||||
"pattern": "",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "description"
|
||||
},
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "description"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
1
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "AB",
|
||||
"name": "Alberta",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BC",
|
||||
"name": "British Columbia",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MB",
|
||||
"name": "Manitoba",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NB",
|
||||
"name": "New Brunswick",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NL",
|
||||
"name": "Newfoundland and Labrador",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NS",
|
||||
"name": "Nova Scotia",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NT",
|
||||
"name": "Northwest Territories",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NU",
|
||||
"name": "Nunavut",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "ON",
|
||||
"name": "Ontario",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PE",
|
||||
"name": "Prince Edward Island",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "QC",
|
||||
"name": "Quebec",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SK",
|
||||
"name": "Saskatchewan",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "YT",
|
||||
"name": "Yukon",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "SOTA",
|
||||
"name": "Summits On The Air",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "REFERENCE",
|
||||
"field": "sota_ref",
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T17:00:44Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "VUCC",
|
||||
"name": "VHF/UHF Century Club",
|
||||
"description": "VHF/UHF Century Club",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||
"valid_from": "1970-01-01",
|
||||
"valid_to": "9999-01-30",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "grid4",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"valid_bands": [
|
||||
"6m",
|
||||
"4m",
|
||||
"2m",
|
||||
"70cm",
|
||||
"23cm",
|
||||
"13cm",
|
||||
"1.25m"
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "WAC",
|
||||
"name": "Worked All Continents",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "cont",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl",
|
||||
"eqsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 6,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T21:57:23Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "WAIP",
|
||||
"name": "ARI Worked All Italian Provinces",
|
||||
"valid": true,
|
||||
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||
"type": "REFERENCE",
|
||||
"field": "qth",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "description"
|
||||
},
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "code"
|
||||
},
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "description"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
248
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": false
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "AG",
|
||||
"name": "Agrigento",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AL",
|
||||
"name": "Alessandria",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AN",
|
||||
"name": "Ancona",
|
||||
"dxcc": 248,
|
||||
"group": "Marche",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AO",
|
||||
"name": "Aosta",
|
||||
"dxcc": 248,
|
||||
"group": "Val d'Aosta",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AP",
|
||||
"name": "Ascoli Piceno",
|
||||
"dxcc": 248,
|
||||
"group": "Marche",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AQ",
|
||||
"name": "L'Aquila",
|
||||
"dxcc": 248,
|
||||
"group": "Abruzzo",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AR",
|
||||
"name": "Arezzo",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AT",
|
||||
"name": "Asti",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "AV",
|
||||
"name": "Avellino",
|
||||
"dxcc": 248,
|
||||
"group": "Campania",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BA",
|
||||
"name": "Bari",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BG",
|
||||
"name": "Bergamo",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BI",
|
||||
"name": "Biella",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BL",
|
||||
"name": "Belluno",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BN",
|
||||
"name": "Benevento",
|
||||
"dxcc": 248,
|
||||
"group": "Campania",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BO",
|
||||
"name": "Bologna",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BR",
|
||||
"name": "Brindisi",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BS",
|
||||
"name": "Brescia",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BT",
|
||||
"name": "Barletta-Andria-Trani",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BZ",
|
||||
"name": "Bolzano/Bozen",
|
||||
"dxcc": 248,
|
||||
"group": "Trentino-AltoAdige/Südtirol",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CA",
|
||||
"name": "Cagliari",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CB",
|
||||
"name": "Campobasso",
|
||||
"dxcc": 248,
|
||||
"group": "Molise",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CE",
|
||||
"name": "Caserta",
|
||||
"dxcc": 248,
|
||||
"group": "Campania",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CH",
|
||||
"name": "Chieti",
|
||||
"dxcc": 248,
|
||||
"group": "Abruzzo",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CI",
|
||||
"name": "Carbonia-Iglesias",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CL",
|
||||
"name": "Caltanissetta",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CN",
|
||||
"name": "Cuneo",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CO",
|
||||
"name": "Como",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CR",
|
||||
"name": "Cremona",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CS",
|
||||
"name": "Cosenza",
|
||||
"dxcc": 248,
|
||||
"group": "Calabria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CT",
|
||||
"name": "Catania",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CZ",
|
||||
"name": "Catanzaro",
|
||||
"dxcc": 248,
|
||||
"group": "Calabria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "EN",
|
||||
"name": "Enna",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FC",
|
||||
"name": "Forlì-Cesena",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FE",
|
||||
"name": "Ferrara",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FG",
|
||||
"name": "Foggia",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FI",
|
||||
"name": "Firenze",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FM",
|
||||
"name": "Fermo",
|
||||
"dxcc": 248,
|
||||
"group": "Marche",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FR",
|
||||
"name": "Frosinone",
|
||||
"dxcc": 248,
|
||||
"group": "Lazio",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GE",
|
||||
"name": "Genova",
|
||||
"dxcc": 248,
|
||||
"group": "Liguria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GO",
|
||||
"name": "Gorizia",
|
||||
"dxcc": 248,
|
||||
"group": "Friuli-Venezia Giulia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GR",
|
||||
"name": "Grosseto",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "IM",
|
||||
"name": "Imperia",
|
||||
"dxcc": 248,
|
||||
"group": "Liguria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "IS",
|
||||
"name": "Isernia",
|
||||
"dxcc": 248,
|
||||
"group": "Molise",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "KR",
|
||||
"name": "Crotone",
|
||||
"dxcc": 248,
|
||||
"group": "Calabria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LC",
|
||||
"name": "Lecco",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LE",
|
||||
"name": "Lecce",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LI",
|
||||
"name": "Livorno",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LO",
|
||||
"name": "Lodi",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LT",
|
||||
"name": "Latina",
|
||||
"dxcc": 248,
|
||||
"group": "Lazio",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LU",
|
||||
"name": "Lucca",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MB",
|
||||
"name": "Monza e Brianza",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MC",
|
||||
"name": "Macerata",
|
||||
"dxcc": 248,
|
||||
"group": "Marche",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "ME",
|
||||
"name": "Messina",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MI",
|
||||
"name": "Milano",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MN",
|
||||
"name": "Mantova",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MO",
|
||||
"name": "Modena",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MS",
|
||||
"name": "Massa-Carrara",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MT",
|
||||
"name": "Matera",
|
||||
"dxcc": 248,
|
||||
"group": "Basilicata",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NA",
|
||||
"name": "Napoli",
|
||||
"dxcc": 248,
|
||||
"group": "Campania",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NO",
|
||||
"name": "Novara",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NU",
|
||||
"name": "Nuoro",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "OG",
|
||||
"name": "Ogliastra",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "OR",
|
||||
"name": "Oristano",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "OT",
|
||||
"name": "Olbia-Tempio",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PA",
|
||||
"name": "Palermo",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PC",
|
||||
"name": "Piacenza",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PD",
|
||||
"name": "Padova",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PE",
|
||||
"name": "Pescara",
|
||||
"dxcc": 248,
|
||||
"group": "Abruzzo",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PG",
|
||||
"name": "Perugia",
|
||||
"dxcc": 248,
|
||||
"group": "Umbria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PI",
|
||||
"name": "Pisa",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PN",
|
||||
"name": "Pordenone",
|
||||
"dxcc": 248,
|
||||
"group": "Friuli-Venezia Giulia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PO",
|
||||
"name": "Prato",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PR",
|
||||
"name": "Parma",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PT",
|
||||
"name": "Pistoia",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PU",
|
||||
"name": "Pesaro-Urbino",
|
||||
"dxcc": 248,
|
||||
"group": "Marche",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PV",
|
||||
"name": "Pavia",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PZ",
|
||||
"name": "Potenza",
|
||||
"dxcc": 248,
|
||||
"group": "Basilicata",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RA",
|
||||
"name": "Ravenna",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RC",
|
||||
"name": "Reggio Calabria",
|
||||
"dxcc": 248,
|
||||
"group": "Calabria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RE",
|
||||
"name": "Reggio Emilia",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RG",
|
||||
"name": "Ragusa",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RI",
|
||||
"name": "Rieti",
|
||||
"dxcc": 248,
|
||||
"group": "Lazio",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RM",
|
||||
"name": "Roma",
|
||||
"dxcc": 248,
|
||||
"group": "Lazio",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RN",
|
||||
"name": "Rimini",
|
||||
"dxcc": 248,
|
||||
"group": "Emilia-Romagna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "RO",
|
||||
"name": "Rovigo",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SA",
|
||||
"name": "Salerno",
|
||||
"dxcc": 248,
|
||||
"group": "Campania",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SI",
|
||||
"name": "Siena",
|
||||
"dxcc": 248,
|
||||
"group": "Toscana",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SO",
|
||||
"name": "Sondrio",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SP",
|
||||
"name": "La Spezia",
|
||||
"dxcc": 248,
|
||||
"group": "Liguria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SR",
|
||||
"name": "Siracusa",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SS",
|
||||
"name": "Sassari",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SV",
|
||||
"name": "Savona",
|
||||
"dxcc": 248,
|
||||
"group": "Liguria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TA",
|
||||
"name": "Taranto",
|
||||
"dxcc": 248,
|
||||
"group": "Puglia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TE",
|
||||
"name": "Teramo",
|
||||
"dxcc": 248,
|
||||
"group": "Abruzzo",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TN",
|
||||
"name": "Trento",
|
||||
"dxcc": 248,
|
||||
"group": "Trentino-AltoAdige/Südtirol",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TO",
|
||||
"name": "Torino",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TP",
|
||||
"name": "Trapani",
|
||||
"dxcc": 248,
|
||||
"group": "Sicilia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TR",
|
||||
"name": "Terni",
|
||||
"dxcc": 248,
|
||||
"group": "Umbria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TS",
|
||||
"name": "Trieste",
|
||||
"dxcc": 248,
|
||||
"group": "Friuli-Venezia Giulia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TV",
|
||||
"name": "Treviso",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "UD",
|
||||
"name": "Udine",
|
||||
"dxcc": 248,
|
||||
"group": "Friuli-Venezia Giulia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VA",
|
||||
"name": "Varese",
|
||||
"dxcc": 248,
|
||||
"group": "Lombardia",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VB",
|
||||
"name": "Verbano-Cusio-Ossola",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VC",
|
||||
"name": "Vercelli",
|
||||
"dxcc": 248,
|
||||
"group": "Piemonte",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VE",
|
||||
"name": "Venezia",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VI",
|
||||
"name": "Vicenza",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VR",
|
||||
"name": "Verona",
|
||||
"dxcc": 248,
|
||||
"group": "Veneto",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VS",
|
||||
"name": "Medio Campidano",
|
||||
"dxcc": 248,
|
||||
"group": "Sardegna",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VT",
|
||||
"name": "Viterbo",
|
||||
"dxcc": 248,
|
||||
"group": "Lazio",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "VV",
|
||||
"name": "Vibo Valentia",
|
||||
"dxcc": 248,
|
||||
"group": "Calabria",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T15:44:38Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "WAJA",
|
||||
"name": "Worked All Japanese Prefectures",
|
||||
"description": "Worked All Japanese Prefectures",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"valid_from": "1970-01-01",
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "qth",
|
||||
"match_by": "description",
|
||||
"pattern": "",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "pattern"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
339
|
||||
],
|
||||
"valid_bands": [
|
||||
"80m",
|
||||
"40m",
|
||||
"20m",
|
||||
"15m",
|
||||
"10m",
|
||||
"160m"
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "1",
|
||||
"name": "Hokkaido",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "10",
|
||||
"name": "Gunma",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "11",
|
||||
"name": "Saitama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "12",
|
||||
"name": "Chiba",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "13",
|
||||
"name": "Tokyo",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\bTok[iy]o\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "14",
|
||||
"name": "Kanagawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "15",
|
||||
"name": "Niigata",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "16",
|
||||
"name": "Toyama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "17",
|
||||
"name": "Ishikawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "18",
|
||||
"name": "Fukui",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "19",
|
||||
"name": "Yamanashi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "2",
|
||||
"name": "Aomori",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "20",
|
||||
"name": "Nagano",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "21",
|
||||
"name": "Gifu",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "22",
|
||||
"name": "Shizuoka",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "23",
|
||||
"name": "Aichi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "24",
|
||||
"name": "Mie",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "25",
|
||||
"name": "Shiga",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "26",
|
||||
"name": "Kyoto",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "27",
|
||||
"name": "Osaka",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "28",
|
||||
"name": "Hyogo",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "29",
|
||||
"name": "Nara",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "3",
|
||||
"name": "Iwate",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "30",
|
||||
"name": "Wakayama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "31",
|
||||
"name": "Tottori",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "32",
|
||||
"name": "Shimane",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "33",
|
||||
"name": "Okayama",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "34",
|
||||
"name": "Hiroshima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "35",
|
||||
"name": "Yamaguchi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "36",
|
||||
"name": "Tokushima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "37",
|
||||
"name": "Kagawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "38",
|
||||
"name": "Ehime",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "39",
|
||||
"name": "Kochi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "4",
|
||||
"name": "Miyagi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "40",
|
||||
"name": "Fukuoka",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "41",
|
||||
"name": "Saga",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "42",
|
||||
"name": "Nagasaki",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "43",
|
||||
"name": "Kumamoto",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "44",
|
||||
"name": "Oita",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "45",
|
||||
"name": "Miyazaki",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "46",
|
||||
"name": "Kagoshima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "47",
|
||||
"name": "Okinawa",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "5",
|
||||
"name": "Akita",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "6",
|
||||
"name": "Yamagata",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "7",
|
||||
"name": "Fukushima",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "8",
|
||||
"name": "Ibaraki",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "9",
|
||||
"name": "Tochigi",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T15:43:49Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "WAPC",
|
||||
"name": "Worked All Provinces of China",
|
||||
"description": "Worked All Provinces of China",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"url": "http://www.mulandxc.com/index/wapc_medal_app",
|
||||
"valid_from": "2012-04-21",
|
||||
"valid_to": "9999-01-31",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "address",
|
||||
"match_by": "description",
|
||||
"pattern": "",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "description"
|
||||
},
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "pattern"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
318,
|
||||
152,
|
||||
321
|
||||
],
|
||||
"valid_bands": [
|
||||
"80m",
|
||||
"40m",
|
||||
"20m",
|
||||
"15m",
|
||||
"10m"
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "AH",
|
||||
"name": "Anhui",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BJ",
|
||||
"name": "Beijing",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "CQ",
|
||||
"name": "Chongqing",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "FJ",
|
||||
"name": "Fujian",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GD",
|
||||
"name": "Guangdong",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GS",
|
||||
"name": "Gansu",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GX",
|
||||
"name": "Guangxi",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "GZ",
|
||||
"name": "Guizhou",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HA",
|
||||
"name": "Henan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HB",
|
||||
"name": "Hubei",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HE",
|
||||
"name": "Hebei",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HI",
|
||||
"name": "Hainan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HK",
|
||||
"name": "Hong Kong",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HL",
|
||||
"name": "Heilongjiang",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "HN",
|
||||
"name": "Hunan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "JL",
|
||||
"name": "Jilin",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "JS",
|
||||
"name": "Jiangsu",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"pattern": "\\bJiangyin\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "JX",
|
||||
"name": "Jiangxi",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "LN",
|
||||
"name": "Liaoning",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MO",
|
||||
"name": "Macau",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"pattern": "\\bMaca[uo]\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NM",
|
||||
"name": "NeiMongol",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NX",
|
||||
"name": "Ningxia",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "QH",
|
||||
"name": "Qinghai",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SC",
|
||||
"name": "Sichuan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SD",
|
||||
"name": "Shandong",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SH",
|
||||
"name": "Shanghai",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SN",
|
||||
"name": "Shaanxi",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SX",
|
||||
"name": "Shanxi",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TJ",
|
||||
"name": "Tianjin",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "TW",
|
||||
"name": "Taiwan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "XJ",
|
||||
"name": "Xinjiang",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "XZ",
|
||||
"name": "Xizang",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "YN",
|
||||
"name": "Yunnan",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "ZJ",
|
||||
"name": "Zhejiang",
|
||||
"dxcc": 318,
|
||||
"group": "China Provinces",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "WAS",
|
||||
"name": "Worked All States",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "state",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"dxcc_filter": [
|
||||
291,
|
||||
110,
|
||||
6
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 50,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "WAZ",
|
||||
"name": "Worked All Zones (CQ)",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "cqz",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 40,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "WPX",
|
||||
"name": "Worked All Prefixes (CQ WPX)",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "prefix",
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "WWFF",
|
||||
"name": "World Wide Flora & Fauna",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "REFERENCE",
|
||||
"field": "wwff",
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package award
|
||||
|
||||
import "strings"
|
||||
|
||||
// WPXPrefix is the exported entry point for deriving a callsign's CQ WPX prefix
|
||||
// (used by contest-mode multiplier counting).
|
||||
func WPXPrefix(call string) string { return wpxPrefix(call) }
|
||||
|
||||
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
|
||||
// of the official WPX rules — good enough to count distinct prefixes worked:
|
||||
// - standard call: letters+digits up to and including the LAST digit of the
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Package cabrillo writes a Cabrillo v3 contest log from OpsLog QSOs — the
|
||||
// format most contest sponsors require for log submission (a header of
|
||||
// CATEGORY-* / CONTEST / CALLSIGN tags followed by one "QSO:" line each).
|
||||
package cabrillo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Header holds the Cabrillo header fields. Only Callsign and Contest really
|
||||
// matter; the rest carry sensible defaults the operator can edit in the file
|
||||
// before submitting (categories vary per contest).
|
||||
type Header struct {
|
||||
Callsign string
|
||||
Contest string
|
||||
Operators string
|
||||
CatOperator string // SINGLE-OP | MULTI-OP | CHECKLOG
|
||||
CatBand string // ALL | 20M | …
|
||||
CatMode string // CW | SSB | MIXED | …
|
||||
CatPower string // HIGH | LOW | QRP
|
||||
GridLocator string
|
||||
Name string
|
||||
Email string
|
||||
Club string
|
||||
}
|
||||
|
||||
func def(v, d string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Generate writes the Cabrillo document for qsos and returns the number of QSO
|
||||
// lines written (rows without a usable frequency/callsign are skipped).
|
||||
func Generate(w io.Writer, h Header, qsos []qso.QSO) (int, error) {
|
||||
myCall := strings.ToUpper(strings.TrimSpace(h.Callsign))
|
||||
fmt.Fprintln(w, "START-OF-LOG: 3.0")
|
||||
fmt.Fprintln(w, "CREATED-BY: OpsLog")
|
||||
fmt.Fprintf(w, "CONTEST: %s\n", strings.ToUpper(def(h.Contest, "UNKNOWN")))
|
||||
fmt.Fprintf(w, "CALLSIGN: %s\n", myCall)
|
||||
fmt.Fprintf(w, "CATEGORY-OPERATOR: %s\n", strings.ToUpper(def(h.CatOperator, "SINGLE-OP")))
|
||||
fmt.Fprintf(w, "CATEGORY-BAND: %s\n", strings.ToUpper(def(h.CatBand, "ALL")))
|
||||
fmt.Fprintf(w, "CATEGORY-MODE: %s\n", strings.ToUpper(def(h.CatMode, "MIXED")))
|
||||
fmt.Fprintf(w, "CATEGORY-POWER: %s\n", strings.ToUpper(def(h.CatPower, "HIGH")))
|
||||
fmt.Fprintln(w, "CLAIMED-SCORE: 0")
|
||||
fmt.Fprintf(w, "OPERATORS: %s\n", strings.ToUpper(def(h.Operators, myCall)))
|
||||
if h.GridLocator != "" {
|
||||
fmt.Fprintf(w, "GRID-LOCATOR: %s\n", strings.ToUpper(h.GridLocator))
|
||||
}
|
||||
if h.Name != "" {
|
||||
fmt.Fprintf(w, "NAME: %s\n", h.Name)
|
||||
}
|
||||
if h.Club != "" {
|
||||
fmt.Fprintf(w, "CLUB: %s\n", h.Club)
|
||||
}
|
||||
if h.Email != "" {
|
||||
fmt.Fprintf(w, "EMAIL: %s\n", h.Email)
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, q := range qsos {
|
||||
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||
if call == "" {
|
||||
continue
|
||||
}
|
||||
khz := freqKHz(q)
|
||||
mode := cabrilloMode(q.Mode)
|
||||
myRST := def(q.RSTSent, defaultRST(mode))
|
||||
hisRST := def(q.RSTRcvd, defaultRST(mode))
|
||||
myExch := exchange(q.STX, q.STXString)
|
||||
hisExch := exchange(q.SRX, q.SRXString)
|
||||
// QSO: <freq> <mo> <date> <time> <mycall> <myrst> <myexch> <call> <rst> <exch>
|
||||
fmt.Fprintf(w, "QSO: %5d %-2s %s %s %-13s %-3s %-6s %-13s %-3s %-6s\n",
|
||||
khz, mode,
|
||||
q.QSODate.UTC().Format("2006-01-02"), q.QSODate.UTC().Format("1504"),
|
||||
myCall, myRST, myExch, call, hisRST, hisExch)
|
||||
n++
|
||||
}
|
||||
fmt.Fprintln(w, "END-OF-LOG:")
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// freqKHz returns the QSO frequency in kHz, falling back to a nominal frequency
|
||||
// for the band when the exact frequency wasn't logged.
|
||||
func freqKHz(q qso.QSO) int {
|
||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||
return int((*q.FreqHz + 500) / 1000)
|
||||
}
|
||||
return bandNominalKHz(q.Band)
|
||||
}
|
||||
|
||||
func bandNominalKHz(band string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||
case "160m":
|
||||
return 1800
|
||||
case "80m":
|
||||
return 3500
|
||||
case "60m":
|
||||
return 5350
|
||||
case "40m":
|
||||
return 7000
|
||||
case "30m":
|
||||
return 10100
|
||||
case "20m":
|
||||
return 14000
|
||||
case "17m":
|
||||
return 18068
|
||||
case "15m":
|
||||
return 21000
|
||||
case "12m":
|
||||
return 24890
|
||||
case "10m":
|
||||
return 28000
|
||||
case "6m":
|
||||
return 50000
|
||||
case "2m":
|
||||
return 144000
|
||||
case "70cm":
|
||||
return 432000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cabrilloMode maps an ADIF mode to the two-letter Cabrillo mode code.
|
||||
func cabrilloMode(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "RTTY":
|
||||
return "RY"
|
||||
case "FM":
|
||||
return "FM"
|
||||
case "SSB", "USB", "LSB", "AM":
|
||||
return "PH"
|
||||
case "":
|
||||
return "PH"
|
||||
default:
|
||||
return "DG" // FT8/FT4/PSK/JT/DATA and other digital modes
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRST(cabMode string) string {
|
||||
if cabMode == "CW" || cabMode == "RY" || cabMode == "DG" {
|
||||
return "599"
|
||||
}
|
||||
return "59"
|
||||
}
|
||||
|
||||
// exchange renders the contest exchange: the string field if set, else the
|
||||
// numeric serial, else empty.
|
||||
func exchange(num *int, str string) string {
|
||||
if s := strings.TrimSpace(str); s != "" {
|
||||
return s
|
||||
}
|
||||
if num != nil && *num > 0 {
|
||||
return fmt.Sprintf("%d", *num)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+265
-28
@@ -32,6 +32,16 @@ type Backend interface {
|
||||
SetPTT(on bool) error
|
||||
}
|
||||
|
||||
// interruptible is an OPTIONAL backend capability: abort an in-progress Connect
|
||||
// quickly. The network Icom backend's Connect blocks for up to tens of seconds
|
||||
// (UDP handshake + login + waiting for the rig to boot from standby); without a
|
||||
// way to interrupt it, Stop()/Start() would freeze on the poll goroutine until
|
||||
// the dial gives up — which is why Settings "Save & Close" hung for ~1 min once
|
||||
// the link was lost. Backends that don't implement it are simply not interrupted.
|
||||
type interruptible interface {
|
||||
Interrupt()
|
||||
}
|
||||
|
||||
// RigState is the snapshot exchanged with the frontend.
|
||||
//
|
||||
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
||||
@@ -39,18 +49,18 @@ type Backend interface {
|
||||
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||
type RigState struct {
|
||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||
Connected bool `json:"connected"` // backend says rig is online
|
||||
Backend string `json:"backend,omitempty"` // active backend name
|
||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||
Connected bool `json:"connected"` // backend says rig is online
|
||||
Backend string `json:"backend,omitempty"` // active backend name
|
||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -156,6 +166,7 @@ func (m *Manager) stopLocked() {
|
||||
m.mu.Lock()
|
||||
stop := m.stopCh
|
||||
done := m.doneCh
|
||||
b := m.backend
|
||||
m.stopCh = nil
|
||||
m.doneCh = nil
|
||||
m.cmdCh = nil
|
||||
@@ -164,6 +175,11 @@ func (m *Manager) stopLocked() {
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
// Abort any in-progress Connect so we don't block on a slow network dial
|
||||
// (the poll goroutine can be tens of seconds deep in the Icom UDP handshake).
|
||||
if iv, ok := b.(interruptible); ok {
|
||||
iv.Interrupt()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
@@ -189,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error {
|
||||
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||
// driven later by setting Color per spot.)
|
||||
type SpotInfo struct {
|
||||
FreqHz int64
|
||||
Callsign string
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
FreqHz int64
|
||||
Callsign string
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||
}
|
||||
|
||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||
@@ -229,9 +246,24 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
||||
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||
// FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the
|
||||
// panel, so the operator sees every slice and which one is active/TX.
|
||||
type FlexSliceInfo struct {
|
||||
Index int `json:"index"` // 0-based slice index
|
||||
Letter string `json:"letter"` // A, B, C, D…
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Mode string `json:"mode,omitempty"` // ADIF mode
|
||||
Band string `json:"band,omitempty"`
|
||||
Active bool `json:"active"` // the focused/operating slice
|
||||
TX bool `json:"tx"` // this slice transmits
|
||||
}
|
||||
|
||||
type FlexTXState struct {
|
||||
Available bool `json:"available"` // backend is Flex and handshaked
|
||||
Available bool `json:"available"` // backend is Flex and handshaked
|
||||
Model string `json:"model,omitempty"`
|
||||
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
||||
// them all and highlight the active one. The active slice drives everything.
|
||||
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
||||
RFPower int `json:"rf_power"`
|
||||
TunePower int `json:"tune_power"`
|
||||
Tune bool `json:"tune"` // tune carrier active
|
||||
@@ -244,19 +276,41 @@ type FlexTXState struct {
|
||||
Mon bool `json:"mon"`
|
||||
MonLevel int `json:"mon_level"`
|
||||
MicLevel int `json:"mic_level"`
|
||||
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
||||
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
||||
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
||||
MicProfile string `json:"mic_profile,omitempty"`
|
||||
MicProfiles []string `json:"mic_profiles,omitempty"`
|
||||
ATUStatus string `json:"atu_status,omitempty"`
|
||||
ATUMemories bool `json:"atu_memories"`
|
||||
// Active RX slice DSP controls.
|
||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||
AGCMode string `json:"agc_mode,omitempty"`
|
||||
AGCThreshold int `json:"agc_threshold"`
|
||||
AudioLevel int `json:"audio_level"`
|
||||
NB bool `json:"nb"`
|
||||
NBLevel int `json:"nb_level"`
|
||||
NR bool `json:"nr"`
|
||||
NRLevel int `json:"nr_level"`
|
||||
ANF bool `json:"anf"`
|
||||
ANFLevel int `json:"anf_level"`
|
||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||
Split bool `json:"split"` // RX/TX on separate slices
|
||||
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
|
||||
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
|
||||
AGCMode string `json:"agc_mode,omitempty"`
|
||||
AGCThreshold int `json:"agc_threshold"`
|
||||
AudioLevel int `json:"audio_level"`
|
||||
Mute bool `json:"mute"`
|
||||
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
|
||||
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
|
||||
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
|
||||
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
|
||||
NB bool `json:"nb"`
|
||||
NBLevel int `json:"nb_level"`
|
||||
NR bool `json:"nr"`
|
||||
NRLevel int `json:"nr_level"`
|
||||
ANF bool `json:"anf"`
|
||||
ANFLevel int `json:"anf_level"`
|
||||
WNB bool `json:"wnb"`
|
||||
WNBLevel int `json:"wnb_level"`
|
||||
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||
// moving the slice. The offset survives the switch being turned off, so
|
||||
// re-enabling restores it, exactly like the radio's own knob.
|
||||
RIT bool `json:"rit"`
|
||||
RITFreq int `json:"rit_freq"`
|
||||
XIT bool `json:"xit"`
|
||||
XITFreq int `json:"xit_freq"`
|
||||
// CW / mode-specific controls.
|
||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||
CWSpeed int `json:"cw_speed"`
|
||||
@@ -283,6 +337,7 @@ type FlexMeter struct {
|
||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||
Value float64 `json:"value"`
|
||||
Lo float64 `json:"lo"`
|
||||
Hi float64 `json:"hi"`
|
||||
@@ -305,6 +360,8 @@ type FlexController interface {
|
||||
SetMon(bool) error
|
||||
SetMonLevel(int) error
|
||||
SetMic(int) error
|
||||
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
|
||||
SetMicProfile(string) error // load a SmartSDR mic profile by name
|
||||
ATUStart() error
|
||||
ATUBypass() error
|
||||
SetATUMemories(bool) error
|
||||
@@ -312,6 +369,12 @@ type FlexController interface {
|
||||
SetAGCMode(string) error
|
||||
SetAGCThreshold(int) error
|
||||
SetAudioLevel(int) error
|
||||
SetMute(bool) error
|
||||
SetRXAntenna(string) error
|
||||
SetTXAntenna(string) error
|
||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||
SetSplit(bool) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
SetNR(bool) error
|
||||
@@ -320,6 +383,12 @@ type FlexController interface {
|
||||
SetANFLevel(int) error
|
||||
SetAPF(bool) error
|
||||
SetAPFLevel(int) error
|
||||
SetWNB(bool) error
|
||||
SetWNBLevel(int) error
|
||||
SetRIT(bool) error
|
||||
SetRITFreq(int) error
|
||||
SetXIT(bool) error
|
||||
SetXITFreq(int) error
|
||||
// CW keyer + mode-specific controls.
|
||||
SetCWSpeed(int) error
|
||||
SetCWPitch(int) error
|
||||
@@ -327,6 +396,7 @@ type FlexController interface {
|
||||
SetCWSidetone(bool) error
|
||||
SetSidetoneLevel(int) error
|
||||
SetCWFilter(int) error
|
||||
SetFilter(lo, hi int) error
|
||||
// External amplifier (PowerGenius XL) operate/standby.
|
||||
SetAmpOperate(bool) error
|
||||
}
|
||||
@@ -355,6 +425,158 @@ func (m *Manager) FlexDo(fn func(FlexController) error) error {
|
||||
})
|
||||
}
|
||||
|
||||
// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
|
||||
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
|
||||
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
|
||||
// plus the optimistic updates each setter applies.
|
||||
type IcomTXState struct {
|
||||
Available bool `json:"available"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Transmit + live status (polled).
|
||||
Transmitting bool `json:"transmitting"`
|
||||
Split bool `json:"split"`
|
||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
||||
// RIT / ΔTX (XIT).
|
||||
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
|
||||
RITOn bool `json:"rit_on"`
|
||||
XITOn bool `json:"xit_on"`
|
||||
// CW keyer (send messages via the rig's internal keyer, CI-V 0x17).
|
||||
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
||||
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
// Set controls.
|
||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
AFGain int `json:"af_gain"`
|
||||
RFGain int `json:"rf_gain"`
|
||||
NB bool `json:"nb"`
|
||||
NBLevel int `json:"nb_level"`
|
||||
NR bool `json:"nr"`
|
||||
NRLevel int `json:"nr_level"`
|
||||
ANF bool `json:"anf"`
|
||||
APF bool `json:"apf"` // audio peak filter (CW only)
|
||||
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
Att int `json:"att"` // dB attenuation, 0=off
|
||||
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
||||
// Antenna (IC-7610 = ANT1/ANT2).
|
||||
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
|
||||
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
||||
PBTInner int `json:"pbt_inner"`
|
||||
PBTOuter int `json:"pbt_outer"`
|
||||
ManualNotch bool `json:"manual_notch"`
|
||||
NotchPos int `json:"notch_pos"`
|
||||
// TX extras.
|
||||
Squelch int `json:"squelch"`
|
||||
Comp bool `json:"comp"`
|
||||
CompLevel int `json:"comp_level"`
|
||||
Monitor bool `json:"monitor"`
|
||||
MonLevel int `json:"mon_level"`
|
||||
VOX bool `json:"vox"`
|
||||
VOXGain int `json:"vox_gain"`
|
||||
AntiVOX int `json:"anti_vox"`
|
||||
}
|
||||
|
||||
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
||||
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
|
||||
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
|
||||
type IcomController interface {
|
||||
IcomState() IcomTXState
|
||||
RefreshIcom() error // re-read all DSP state from the rig
|
||||
SetAFGain(int) error
|
||||
SetRFGain(int) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
SetNR(bool) error
|
||||
SetNRLevel(int) error
|
||||
SetANF(bool) error
|
||||
SetAPF(bool) error
|
||||
SetAGC(string) error
|
||||
SetPreamp(int) error
|
||||
SetAtt(int) error
|
||||
SetIcomFilter(int) error
|
||||
SetRFPower(int) error
|
||||
SetMicGain(int) error
|
||||
SetIcomSplit(bool) error
|
||||
TuneATU() error
|
||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||
SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
|
||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||
SetRITOn(bool) error // RIT on/off
|
||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
||||
StopCW() error // abort the CW message being sent
|
||||
SetKeySpeed(int) error // CW keyer speed in WPM
|
||||
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
||||
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
||||
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
||||
SetManualNotch(bool) error
|
||||
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
||||
SetSquelch(int) error
|
||||
SetComp(bool) error
|
||||
SetCompLevel(int) error
|
||||
SetMonitor(bool) error
|
||||
SetMonLevel(int) error
|
||||
SetVOX(bool) error
|
||||
SetVOXGain(int) error
|
||||
SetAntiVOX(int) error
|
||||
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
// divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig
|
||||
// scale, typically 0-160). Seq increments on every completed sweep so the UI can
|
||||
// tell fresh data from a repeated poll.
|
||||
type ScopeSweep struct {
|
||||
Amp []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array
|
||||
Seq int `json:"seq"`
|
||||
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
|
||||
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
|
||||
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
|
||||
}
|
||||
|
||||
// IcomState returns the current Icom DSP state, or (zero, false) when the active
|
||||
// backend isn't an Icom. Safe to call from any goroutine.
|
||||
func (m *Manager) IcomState() (IcomTXState, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if ic, ok := b.(IcomController); ok {
|
||||
return ic.IcomState(), true
|
||||
}
|
||||
return IcomTXState{}, false
|
||||
}
|
||||
|
||||
// IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the
|
||||
// active backend isn't an Icom. The sweep is mutex-guarded in the backend, so
|
||||
// this reads it directly (no CAT-goroutine round trip) — cheap enough to poll.
|
||||
func (m *Manager) IcomScope() (ScopeSweep, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if ic, ok := b.(IcomController); ok {
|
||||
return ic.ScopeData(), true
|
||||
}
|
||||
return ScopeSweep{}, false
|
||||
}
|
||||
|
||||
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
|
||||
// active backend isn't an Icom.
|
||||
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
ic, ok := b.(IcomController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not an Icom")
|
||||
}
|
||||
return fn(ic)
|
||||
})
|
||||
}
|
||||
|
||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||
func (m *Manager) exec(fn func(Backend) error) error {
|
||||
@@ -413,6 +635,21 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
case <-ticker.C:
|
||||
// Drain any queued commands before polling. A serial backend reads
|
||||
// many registers per ReadState, so without this the shared select's
|
||||
// fairness lets polls repeatedly win and a user's Set* can lag by
|
||||
// seconds. Servicing commands first bounds that latency to a single
|
||||
// ReadState.
|
||||
for {
|
||||
select {
|
||||
case fn := <-cmds:
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
continue
|
||||
default:
|
||||
}
|
||||
break
|
||||
}
|
||||
if !connected {
|
||||
tryConnect()
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// Package civ implements the Icom CI-V protocol independently of the transport
|
||||
// carrying it. The exact same frames travel over a USB/serial port (local
|
||||
// control) and, wrapped in Icom's UDP "serial" stream, over the network
|
||||
// (remote control). Keeping the wire format in one place means the USB backend
|
||||
// (icomserial) and a future network backend (icomnet) share all of it — only
|
||||
// the transport differs.
|
||||
//
|
||||
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
|
||||
package civ
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Protocol bytes.
|
||||
const (
|
||||
Pre = 0xFE // preamble (sent twice at the start of every frame)
|
||||
End = 0xFD // end-of-message
|
||||
OK = 0xFB // rig acknowledged a set command
|
||||
NG = 0xFA // rig rejected a set command
|
||||
|
||||
// AddrController is the conventional address software uses for itself.
|
||||
AddrController = 0xE0
|
||||
)
|
||||
|
||||
// Commands (the few Phase-1 control needs; more get added with the panel).
|
||||
const (
|
||||
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
|
||||
CmdTransceiveMode = 0x01 // unsolicited mode update
|
||||
CmdReadFreq = 0x03
|
||||
CmdReadMode = 0x04
|
||||
CmdSetFreq = 0x05
|
||||
CmdSetMode = 0x06
|
||||
CmdSplit = 0x0F // read/set split & duplex (0x00 off, 0x01 split on)
|
||||
CmdVfoFreq = 0x25 // read a specific VFO's freq (sub 0x00 selected, 0x01 unselected)
|
||||
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
|
||||
|
||||
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
|
||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||
|
||||
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||
|
||||
// CW keyer speed range for the KEY SPEED level (IC-7610: 6-48 WPM).
|
||||
KeyMinWPM = 6
|
||||
KeyMaxWPM = 48
|
||||
|
||||
StopCWByte = 0xFF // 0x17 data byte that stops an in-progress CW message
|
||||
|
||||
SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
|
||||
SubRITOn = 0x01 // RIT on/off (00/01)
|
||||
SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
|
||||
|
||||
SubDataMode = 0x06
|
||||
SubPTT = 0x00
|
||||
SubVfoSelected = 0x00 // CmdVfoFreq: the active/RX VFO
|
||||
SubVfoUnselected = 0x01 // CmdVfoFreq: the other VFO (TX in split)
|
||||
|
||||
// CmdLevel sub-commands.
|
||||
SubLevelAF = 0x01 // AF (volume)
|
||||
SubLevelRF = 0x02 // RF gain
|
||||
SubLevelSQL = 0x03 // squelch level
|
||||
SubLevelPBTIn = 0x07 // Twin PBT (inside) — 0-255, 128 = centre
|
||||
SubLevelPBTOut = 0x08 // Twin PBT (outside) — 0-255, 128 = centre
|
||||
SubLevelNR = 0x06 // noise-reduction depth
|
||||
SubLevelNotch = 0x0D // manual-notch position — 0-255, 128 = centre
|
||||
SubLevelComp = 0x0E // speech-compressor level
|
||||
SubLevelNB = 0x12 // noise-blanker depth
|
||||
SubLevelMon = 0x15 // monitor gain
|
||||
SubLevelVOXGain = 0x16 // VOX gain
|
||||
SubLevelAntiVOX = 0x17 // anti-VOX level
|
||||
SubLevelRFPower = 0x0A // TX RF output power
|
||||
SubLevelMic = 0x0B // mic gain
|
||||
|
||||
// CmdMeter sub-commands.
|
||||
SubMeterS = 0x02 // S-meter (RX)
|
||||
SubMeterPo = 0x11 // power output (TX)
|
||||
SubMeterSWR = 0x12 // SWR (TX)
|
||||
|
||||
// CmdATU / CmdPTT sub-commands.
|
||||
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
|
||||
|
||||
// CmdScope sub-commands.
|
||||
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
||||
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
||||
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
||||
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
|
||||
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
|
||||
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
|
||||
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
|
||||
|
||||
// CmdSwitch sub-commands.
|
||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||
SubSwNB = 0x22 // noise blanker on/off
|
||||
SubSwNR = 0x40 // noise reduction on/off
|
||||
SubSwANF = 0x41 // auto-notch on/off
|
||||
SubSwComp = 0x44 // speech compressor on/off
|
||||
SubSwMon = 0x45 // monitor on/off
|
||||
SubSwVOX = 0x46 // VOX on/off
|
||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||
SubSwMN = 0x48 // manual notch on/off
|
||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||
)
|
||||
|
||||
// CW break-in modes (CmdSwitch 0x47).
|
||||
const (
|
||||
BreakInOff = 0
|
||||
BreakInSemi = 1
|
||||
BreakInFull = 2
|
||||
)
|
||||
|
||||
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||
const (
|
||||
ModeLSB = 0x00
|
||||
ModeUSB = 0x01
|
||||
ModeAM = 0x02
|
||||
ModeCW = 0x03
|
||||
ModeRTTY = 0x04
|
||||
ModeFM = 0x05
|
||||
ModeCWR = 0x07
|
||||
ModeRTTYR = 0x08
|
||||
)
|
||||
|
||||
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
|
||||
// command byte followed by any sub-command/data bytes.
|
||||
func Frame(to, from byte, payload ...byte) []byte {
|
||||
f := make([]byte, 0, len(payload)+5)
|
||||
f = append(f, Pre, Pre, to, from)
|
||||
f = append(f, payload...)
|
||||
f = append(f, End)
|
||||
return f
|
||||
}
|
||||
|
||||
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
|
||||
// expects (10 digits, 2 per byte, least-significant byte first).
|
||||
func FreqToBCD(hz int64) []byte {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
b := make([]byte, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
lo := hz % 10
|
||||
hz /= 10
|
||||
hi := hz % 10
|
||||
hz /= 10
|
||||
b[i] = byte(lo) | byte(hi)<<4
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||
func BCDToFreq(b []byte) int64 {
|
||||
var hz int64
|
||||
mult := int64(1)
|
||||
for i := 0; i < len(b) && i < 5; i++ {
|
||||
hz += int64(b[i]&0x0F) * mult
|
||||
mult *= 10
|
||||
hz += int64(b[i]>>4) * mult
|
||||
mult *= 10
|
||||
}
|
||||
return hz
|
||||
}
|
||||
|
||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
|
||||
func LevelToBCD(v int) []byte {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 255 {
|
||||
v = 255
|
||||
}
|
||||
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
|
||||
}
|
||||
|
||||
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
|
||||
func BCDToLevel(b []byte) int {
|
||||
if len(b) < 2 {
|
||||
return 0
|
||||
}
|
||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||
}
|
||||
|
||||
// RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V
|
||||
// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
|
||||
// a sign byte (0x00 positive, 0x01 negative).
|
||||
func RITToBCD(hz int) []byte {
|
||||
neg := hz < 0
|
||||
if neg {
|
||||
hz = -hz
|
||||
}
|
||||
if hz > 9999 {
|
||||
hz = 9999
|
||||
}
|
||||
lo := byte(hz%10 | (hz/10%10)<<4)
|
||||
hi := byte(hz/100%10 | (hz/1000%10)<<4)
|
||||
sign := byte(0)
|
||||
if neg {
|
||||
sign = 1
|
||||
}
|
||||
return []byte{lo, hi, sign}
|
||||
}
|
||||
|
||||
// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
|
||||
func BCDToRIT(b []byte) int {
|
||||
if len(b) < 3 {
|
||||
return 0
|
||||
}
|
||||
v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
|
||||
if b[2] != 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// WPMToKeyLevel maps a CW speed in words-per-minute to the 0-255 value the KEY
|
||||
// SPEED level (CmdLevel 0x0C) expects, linear across KeyMinWPM..KeyMaxWPM.
|
||||
func WPMToKeyLevel(wpm int) int {
|
||||
if wpm < KeyMinWPM {
|
||||
wpm = KeyMinWPM
|
||||
}
|
||||
if wpm > KeyMaxWPM {
|
||||
wpm = KeyMaxWPM
|
||||
}
|
||||
return (wpm - KeyMinWPM) * 255 / (KeyMaxWPM - KeyMinWPM)
|
||||
}
|
||||
|
||||
// KeyLevelToWPM is the inverse of WPMToKeyLevel (0-255 → WPM).
|
||||
func KeyLevelToWPM(v int) int {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 255 {
|
||||
v = 255
|
||||
}
|
||||
return KeyMinWPM + (v*(KeyMaxWPM-KeyMinWPM)+127)/255
|
||||
}
|
||||
|
||||
// CWText is the set of characters the rig's keyer accepts (command 0x17).
|
||||
// Everything else is dropped. Space keys a word gap.
|
||||
const CWText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+@:"
|
||||
|
||||
// FilterCW upper-cases text and keeps only keyer-legal characters.
|
||||
func FilterCW(text string) string {
|
||||
out := make([]byte, 0, len(text))
|
||||
for i := 0; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c -= 32
|
||||
}
|
||||
for j := 0; j < len(CWText); j++ {
|
||||
if CWText[j] == c {
|
||||
out = append(out, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||
func ByteToBCD(v int) byte {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 99 {
|
||||
v = 99
|
||||
}
|
||||
return byte((v/10)<<4 | v%10)
|
||||
}
|
||||
|
||||
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
|
||||
|
||||
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
|
||||
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
|
||||
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
|
||||
func ModeToADIF(m byte, data bool) string {
|
||||
switch m {
|
||||
case ModeCW, ModeCWR:
|
||||
return "CW"
|
||||
case ModeRTTY, ModeRTTYR:
|
||||
return "RTTY"
|
||||
case ModeAM:
|
||||
return "AM"
|
||||
case ModeFM:
|
||||
return "FM"
|
||||
case ModeLSB, ModeUSB:
|
||||
if data {
|
||||
return "DATA"
|
||||
}
|
||||
return "SSB"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||
// model. Unknown addresses fall back to a hex label.
|
||||
func ModelName(addr byte) string {
|
||||
switch addr {
|
||||
case 0x94:
|
||||
return "IC-7300"
|
||||
case 0x98:
|
||||
return "IC-7610"
|
||||
case 0x7C:
|
||||
return "IC-9100"
|
||||
case 0xA2:
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0x88:
|
||||
return "IC-7700"
|
||||
case 0x80:
|
||||
return "IC-7800"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
// Decoded is one parsed CI-V frame. Data is everything after the command byte
|
||||
// (so it still carries the sub-command for multi-byte commands like 1A 06).
|
||||
type Decoded struct {
|
||||
To byte
|
||||
From byte
|
||||
Cmd byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Scan extracts every complete frame from buf and reports how many leading
|
||||
// bytes the caller may now discard. A trailing partial frame (or a lone
|
||||
// preamble byte) is left unconsumed so it can be completed by the next read.
|
||||
func Scan(buf []byte) (frames []Decoded, consumed int) {
|
||||
pos := 0
|
||||
for {
|
||||
p := indexPreamble(buf, pos)
|
||||
if p < 0 {
|
||||
// No further preamble. Keep a trailing FE (possible start of the
|
||||
// next preamble); otherwise everything seen is consumable.
|
||||
if len(buf) > 0 && buf[len(buf)-1] == Pre {
|
||||
return frames, len(buf) - 1
|
||||
}
|
||||
return frames, len(buf)
|
||||
}
|
||||
start := p + 2
|
||||
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
|
||||
start++
|
||||
}
|
||||
end := bytes.IndexByte(buf[start:], End)
|
||||
if end < 0 {
|
||||
return frames, p // incomplete frame — keep from its preamble
|
||||
}
|
||||
end += start
|
||||
if body := buf[start:end]; len(body) >= 3 {
|
||||
frames = append(frames, Decoded{
|
||||
To: body[0],
|
||||
From: body[1],
|
||||
Cmd: body[2],
|
||||
Data: append([]byte(nil), body[3:]...),
|
||||
})
|
||||
}
|
||||
pos = end + 1
|
||||
consumed = pos
|
||||
}
|
||||
}
|
||||
|
||||
// indexPreamble returns the index of the next FE FE pair at or after from.
|
||||
func indexPreamble(buf []byte, from int) int {
|
||||
for i := from; i+1 < len(buf); i++ {
|
||||
if buf[i] == Pre && buf[i+1] == Pre {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user