Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
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 |
+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
|
# OpsLog
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
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,
|
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||||
|
Fully themeable and bilingual (English / French).
|
||||||
|
|
||||||
Developed by **F4BPO**.
|
Developed by **F4BPO**.
|
||||||
|
|
||||||
@@ -30,10 +33,16 @@ Developed by **F4BPO**.
|
|||||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
and the QRZ.com tab.
|
and the QRZ.com tab.
|
||||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
date overrides.
|
ClubLog DXpedition date overrides.
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
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
|
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
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 = two configurable panes** (per profile, Settings → General →
|
||||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
*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
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
@@ -58,15 +68,29 @@ Developed by **F4BPO**.
|
|||||||
pane, with a show/hide toggle.
|
pane, with a show/hide toggle.
|
||||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
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
|
## CAT control
|
||||||
|
|
||||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
non-blocking connect so a powered-off radio never freezes the app:
|
||||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
|
||||||
Flex display, click → fill the call).
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
from the frequency.
|
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)
|
### 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
|
- **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.
|
(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
|
## Keyers & audio
|
||||||
|
|
||||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||||
- **Digital Voice Keyer** (DVK) message playback.
|
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||||
audio in CW).
|
- **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
|
## QSL & awards
|
||||||
|
|
||||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
|
||||||
manual reference assignment, live reference detection on call entry, and a
|
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
|
||||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
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
|
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||||
and **confirmation download** (which auto-refreshes the award stats).
|
and **confirmation download** (which auto-refreshes the award stats).
|
||||||
- **QSL Card Designer** (see below).
|
- **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
|
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||||
password, or a Microsoft app password.)
|
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)
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
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
|
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.
|
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
|
## Other
|
||||||
|
|
||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
OpsLog startup, skipping any already running.
|
OpsLog startup, skipping any already running.
|
||||||
|
- **Backup:** optional database + ADIF backup at shutdown.
|
||||||
- **Update check** at startup with a toast (toggleable).
|
- **Update check** at startup with a toast (toggleable).
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
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.
|
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||||
file or a per-profile shared **MySQL** database.
|
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
|
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) {
|
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||||
if a.qslTemplates == nil {
|
if a.qslTemplates == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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_NAME = 'opslog'; // database name
|
||||||
$DB_USER = 'opslog';
|
$DB_USER = 'opslog';
|
||||||
$DB_PASS = 'CHANGE_ME';
|
$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".
|
// instead of a fatal "table doesn't exist".
|
||||||
mysqli_report(MYSQLI_REPORT_OFF);
|
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) {
|
if ($mysqli->connect_errno) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
exit('DB error');
|
exit('DB error');
|
||||||
|
|||||||
+847
-230
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 { Input } from '@/components/ui/input';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldDef = {
|
type FieldDef = {
|
||||||
name: string; kind: string; category: string;
|
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
|
// 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.
|
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||||
export function AdifExtrasEditor({ value, onChange }: Props) {
|
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [dict, setDict] = useState<FieldDef[]>([]);
|
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
|
|
||||||
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<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,
|
{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')}
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add a field */}
|
{/* Add a field */}
|
||||||
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
<Combobox
|
<Combobox
|
||||||
value=""
|
value=""
|
||||||
options={addable}
|
options={addable}
|
||||||
placeholder="Add ADIF field…"
|
placeholder={t('adx.addFieldPh')}
|
||||||
allowFreeText
|
allowFreeText
|
||||||
onChange={addField}
|
onChange={addField}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
<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)} />
|
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||||
Show deprecated
|
{t('adx.showDeprecated')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current entries */}
|
{/* Current entries */}
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<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>
|
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||||
{def && (
|
{def && (
|
||||||
<span className="block text-[10px] text-muted-foreground leading-tight">
|
<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 && ''}
|
{!def && ''}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
className="flex-1 h-8 text-xs font-mono"
|
className="flex-1 h-8 text-xs font-mono"
|
||||||
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
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" />
|
<X className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
@@ -32,6 +33,7 @@ function emptyRule(): Rule {
|
|||||||
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||||
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const shown = useMemo(
|
const shown = useMemo(
|
||||||
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||||
@@ -43,7 +45,7 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
|||||||
{searchable && (
|
{searchable && (
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||||
<Search className="size-3 text-muted-foreground" />
|
<Search className="size-3 text-muted-foreground" />
|
||||||
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn('overflow-y-auto p-1', height)}>
|
<div className={cn('overflow-y-auto p-1', height)}>
|
||||||
@@ -53,10 +55,10 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
|||||||
<span className="truncate">{o}</span>
|
<span className="truncate">{o}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
|
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||||
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
|
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -65,6 +67,7 @@ function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }
|
|||||||
export function AlertsModal({ onClose, bands, modes, countries }: {
|
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||||
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [rules, setRules] = useState<Rule[]>([]);
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
const [draft, setDraft] = useState<Rule | null>(null);
|
const [draft, setDraft] = useState<Rule | null>(null);
|
||||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||||
@@ -88,14 +91,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) { setErr('Give the rule a name'); return; }
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function del() {
|
async function del() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.id) { setDraft(null); return; }
|
if (!draft.id) { setDraft(null); return; }
|
||||||
if (!window.confirm(`Delete alert "${draft.name}"?`)) return;
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
@@ -104,24 +107,24 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
|
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||||
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
|
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||||
{/* Rule list */}
|
{/* Rule list */}
|
||||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
<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">
|
<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">Rules</span>
|
<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>
|
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<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">No rules yet — click +</div>}
|
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||||
{rules.map((r) => (
|
{rules.map((r) => (
|
||||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
<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',
|
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')}>
|
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-emerald-500' : 'bg-muted-foreground/40')} />
|
<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>
|
<span className="truncate flex-1">{r.name}</span>
|
||||||
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||||
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||||
@@ -129,7 +132,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-1.5 border-t border-border/60">
|
<div className="px-2 py-1.5 border-t border-border/60">
|
||||||
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
|
<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}
|
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||||
onChange={(e) => setEmailTo(e.target.value)}
|
onChange={(e) => setEmailTo(e.target.value)}
|
||||||
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||||
@@ -139,60 +142,60 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||||
{!draft ? (
|
{!draft ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
|
<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">
|
<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">
|
<div className="flex items-center gap-2 px-2 pt-2">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="def">Definition</TabsTrigger>
|
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
|
||||||
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
|
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
|
||||||
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
|
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
|
||||||
<TabsTrigger value="orig">Origin</TabsTrigger>
|
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||||
<TabsContent value="def" className="mt-0 space-y-3">
|
<TabsContent value="def" className="mt-0 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Rule name</Label>
|
<Label className="text-xs">{t('altm.ruleName')}</Label>
|
||||||
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
|
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
|
<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}
|
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||||
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||||
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
|
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<Label className="text-xs font-semibold">Actions</Label>
|
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
|
||||||
<div className="flex gap-4">
|
<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" /> Visual</label>
|
<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" /> Sound</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" /> E-mail</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>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
<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 })} /> Skip calls already worked (same band + mode)
|
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
|
||||||
</label>
|
</label>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
|
<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"
|
<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'}
|
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||||
value={(draft.calls ?? []).join('\n')}
|
value={(draft.calls ?? []).join('\n')}
|
||||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Countries (DXCC)</Label>
|
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||||
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label className="text-xs">Continents</Label>
|
<Label className="text-xs">{t('altm.continents')}</Label>
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="flex gap-3 flex-wrap">
|
||||||
{CONTINENTS.map((c) => (
|
{CONTINENTS.map((c) => (
|
||||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
@@ -205,21 +208,21 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
|
|
||||||
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Bands</Label>
|
<Label className="text-xs">{t('altm.bands')}</Label>
|
||||||
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Modes</Label>
|
<Label className="text-xs">{t('altm.modes')}</Label>
|
||||||
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Spotter callsign (wildcard)</Label>
|
<Label className="text-xs">{t('altm.spotterCall')}</Label>
|
||||||
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
|
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
|
||||||
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||||
<Label className="text-xs mt-2 block">Spotter continents</Label>
|
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="flex gap-3 flex-wrap">
|
||||||
{CONTINENTS.map((c) => (
|
{CONTINENTS.map((c) => (
|
||||||
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
@@ -229,7 +232,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Spotter countries</Label>
|
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
|
||||||
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -237,10 +240,10 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
|
|
||||||
{/* Editor actions */}
|
{/* Editor actions */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||||
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
|
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
|
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||||
<Button size="sm" onClick={save}>Save rule</Button>
|
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
@@ -248,7 +251,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end px-5 pb-4">
|
<div className="flex justify-end px-5 pb-4">
|
||||||
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
|
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { Antenna, X } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type AGAntenna = { index: number; name: string };
|
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||||
export type AGStatus = {
|
export type AGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
antennas: AGAntenna[];
|
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
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
function pretty(name: string): string {
|
function pretty(name: string): string {
|
||||||
@@ -21,12 +38,28 @@ function pretty(name: string): string {
|
|||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// 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.
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
// Clicking an already-selected port deselects it (port → None).
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||||
status: AGStatus;
|
status: AGStatus;
|
||||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||||
}) {
|
}) {
|
||||||
const list = status.antennas ?? [];
|
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 PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
@@ -41,7 +74,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onActivate(port, active ? 0 : index)}
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
|
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)}
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
>
|
>
|
||||||
{letter}
|
{letter}
|
||||||
@@ -52,14 +85,19 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
return (
|
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="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">
|
<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-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
<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="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
<span className="flex-1" />
|
<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="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-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
<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-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
|
<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" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,11 +105,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
{!status.connected ? (
|
{!status.connected ? (
|
||||||
<div className="text-center py-6 text-xs space-y-2">
|
<div className="text-center py-6 text-xs space-y-2">
|
||||||
<div className="text-muted-foreground italic animate-pulse">Connecting…</div>
|
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||||
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||||
</div>
|
</div>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
|
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||||
) : list.map((a) => {
|
) : list.map((a) => {
|
||||||
const aActive = status.port_a === a.index;
|
const aActive = status.port_a === a.index;
|
||||||
const bActive = status.port_b === a.index;
|
const bActive = status.port_b === a.index;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
@@ -17,7 +18,8 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// 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 = {
|
export type AwardDef = {
|
||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
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;
|
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;
|
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[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
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;
|
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
|
// 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[].
|
// 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[] }) {
|
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [names, setNames] = useState<Record<number, string>>({});
|
const [names, setNames] = useState<Record<number, string>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -124,7 +128,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<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 && (
|
{value.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{value.map((n) => (
|
{value.map((n) => (
|
||||||
@@ -141,6 +145,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
const [fields, setFields] = useState<string[]>([]);
|
const [fields, setFields] = useState<string[]>([]);
|
||||||
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||||
@@ -173,7 +178,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [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 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 patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -208,20 +296,42 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const p = await ExportAwards();
|
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)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Import an award bundle: definitions are upserted by code, reference lists
|
// Import: LOOK FIRST, then ask.
|
||||||
// replaced. Reloads the editor afterwards.
|
//
|
||||||
|
// 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() {
|
async function importAwards() {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await ImportAwards();
|
const p: any = await InspectAwardImport();
|
||||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
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()]);
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
setDefs((d ?? []) as any); setSel(0);
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
onSaved();
|
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)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
@@ -244,7 +354,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<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">
|
<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">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>Award management</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
<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="p-2 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{filtered.map(({ d, i }) => (
|
{filtered.map(({ d, i }) => {
|
||||||
<button key={i} onClick={() => setSel(i)}
|
// An award that is NOT in the shipped catalog exists only in THIS
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
// database: nobody else has it, and a reinstall loses it unless it
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
// discovered the hard way.
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
// A pending update is only reachable from the award's own banner, so
|
||||||
</button>
|
// 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>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<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 ? (
|
{!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">
|
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<TabsList className="px-3 justify-start">
|
<TabsList className="px-3 justify-start">
|
||||||
<TabsTrigger value="info">Award info</TabsTrigger>
|
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||||
<TabsTrigger value="type">Award type</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
<TabsTrigger value="conf">Confirmation</TabsTrigger>
|
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||||
<TabsTrigger value="refs">References</TabsTrigger>
|
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<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">
|
<TabsContent value="info" className="mt-0 space-y-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<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 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" />
|
<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 })} /> Valid</label>
|
<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>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
{/* 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>
|
</div>
|
||||||
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.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={t('awed.awardUrl')}><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.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">
|
<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={t('awed.validFrom')}><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.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
<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} />
|
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
|
||||||
</div>
|
</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">{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">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">{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">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.validModes')}</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── Award type ── */}
|
{/* ── Award type ── */}
|
||||||
<TabsContent value="type" className="mt-0 space-y-2.5">
|
<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 })}>
|
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{
|
<SelectContent>{
|
||||||
@@ -324,18 +507,20 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</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>
|
{/* No "allow multiple references" switch: a QSO always counts for
|
||||||
<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>
|
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">
|
<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>
|
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||||
<Field2 label="Search in field">
|
<Field2 label={t('awed.searchInField')}>
|
||||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
<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>
|
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
<Field2 label="Match by">
|
<Field2 label={t('awed.matchBy')}>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{['code', 'description', 'pattern'].map((m) => (
|
{['code', 'description', 'pattern'].map((m) => (
|
||||||
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
@@ -344,21 +529,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field2>
|
</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>
|
<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="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>
|
<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">
|
<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={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="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.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional OR searches: a QSO earns a reference if the
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
primary rule OR any of these match. */}
|
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="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<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"
|
<Button size="sm" variant="outline" className="h-7"
|
||||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{(cur.or_rules ?? []).map((r, ri) => {
|
{(cur.or_rules ?? []).map((r, ri) => {
|
||||||
@@ -367,7 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||||
<div className="flex items-center gap-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 })}>
|
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
<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>
|
<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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<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="Remove this OR search"><Trash2 className="size-4" /></button>
|
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
<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.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="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -397,20 +584,117 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsContent value="conf" className="mt-0 space-y-4">
|
<TabsContent value="conf" className="mt-0 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div className="space-y-1.5">
|
<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) => (
|
{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>
|
<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>
|
||||||
<div className="space-y-1.5">
|
<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) => (
|
{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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
<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>
|
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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── References ── */}
|
{/* ── References ── */}
|
||||||
@@ -428,18 +712,85 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
<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="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||||
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> Export…
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> Import…
|
<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>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -451,6 +802,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [selCode, setSelCode] = useState<string | null>(null);
|
const [selCode, setSelCode] = useState<string | null>(null);
|
||||||
@@ -486,7 +838,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
}, [code, q, large]);
|
}, [code, q, large]);
|
||||||
|
|
||||||
async function populateBuiltin() {
|
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)); }
|
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)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function addRef() {
|
async function addRef() {
|
||||||
const c = prompt('New reference code:')?.trim().toUpperCase();
|
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||||
await saveRef(r); setSelCode(c);
|
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)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function importBulk() {
|
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)); }
|
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">
|
<div className="space-y-2">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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" />
|
<div className="flex-1" />
|
||||||
<Select value="" onValueChange={applyPreset}>
|
<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>
|
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</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 && (
|
{hasBuiltin && (
|
||||||
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
|
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
|
||||||
Populate built-in
|
{t('awed.populateBuiltin')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{meta?.can_update && (
|
{meta?.can_update && (
|
||||||
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{showBulk && (
|
{showBulk && (
|
||||||
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
|
<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'} />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[200px_1fr] gap-3">
|
<div className="grid grid-cols-[200px_1fr] gap-3">
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
|
<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">
|
<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 && (
|
{!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">
|
<div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
|
||||||
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
|
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
||||||
</div>
|
</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) => (
|
{filtered.map((r) => (
|
||||||
<button key={r.code} onClick={() => setSelCode(r.code)}
|
<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')}>
|
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 */}
|
{/* Per-reference editor */}
|
||||||
<div className="border rounded p-3">
|
<div className="border rounded p-3">
|
||||||
{!sel ? (
|
{!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="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
<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" />
|
<div className="flex-1" />
|
||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* One per row: side by side, each half spends 120px of its width on
|
||||||
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
the label column and the input is left too narrow to read a group
|
||||||
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
name ("Basilicata" → "Basili"). */}
|
||||||
</div>
|
<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="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>
|
<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>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
boxes were pure decoration. The columns stay in the database — a
|
||||||
<Label className="text-xs text-muted-foreground">Score</Label>
|
third-party list may carry the values — but they are not offered
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
for editing until something actually reads them. */}
|
||||||
</div>
|
<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 flex-col gap-1 min-w-0">
|
<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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X, Loader2 } from 'lucide-react';
|
import { X, Loader2 } from 'lucide-react';
|
||||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
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
|
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||||
// result assigns it to the QSO.
|
// result assigns it to the QSO.
|
||||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [results, setResults] = useState<Ref[]>([]);
|
const [results, setResults] = useState<Ref[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
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>
|
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||||
<div className="relative" ref={boxRef}>
|
<div className="relative" ref={boxRef}>
|
||||||
{value ? (
|
{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>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<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"
|
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}
|
value={q}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onChange={(e) => { setQ(e.target.value); 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 && (
|
{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">
|
<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 && (
|
{!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) => (
|
{results.map((r) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../.
|
|||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
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 };
|
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) {
|
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||||
const [awardCode, setAwardCode] = useState('POTA');
|
const [awardCode, setAwardCode] = useState('POTA');
|
||||||
@@ -208,24 +210,24 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
{/* Group / Sub from selected ref */}
|
{/* Group / Sub from selected ref */}
|
||||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
<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="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>
|
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected ref chip */}
|
{/* Selected ref chip */}
|
||||||
{selectedRef ? (
|
{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="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
<button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -236,10 +238,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
onClick={() => selectedRef && addRef(selectedRef)}
|
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"
|
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>
|
</button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
{/* Added refs list */}
|
{/* Added refs list */}
|
||||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||||
{entries.length === 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) => (
|
entries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
@@ -273,7 +275,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
{/* Right panel: reference search */}
|
{/* Right panel: reference search */}
|
||||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
<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). */}
|
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||||
{autoMatch && (
|
{autoMatch && (
|
||||||
<button
|
<button
|
||||||
@@ -282,20 +284,20 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||||
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||||
autoAlreadyAdded
|
autoAlreadyAdded
|
||||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
|
||||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
: '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 ? '✓ ' : '+ '}
|
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||||
<span className="text-emerald-700"> from {selField}</span>
|
<span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
|
||||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto — click to add</span>}
|
{!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<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"
|
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}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -306,22 +308,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
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"
|
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>
|
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||||
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||||
{busy && (
|
{busy && (
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Too many auto-results → require typed search */}
|
{/* Too many auto-results → require typed search */}
|
||||||
{!busy && q.length < 2 && tooManyAuto && (
|
{!busy && q.length < 2 && tooManyAuto && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
Type 2+ chars to search
|
{t('awrs.typeToSearch')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||||
@@ -329,16 +331,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
{isDynamic && !dxcc
|
{isDynamic && !dxcc
|
||||||
? 'Enter a callsign, or type to search.'
|
? t('awrs.enterCallsignOrSearch')
|
||||||
: 'No references for this entity.'}
|
: t('awrs.noRefsForEntity')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Typed search, no results */}
|
{/* Typed search, no results */}
|
||||||
{!busy && q.length >= 2 && results.length === 0 && (
|
{!busy && q.length >= 2 && results.length === 0 && (
|
||||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||||
No results.
|
{t('awrs.noResults')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
@@ -35,9 +36,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
const CELL_STYLE: Record<CellStatus, string> = {
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
validated: 'bg-emerald-500 text-white',
|
validated: 'bg-success text-success-foreground',
|
||||||
confirmed: 'bg-amber-400 text-amber-950',
|
confirmed: 'bg-warning text-warning-foreground',
|
||||||
worked: 'bg-stone-400 text-white',
|
worked: 'bg-muted-foreground text-background',
|
||||||
none: '',
|
none: '',
|
||||||
};
|
};
|
||||||
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', 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;
|
if (total <= 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
<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-success" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
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[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
// first time it's selected (or when explicitly rescanned).
|
// 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="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">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||||
<AwardIcon className="size-4 text-primary" />
|
<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" />
|
<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" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
<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" />}
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
Rescan
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<div className="px-2 py-2 border-b border-border/40">
|
||||||
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
<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">
|
<SelectContent className="max-h-80">
|
||||||
{awardList.map((a) => (
|
{awardList.map((a) => (
|
||||||
<SelectItem key={a.code} value={a.code} className="text-xs">
|
<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>
|
<span className="font-semibold text-sm">{a.code}</span>
|
||||||
{r ? (
|
{r ? (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
<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>
|
/<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>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</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">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{!current ? (
|
{!current ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -283,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
<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-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.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-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.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-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||||
{current.total > 0 && (
|
{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>
|
||||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></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 */}
|
{/* Band breakdown */}
|
||||||
{(current.bands ?? []).length > 0 && (
|
{(current.bands ?? []).length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-border/60">
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(current.bands ?? []).map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
<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="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>
|
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||||
</div>
|
</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="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<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>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
<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)}
|
<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')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
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"
|
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="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
title={t('awp.missingRefsTitle')}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> Missing refs
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<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-muted-foreground" />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-warning" />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-success" />V</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
<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 === '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="List view" onClick={() => setView('list')}><List 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="Statistics" onClick={() => setView('stats')}><BarChart3 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -349,15 +351,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{view === 'stats' ? (
|
{view === 'stats' ? (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
{statsLoading || !stats ? (
|
{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 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<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>)}
|
{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">{t('awp.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.grand')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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',
|
<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>
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
{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 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>
|
<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 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<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="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">Description</th>
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||||
{gridBands.map((b) => (
|
{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>
|
<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" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<button
|
||||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
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 })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
)}
|
)}
|
||||||
@@ -422,11 +424,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<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 w-24">{t('awp.ref')}</th>
|
||||||
<th className="py-1.5 pr-2 font-medium">Name</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">Group</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">Status</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">Bands</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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-[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 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||||
<td className="py-1 pr-2">
|
<td className="py-1 pr-2">
|
||||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
|
||||||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
|
||||||
: <span className="text-stone-500">worked</span>}
|
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 font-mono text-muted-foreground">
|
<td className="py-1 font-mono text-muted-foreground">
|
||||||
{r.bands.map((b) => (
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -472,6 +474,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
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 }) {
|
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 [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
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);
|
const done = new Set(assignedIds);
|
||||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||||
setSel(new Set());
|
setSel(new Set());
|
||||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setMsg(String(e?.message ?? e));
|
setMsg(String(e?.message ?? e));
|
||||||
} finally { setBusy(false); }
|
} 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="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="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">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
<AlertTriangle className="size-4 text-amber-600" />
|
<AlertTriangle className="size-4 text-warning" />
|
||||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={load} disabled={loading}
|
<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"
|
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">
|
title={t('awp.recomputeTitle')}>
|
||||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
|
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
<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.
|
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
|
||||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk-assign toolbar */}
|
{/* Bulk-assign toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
<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}>
|
<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">
|
<SelectContent className="max-h-72">
|
||||||
{refs.map((r) => (
|
{refs.map((r) => (
|
||||||
<SelectItem key={r.code} value={r.code}>
|
<SelectItem key={r.code} value={r.code}>
|
||||||
@@ -585,29 +587,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
<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>
|
</Button>
|
||||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{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 ? (
|
) : qsos.length === 0 ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
<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="qso_date" label={t('awp.dateUtc')} className="pl-0" />
|
||||||
<SortTh k="callsign" label="Callsign" />
|
<SortTh k="callsign" label={t('awp.callsign')} />
|
||||||
<SortTh k="band" label="Band" />
|
<SortTh k="band" label={t('awp.band')} />
|
||||||
<SortTh k="mode" label="Mode" />
|
<SortTh k="mode" label={t('awp.mode')} />
|
||||||
<SortTh k="country" label="Country" />
|
<SortTh k="country" label={t('awp.country')} />
|
||||||
<SortTh k="qth" label="QTH / Note" />
|
<SortTh k="qth" label={t('awp.qthNote')} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -633,8 +635,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
<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' : ''} ·{' '}
|
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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).
|
// 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 }) {
|
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 [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -666,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{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 ? (
|
) : 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">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{qsos.map((q, i) => (
|
{qsos.map((q, i) => (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
@@ -34,6 +35,12 @@ interface Props {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
side?: 'left' | 'right';
|
side?: 'left' | 'right';
|
||||||
onToggleSide?: () => void;
|
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]> = {
|
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][]> = {
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// 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
|
// 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.
|
// 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) {
|
switch (s) {
|
||||||
case 'new': return 'NEW DXCC (entity never worked)';
|
case 'new': return t('bmp.statusNew');
|
||||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
case 'new-band': return t('bmp.statusNewBand');
|
||||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
case 'new-slot': return t('bmp.statusNewSlot');
|
||||||
case 'worked': return 'Worked (this band + mode already in log)';
|
case 'worked': return t('bmp.statusWorked');
|
||||||
default: return 'Entity not resolved';
|
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
|
// dot = small marker on the freq scale
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return {
|
case 'new': return {
|
||||||
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||||
bar: 'bg-rose-500',
|
bar: 'bg-danger',
|
||||||
line: 'stroke-rose-400',
|
line: 'stroke-danger',
|
||||||
dot: 'fill-rose-500',
|
dot: 'fill-danger',
|
||||||
};
|
};
|
||||||
case 'new-band': return {
|
case 'new-band': return {
|
||||||
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||||||
bar: 'bg-amber-500',
|
bar: 'bg-warning',
|
||||||
line: 'stroke-amber-400',
|
line: 'stroke-warning',
|
||||||
dot: 'fill-amber-500',
|
dot: 'fill-warning',
|
||||||
};
|
};
|
||||||
case 'new-slot': return {
|
case 'new-slot': return {
|
||||||
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||||
bar: 'bg-yellow-500',
|
bar: 'bg-caution',
|
||||||
line: 'stroke-yellow-500',
|
line: 'stroke-caution',
|
||||||
dot: 'fill-yellow-500',
|
dot: 'fill-caution',
|
||||||
};
|
};
|
||||||
case 'worked': return {
|
case 'worked': return {
|
||||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
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).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
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 range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[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 scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerH, setContainerH] = useState(400);
|
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 fallback: [number, number] = range ?? [0, 1];
|
||||||
const [lo, hi] = fallback;
|
const [lo, hi] = fallback;
|
||||||
const span = hi - lo;
|
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
|
// 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
|
// 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
|
// 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.
|
// 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 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));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
// 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 });
|
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;
|
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 {
|
return {
|
||||||
placed: out,
|
placed: out,
|
||||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||||
hidden: hiddenCount,
|
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).
|
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
// 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 innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
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
|
// Keep the map centred on the rig frequency: the effect re-runs only when the
|
||||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||||||
// for the scroller height to be measured and a valid in-band rig freq.
|
// while a stable frequency leaves your manual scroll alone.
|
||||||
const centeredForRef = useRef<string>('');
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
if (!range || containerH <= 0) return;
|
||||||
const kHz = currentFreqHz / 1000;
|
|
||||||
if (kHz < lo || kHz > hi) return;
|
|
||||||
if (centeredForRef.current === band) return;
|
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
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);
|
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
@@ -330,8 +335,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
if (!range) {
|
if (!range) {
|
||||||
return (
|
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="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>
|
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||||
Not configured for {band || '—'}.
|
{t('bmp.notConfigured', { band: band || '—' })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -363,35 +368,35 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<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">
|
<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">Map · {band}</span>
|
<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={zoomIdx === 0}
|
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title="Zoom out">
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
<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={zoomIdx === PX_PER_KHZ.length - 1}
|
<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 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title="Zoom in">
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={recenterOnRig}
|
<button type="button" onClick={recenterOnRig}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
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" />
|
<Crosshair className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
{onToggleSide && (
|
{onToggleSide && (
|
||||||
<button type="button" onClick={onToggleSide}
|
<button type="button" onClick={onToggleSide}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
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" />}
|
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" onClick={onClose}
|
<button type="button" onClick={onClose}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
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" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -496,7 +501,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
'hover:translate-x-0.5 hover:shadow',
|
'hover:translate-x-0.5 hover:shadow',
|
||||||
style.pill,
|
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 */}
|
{/* Status accent strip on the left */}
|
||||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
<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>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* 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">
|
<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-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-amber-400" label="New band" />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||||
</div>
|
</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">
|
<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
|
{t('bmp.footerHint')}
|
||||||
{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>}
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,22 +51,27 @@ function classMatchesMode(cls: string, mode: string): boolean {
|
|||||||
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
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> = {
|
const STATUS_CLASSES: Record<string, string> = {
|
||||||
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
call_c: 'bg-mx-call-conf',
|
||||||
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
call_w: 'bg-mx-call-work',
|
||||||
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
dxcc_c: 'bg-mx-dx-conf',
|
||||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
dxcc_w: 'bg-mx-dx-work',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legend entries, in the same colour order as the cells. swatch = the
|
// Legend entries, in the same colour order as the cells. swatch = the
|
||||||
// background class (or a special ring marker for the current-entry cell).
|
// background class (or a special ring marker for the current-entry cell).
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
||||||
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
||||||
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
||||||
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
||||||
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
||||||
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
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
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
'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]">
|
<div className="flex items-center gap-2 min-w-[220px]">
|
||||||
{newOne ? (
|
{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" />
|
<Star className="size-3 fill-current" />
|
||||||
NEW ONE
|
NEW ONE
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-amber-900">
|
<span className="text-xs text-warning-muted-foreground">
|
||||||
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
{' '}· never worked this entity
|
{' '}· never worked this entity
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -161,10 +166,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</span>
|
</span>
|
||||||
{(newBand || newMode || newBandMode || newSlot) && (
|
{(newBand || newMode || newBandMode || newSlot) && (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<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>}
|
{newBandMode && <Badge className="bg-warning text-warning-foreground 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>}
|
{newBand && <Badge className="bg-warning text-warning-foreground 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>}
|
{newMode && <Badge className="bg-warning text-warning-foreground 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>}
|
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -220,8 +225,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -240,7 +245,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
l.ring && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{l.label}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldKind = 'status' | 'text';
|
type FieldKind = 'status' | 'text';
|
||||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
@@ -17,52 +18,85 @@ 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.
|
// 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
|
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||||
// the backend BulkUpdateField whitelist.
|
// the backend BulkUpdateField whitelist.
|
||||||
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const FIELDS: FieldDef[] = [
|
const FIELDS: FieldDef[] = [
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sota_ref', label: 'My SOTA ref', 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: 'My POTA ref', 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: 'My WWFF ref', 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: 'My SIG', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
|
// 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
|
// Misc
|
||||||
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'ant', label: 'Antenna (contacted)', 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 }[] = [
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||||
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
{ v: 'Y', label: 'bulk.statusY' },
|
||||||
{ v: 'N', label: 'N — No' },
|
{ v: 'N', label: 'bulk.statusN' },
|
||||||
{ v: 'R', label: 'R — Requested' },
|
{ v: 'R', label: 'bulk.statusR' },
|
||||||
{ v: 'I', label: 'I — Ignore' },
|
{ v: 'I', label: 'bulk.statusI' },
|
||||||
{ v: '_', label: '(blank — clear)' },
|
{ 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 = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -75,6 +109,7 @@ type Props = {
|
|||||||
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||||
// so they become eligible for upload.
|
// so they become eligible for upload.
|
||||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [field, setField] = useState('hrdlog_upload');
|
const [field, setField] = useState('hrdlog_upload');
|
||||||
const [statusValue, setStatusValue] = useState('R');
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
const [textValue, setTextValue] = useState('');
|
const [textValue, setTextValue] = useState('');
|
||||||
@@ -105,60 +140,62 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Bulk edit field</DialogTitle>
|
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
{t('bulk.desc', { n: ids.length })}
|
||||||
This overwrites the current value — there is no undo.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-5 py-2 space-y-3">
|
<div className="px-5 py-2 space-y-3">
|
||||||
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
<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}>
|
<Select value={field} onValueChange={setField}>
|
||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
<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) => (
|
{FIELDS.filter((f) => f.group === g)
|
||||||
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
.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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Label className="text-sm">Value</Label>
|
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
||||||
{isStatus ? (
|
{isStatus ? (
|
||||||
<Select value={statusValue} onValueChange={setStatusValue}>
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={textValue}
|
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)}
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||||
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-rose-700">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<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}>
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
Apply to {ids.length}
|
{t('bulk.applyTo', { n: ids.length })}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -26,6 +27,7 @@ function fmtDateTime(s: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const hasCall = currentCall.trim() !== '';
|
const hasCall = currentCall.trim() !== '';
|
||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
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">
|
<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">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Worked before
|
{t('chp.workedBefore')}
|
||||||
</span>
|
</span>
|
||||||
{hasCall && (
|
{hasCall && (
|
||||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||||
@@ -52,54 +54,54 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
|
|
||||||
{!hasCall ? (
|
{!hasCall ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : busy && count === 0 ? (
|
) : busy && count === 0 ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||||
checking…
|
{t('chp.checking')}
|
||||||
</div>
|
</div>
|
||||||
) : count === 0 ? (
|
) : count === 0 ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<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" />
|
<Star className="size-6 text-primary fill-current" />
|
||||||
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||||||
<div>No prior QSO with this callsign.</div>
|
<div>{t('chp.noPriorQso')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
<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> ·{' '}
|
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-[11px] border-collapse">
|
<table className="w-full text-[11px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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-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-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-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-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-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-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-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-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-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-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">QSL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{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 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">
|
<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>
|
<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>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
<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>
|
||||||
<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_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 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">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{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' && (
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -108,7 +110,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
</table>
|
</table>
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||||
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
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;
|
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||||
onSend: (text: string) => void; onClose: () => void;
|
onSend: (text: string) => void; onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const me = (myCall || '').toUpperCase();
|
const me = (myCall || '').toUpperCase();
|
||||||
@@ -33,8 +35,8 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<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">
|
<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" />
|
<MessageSquare className="size-4 text-info" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{/* Online count — hover to see who's connected. */}
|
{/* Online count — hover to see who's connected. */}
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -43,7 +45,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
</span>
|
</span>
|
||||||
{online.length > 0 && (
|
{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="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">Online</div>
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
|
||||||
{online.map((o) => (
|
{online.map((o) => (
|
||||||
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
<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}
|
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||||
@@ -52,19 +54,19 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
<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 ? (
|
{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) => {
|
) : msgs.map((m) => {
|
||||||
const mine = m.operator.toUpperCase() === me;
|
const mine = m.operator.toUpperCase() === me;
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
<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>}
|
{!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>
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +81,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
placeholder="Message…"
|
placeholder={t('chatp.messagePh')}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
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, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -12,30 +13,13 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
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]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ClusterSpot = {
|
export type ClusterSpot = {
|
||||||
source_id: number;
|
source_id: number;
|
||||||
@@ -117,55 +101,60 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
// 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
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
// there's nothing notable to show.
|
// 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) {
|
switch (s?.status) {
|
||||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||||
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
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',
|
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Call', colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
||||||
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
||||||
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
||||||
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
// the rest of the row across every theme instead of always shouting orange.
|
||||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
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) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
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',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: 'Status', width: 96, sortable: true,
|
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// 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.
|
// is obvious at the row level, not just a single coloured cell.
|
||||||
valueGetter: (p: any) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return 'NEW DXCC';
|
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||||
if (s?.status === 'new-band') return 'NEW BAND';
|
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||||
if (s?.status === 'new-mode') return 'NEW MODE';
|
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||||
return s?.worked_call ? 'WKD CALL' : '';
|
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(statusFor(p));
|
const b = statusBadge(t, statusFor(p));
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -176,41 +165,41 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||||
if (s?.worked_call) return 'Already worked this call';
|
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#166534' },
|
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',
|
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Band', colId: 'band',
|
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||||
headerName: 'Band', field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||||
: undefined),
|
: 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',
|
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
@@ -226,48 +215,48 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
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) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
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) : '',
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
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) : '',
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Country', colId: 'country',
|
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||||
headerName: 'Country', width: 140,
|
headerName: t('clg2.c.country'), width: 140,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
@@ -275,8 +264,8 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
@@ -284,50 +273,57 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Source', colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: '#7a6b50' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
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) {
|
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
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 columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), []);
|
}), [COL_CATALOG]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -404,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">
|
<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)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title="Clear all column filters">
|
title={t('clg2.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> Clear filters
|
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
@@ -437,9 +433,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Cluster columns</DialogTitle>
|
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Cluster table.
|
{t('clg2.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||||
@@ -449,10 +445,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
<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">
|
<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">
|
<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-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)}>none</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
@@ -471,8 +467,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -75,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).
|
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||||
const PROP_MODES: { value: string; label: string }[] = [
|
const PROP_MODES: { value: string; label: string }[] = [
|
||||||
{ value: 'NONE', label: '—' },
|
{ value: 'NONE', label: '—' },
|
||||||
{ value: 'AS', label: 'Aircraft Scatter' },
|
{ value: 'AS', label: 'detp.propAS' },
|
||||||
{ value: 'AUR', label: 'Aurora' },
|
{ value: 'AUR', label: 'detp.propAUR' },
|
||||||
{ value: 'AUE', label: 'Aurora-E' },
|
{ value: 'AUE', label: 'detp.propAUE' },
|
||||||
{ value: 'BS', label: 'Back Scatter' },
|
{ value: 'BS', label: 'detp.propBS' },
|
||||||
{ value: 'ECH', label: 'EchoLink' },
|
{ value: 'ECH', label: 'EchoLink' },
|
||||||
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
{ value: 'EME', label: 'detp.propEME' },
|
||||||
{ value: 'ES', label: 'Sporadic E' },
|
{ value: 'ES', label: 'detp.propES' },
|
||||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
{ value: 'FAI', label: 'detp.propFAI' },
|
||||||
{ value: 'F2', label: 'F2 Reflection' },
|
{ value: 'F2', label: 'detp.propF2' },
|
||||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
||||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
||||||
{ value: 'ION', label: 'Ionoscatter' },
|
{ value: 'ION', label: 'detp.propION' },
|
||||||
{ value: 'IRL', label: 'IRLP' },
|
{ value: 'IRL', label: 'IRLP' },
|
||||||
{ value: 'LOS', label: 'Line of Sight' },
|
{ value: 'LOS', label: 'detp.propLOS' },
|
||||||
{ value: 'MS', label: 'Meteor Scatter' },
|
{ value: 'MS', label: 'detp.propMS' },
|
||||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
{ value: 'RPT', label: 'detp.propRPT' },
|
||||||
{ value: 'RS', label: 'Rain Scatter' },
|
{ value: 'RS', label: 'detp.propRS' },
|
||||||
{ value: 'SAT', label: 'Satellite' },
|
{ value: 'SAT', label: 'detp.propSAT' },
|
||||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
{ value: 'TEP', label: 'detp.propTEP' },
|
||||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
{ value: 'TR', label: 'detp.propTR' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||||
const ANT_PATHS: { value: string; label: string }[] = [
|
const ANT_PATHS: { value: string; label: string }[] = [
|
||||||
{ value: 'NONE', label: '—' },
|
{ value: 'NONE', label: '—' },
|
||||||
{ value: 'S', label: 'Short Path' },
|
{ value: 'S', label: 'detp.pathShort' },
|
||||||
{ value: 'L', label: 'Long Path' },
|
{ value: 'L', label: 'detp.pathLong' },
|
||||||
{ value: 'G', label: 'Grayline' },
|
{ value: 'G', label: 'detp.pathGrayline' },
|
||||||
{ value: 'O', label: 'Other' },
|
{ value: 'O', label: 'detp.pathOther' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function numOrUndef(v: string): number | undefined {
|
function numOrUndef(v: string): number | undefined {
|
||||||
@@ -123,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) {
|
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 [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
@@ -191,11 +193,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: TabName; label: string }[] = [
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
{ key: 'stats', label: `Stats (${fk}1)` },
|
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
||||||
{ key: 'info', label: `Info (${fk}2)` },
|
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
||||||
{ key: 'awards', label: `Awards (${fk}3)` },
|
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
||||||
{ key: 'my', label: `My (${fk}4)` },
|
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
||||||
{ key: 'extended', label: `Extended (${fk}5)` },
|
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,27 +228,27 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
|
|
||||||
{open === 'info' && (
|
{open === 'info' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<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 })} />
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="County">
|
<Field label={t('detp.county')}>
|
||||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Prefix">
|
<Field label={t('detp.prefix')}>
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="CQ zone">
|
<Field label={t('detp.cqZone')}>
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
<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) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="ITU zone">
|
<Field label={t('detp.ituZone')}>
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
<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) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
bearing and distances move to the row below. */}
|
bearing and distances move to the row below. */}
|
||||||
<Field label="DXCC #">
|
<Field label={t('detp.dxcc')}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -255,7 +257,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Azimuth LP">
|
<Field label={t('detp.azimuthLp')}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -264,7 +266,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Distance SP">
|
<Field label={t('detp.distanceSp')}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -273,7 +275,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Distance LP">
|
<Field label={t('detp.distanceLp')}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -282,13 +284,13 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Address" span={3}>
|
<Field label={t('detp.address')} span={3}>
|
||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +307,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
/>
|
/>
|
||||||
{detected.length > 0 && (
|
{detected.length > 0 && (
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-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) => (
|
{detected.map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
<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}
|
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||||
@@ -318,49 +320,49 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
|
|
||||||
{open === 'my' && (
|
{open === 'my' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<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) })} />
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</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) })} />
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</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) })} />
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="col-span-3 flex items-end pb-1.5">
|
<div className="col-span-3 flex items-end pb-1.5">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||||
Satellite mode
|
{t('detp.satelliteMode')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 })}>
|
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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 })}>
|
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
{satelliteMode && (
|
{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 })} />
|
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
@@ -370,16 +372,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
|
|
||||||
{open === 'extended' && (
|
{open === 'extended' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<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 })} />
|
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="SRX">
|
<Field label="SRX">
|
||||||
<Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
|
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</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 })} />
|
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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 { Mic, Square, X, Radio } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
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 DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: 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
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const anyAudio = messages.some((m) => m.has_audio);
|
const anyAudio = messages.some((m) => m.has_audio);
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
<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">
|
<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" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<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>
|
||||||
<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" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +40,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
{!anyAudio ? (
|
{!anyAudio ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
<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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-1">
|
<div className="grid grid-cols-1 gap-1">
|
||||||
@@ -48,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!m.has_audio}
|
disabled={!m.has_audio}
|
||||||
onClick={() => onPlay(m.slot)}
|
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(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||||
m.has_audio
|
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="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>}
|
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||||
</button>
|
</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 { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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
|
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||||
// adds field/operator/value conditions, joins them with AND or OR, and can
|
// 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.
|
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||||
type FieldType = 'text' | 'number' | 'date';
|
type FieldType = 'text' | 'number' | 'date';
|
||||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||||
{ value: 'callsign', label: 'Callsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
|
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||||
{ value: 'band', label: 'Band', type: 'text' },
|
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||||
{ value: 'band_rx', label: 'RX band', type: 'text' },
|
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||||
{ value: 'mode', label: 'Mode', type: 'text' },
|
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
|
||||||
{ value: 'submode', label: 'Submode', type: 'text' },
|
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
|
||||||
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
|
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
|
||||||
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
|
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
|
||||||
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
|
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
|
||||||
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
|
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
|
||||||
{ value: 'name', label: 'Name', type: 'text' },
|
{ value: 'name', label: 'fltb.fName', type: 'text' },
|
||||||
{ value: 'qth', label: 'QTH', type: 'text' },
|
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
|
||||||
{ value: 'address', label: 'Address', type: 'text' },
|
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
|
||||||
{ value: 'email', label: 'E-mail', type: 'text' },
|
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
|
||||||
{ value: 'grid', label: 'Grid', type: 'text' },
|
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
|
||||||
{ value: 'country', label: 'Country', type: 'text' },
|
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
|
||||||
{ value: 'state', label: 'State', type: 'text' },
|
{ value: 'state', label: 'fltb.fState', type: 'text' },
|
||||||
{ value: 'cnty', label: 'County', type: 'text' },
|
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
|
||||||
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
|
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
|
||||||
{ value: 'cont', label: 'Continent', type: 'text' },
|
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
|
||||||
{ value: 'cqz', label: 'CQ zone', type: 'number' },
|
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
|
||||||
{ value: 'ituz', label: 'ITU zone', type: 'number' },
|
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
|
||||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
|
||||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
|
||||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
|
||||||
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
|
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||||
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
|
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
|
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
|
||||||
{ value: 'sat_name', label: 'Satellite', type: 'text' },
|
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
|
||||||
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
|
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
|
||||||
{ value: 'operator', label: 'Operator', type: 'text' },
|
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
|
||||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
|
||||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
|
||||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
|
||||||
{ value: 'my_state', label: 'My state', type: 'text' },
|
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
|
||||||
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
|
||||||
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
|
||||||
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
|
||||||
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
|
||||||
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
|
||||||
{ value: 'my_street', label: 'My street', type: 'text' },
|
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
|
||||||
{ value: 'my_city', label: 'My city', type: 'text' },
|
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
|
||||||
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
|
||||||
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
|
||||||
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
|
||||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
|
||||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
|
||||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPS: { value: FilterOp; label: string }[] = [
|
const OPS: { value: FilterOp; label: string }[] = [
|
||||||
{ value: 'eq', label: 'equals (=)' },
|
{ value: 'eq', label: 'fltb.opEq' },
|
||||||
{ value: 'ne', label: 'not equal (≠)' },
|
{ value: 'ne', label: 'fltb.opNe' },
|
||||||
{ value: 'contains', label: 'contains' },
|
{ value: 'contains', label: 'fltb.opContains' },
|
||||||
{ value: 'startswith', label: 'starts with' },
|
{ value: 'startswith', label: 'fltb.opStartsWith' },
|
||||||
{ value: 'endswith', label: 'ends with' },
|
{ value: 'endswith', label: 'fltb.opEndsWith' },
|
||||||
{ value: 'gt', label: 'greater than (>)' },
|
{ value: 'gt', label: 'fltb.opGt' },
|
||||||
{ value: 'lt', label: 'less than (<)' },
|
{ value: 'lt', label: 'fltb.opLt' },
|
||||||
{ value: 'ge', label: 'greater or equal (≥)' },
|
{ value: 'ge', label: 'fltb.opGe' },
|
||||||
{ value: 'le', label: 'less or equal (≤)' },
|
{ value: 'le', label: 'fltb.opLe' },
|
||||||
{ value: 'empty', label: 'is empty' },
|
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||||
{ value: 'notempty', label: 'is not empty' },
|
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
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) {
|
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 [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
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(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader className="px-6 py-4">
|
<DialogHeader className="px-6 py-4">
|
||||||
<DialogTitle>QSO filter</DialogTitle>
|
<DialogTitle>{t('fltb.title')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-6 py-4 space-y-5">
|
<div className="px-6 py-4 space-y-5">
|
||||||
{/* Match mode + presets */}
|
{/* Match mode + presets */}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<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">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{(['AND', 'OR'] as const).map((m) => (
|
{(['AND', 'OR'] as const).map((m) => (
|
||||||
<button key={m} type="button" onClick={() => setMatch(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'}`}>
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{presetNames.length > 0 && (
|
{presetNames.length > 0 && (
|
||||||
<Select onValueChange={loadPreset}>
|
<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>
|
<SelectContent>
|
||||||
{presetNames.map((n) => (
|
{presetNames.map((n) => (
|
||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
@@ -220,14 +228,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
||||||
{conditions.length === 0 && (
|
{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) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<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) => {
|
<Select value={c.field} onValueChange={(v) => {
|
||||||
// Reset op if the new field type doesn't allow the current one.
|
// Reset op if the new field type doesn't allow the current one.
|
||||||
const allowed = opsFor(v).map((o) => o.value);
|
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>
|
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||||
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||||
value={c.value}
|
value={c.value}
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
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" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save preset */}
|
{/* Save preset */}
|
||||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
<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)}
|
onChange={(e) => setPresetName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
||||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
|
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
|
||||||
<Button onClick={apply}>Apply & close</Button>
|
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
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
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
const [p, setP] = useState<Profile | null>(null);
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -66,50 +68,66 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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">
|
<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">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Radio className="size-5 text-primary" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
<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 })} />
|
<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 })} />
|
<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>
|
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
<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>
|
<Label className="text-sm">{t('frm.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 })} />
|
<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>
|
<Label className="text-sm">{t('frm.name')}</Label>
|
||||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
{/* 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="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="font-medium">Award reference lists</div>
|
<div className="font-medium">{t('frm.awardRefs')}</div>
|
||||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
<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>
|
</Button>
|
||||||
</div>
|
</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>
|
</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">
|
<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>}
|
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
|
||||||
type FlexState = {
|
type FlexState = {
|
||||||
available: boolean; model?: string;
|
available: boolean; model?: string;
|
||||||
@@ -22,22 +26,29 @@ type FlexState = {
|
|||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
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[];
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
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;
|
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;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
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;
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
meters?: Meter[];
|
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 = {
|
const ZERO: FlexState = {
|
||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
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,
|
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,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
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,
|
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,
|
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,
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
amp_available: false, amp_operate: false,
|
amp_available: false, amp_operate: false,
|
||||||
@@ -77,7 +88,7 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
|
|||||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
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',
|
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]: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 }}
|
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -105,10 +116,10 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||||
}) {
|
}) {
|
||||||
const onCls = {
|
const onCls = {
|
||||||
emerald: 'bg-emerald-600 border-emerald-600 text-white',
|
emerald: 'bg-success border-success text-success-foreground',
|
||||||
violet: 'bg-violet-600 border-violet-600 text-white',
|
violet: 'bg-info border-info text-info-foreground',
|
||||||
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
cyan: 'bg-info border-info text-info-foreground',
|
||||||
amber: 'bg-amber-500 border-amber-500 text-white',
|
amber: 'bg-warning border-warning text-warning-foreground',
|
||||||
}[accent];
|
}[accent];
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} disabled={disabled}
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
@@ -135,29 +146,93 @@ 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.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
const METER_SEGMENTS = 26;
|
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;
|
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 span = hi - lo;
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
return (
|
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 onClick={onClick} title={title}
|
||||||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
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-[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 : (
|
{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></>
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
<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) => {
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
const on = i < lit;
|
const on = i < lit;
|
||||||
const frac = i / METER_SEGMENTS;
|
const frac = i / METER_SEGMENTS;
|
||||||
@@ -170,7 +245,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -189,7 +264,8 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
|
|
||||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||||
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
|
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
@@ -255,6 +331,13 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||||
: -1;
|
: -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 (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
@@ -264,34 +347,144 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
<Radio className="size-6 text-sky-400" />
|
<Radio className="size-6 text-sky-400" />
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col leading-tight">
|
||||||
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
<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>
|
||||||
<div className="flex-1" />
|
<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',
|
<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.available ? 'bg-muted text-muted-foreground'
|
||||||
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
: 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" />
|
<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>
|
</span>
|
||||||
</div>
|
</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 && (
|
{off && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-6">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TX + RX columns */}
|
{/* TX + RX columns */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* TRANSMIT */}
|
{/* TRANSMIT */}
|
||||||
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||||||
<div className="flex items-center gap-3">
|
<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))} />
|
<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>
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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))} />
|
<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>
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,22 +492,22 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
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',
|
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
|
TUNE
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
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',
|
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
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
|
title={t('flxp.splitHint')}
|
||||||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
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',
|
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-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
|
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
|
SPLIT
|
||||||
</button>
|
</button>
|
||||||
{st.split && !!st.tx_freq_hz && (
|
{st.split && !!st.tx_freq_hz && (
|
||||||
@@ -325,6 +518,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{!isCW ? (
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -338,7 +532,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||||
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||||
<div className="flex items-center gap-2">
|
<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"
|
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||||
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
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>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||||
@@ -351,22 +545,55 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
<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>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
/* 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="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
<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); }} />
|
<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>
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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))} />
|
<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>
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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))} />
|
<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>
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +605,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* 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) && (
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
@@ -398,6 +625,18 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<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}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -406,10 +645,10 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||||
<button type="button" disabled={rxOff}
|
<button type="button" disabled={rxOff}
|
||||||
title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
|
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
||||||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||||
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
|
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" />}
|
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||||
@@ -424,6 +663,10 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
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"
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
@@ -440,7 +683,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{CW_BW.map((bw) => (
|
{CW_BW.map((bw) => (
|
||||||
<button key={bw} type="button" disabled={rxOff}
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
@@ -458,7 +701,7 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
{!isCW && (
|
{!isCW && (
|
||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{SSB_BW.map((bw) => (
|
{SSB_BW.map((bw) => (
|
||||||
<button key={bw} type="button" disabled={rxOff}
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
@@ -485,104 +728,74 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } =
|
|||||||
|
|
||||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||||
{st.amp_available && (
|
{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">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
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',
|
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'}
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
{(pg.host || pg.connected) && (
|
{(pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
<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-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
<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">Fan</span>
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
<select
|
<select
|
||||||
disabled={!pg.connected}
|
disabled={!pg.connected}
|
||||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||||
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
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">Standard</option>
|
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
<option value="CONTEST">Contest</option>
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
<option value="BROADCAST">Broadcast</option>
|
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
{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>
|
</div>
|
||||||
|
{/* 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>
|
</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 < 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" />,
|
|
||||||
].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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,36 +1,128 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetIcomState, IcomRefresh,
|
GetIcomState, IcomRefresh,
|
||||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
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';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
|
||||||
type IcomState = {
|
type IcomState = {
|
||||||
available: boolean; model?: string; mode?: string;
|
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;
|
af_gain: number; rf_gain: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||||||
agc?: string; preamp: number; att: number; filter: number;
|
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 = {
|
const ZERO: IcomState = {
|
||||||
available: false, af_gain: 0, rf_gain: 0,
|
available: false, transmitting: false, split: false,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: 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,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
// 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 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 (
|
return (
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
type="range" min={0} max={100} value={v} disabled={disabled}
|
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
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',
|
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]: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]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||||
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -56,7 +148,7 @@ function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label:
|
|||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick}
|
<button type="button" onClick={onClick}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -95,25 +187,391 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
|
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||||||
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
|
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||||||
// the last refresh plus optimistic updates. Front-panel knob changes show after
|
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
|
||||||
// the next ↻ Refresh.
|
const v = Math.max(0, Math.min(100, value));
|
||||||
export function IcomPanel() {
|
const body = (
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
<>
|
||||||
const [busy, setBusy] = useState(false);
|
<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>;
|
||||||
|
}
|
||||||
|
|
||||||
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
// 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 () => {
|
const refresh = async () => {
|
||||||
setBusy(true);
|
|
||||||
try { await IcomRefresh(); } catch {}
|
try { await IcomRefresh(); } catch {}
|
||||||
await load();
|
await load();
|
||||||
setBusy(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
|
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -124,25 +582,228 @@ export function IcomPanel() {
|
|||||||
fn().catch(() => {});
|
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) {
|
if (!st.available) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
{t('icmp.notConnected')}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-3 space-y-3">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
<div className="flex items-center justify-between">
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
|
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||||||
<button type="button" onClick={refresh} disabled={busy}
|
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
|
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<Card icon={Radio} title="Receive" accent="#2563eb">
|
{/* 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">
|
<Row label="AF">
|
||||||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
<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>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||||
@@ -151,25 +812,54 @@ export function IcomPanel() {
|
|||||||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
<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>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||||
</Row>
|
</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">
|
<Row label="AGC">
|
||||||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Preamp">
|
<Row label={t('icmp.preamp')}>
|
||||||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
<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)))} />
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Att">
|
<Row label="Att">
|
||||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Filter">
|
<Row label={t('icmp.filter')}>
|
||||||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
<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)))} />
|
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
{/* 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}
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||||
@@ -178,9 +868,18 @@ export function IcomPanel() {
|
|||||||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||||
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
|||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
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 {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
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);
|
.setView([20, 0], 1);
|
||||||
const bm = BASEMAPS[basemap];
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
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);
|
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
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
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!m) return;
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
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);
|
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
|
}, [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);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef,
|
type ColDef,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -13,37 +14,19 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
import {
|
||||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
WorkedBefore,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
import { netctl } from '@/../wailsjs/go/models';
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
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 Net = netctl.Net;
|
type Net = netctl.Net;
|
||||||
type Station = netctl.Station;
|
type Station = netctl.Station;
|
||||||
@@ -66,6 +49,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
const [openId, setOpenId] = useState<string>('');
|
const [openId, setOpenId] = useState<string>('');
|
||||||
@@ -84,6 +68,36 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const activeGrid = useRef<any>(null);
|
const activeGrid = useRef<any>(null);
|
||||||
const rosterGrid = 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 isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
const refreshNets = useCallback(async () => {
|
const refreshNets = useCallback(async () => {
|
||||||
@@ -123,7 +137,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function newNet() {
|
async function newNet() {
|
||||||
const name = window.prompt('New NET name:');
|
const name = window.prompt(t('ncp.newNetPrompt'));
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
@@ -131,7 +145,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
async function renameNet() {
|
async function renameNet() {
|
||||||
if (!selId) return;
|
if (!selId) return;
|
||||||
const cur = nets.find((n) => n.id === selId);
|
const cur = nets.find((n) => n.id === selId);
|
||||||
const name = window.prompt('Rename NET:', cur?.name ?? '');
|
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
@@ -139,7 +153,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
async function deleteNet() {
|
async function deleteNet() {
|
||||||
if (!selId) return;
|
if (!selId) return;
|
||||||
const cur = nets.find((n) => n.id === selId);
|
const cur = nets.find((n) => n.id === selId);
|
||||||
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
|
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
||||||
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
@@ -147,7 +161,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
try {
|
try {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (active.length > 0 &&
|
if (active.length > 0 &&
|
||||||
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
|
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
||||||
await NetClose(); setOpenId('');
|
await NetClose(); setOpenId('');
|
||||||
} else {
|
} else {
|
||||||
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||||
@@ -190,6 +204,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
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,
|
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)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
finally { setLooking(false); }
|
finally { setLooking(false); }
|
||||||
@@ -206,7 +223,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
async function removeSelectedRoster() {
|
async function removeSelectedRoster() {
|
||||||
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||||
if (sel.length === 0) return;
|
if (sel.length === 0) return;
|
||||||
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
|
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
||||||
try {
|
try {
|
||||||
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||||
await refreshRoster(selId);
|
await refreshRoster(selId);
|
||||||
@@ -214,23 +231,23 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||||
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
|
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||||
{ headerName: 'Mode', field: 'mode', 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 S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||||
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
|
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||||
], []);
|
], [t]);
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: 'Country', field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
], []);
|
], [t]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||||
|
|
||||||
@@ -239,31 +256,31 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
<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}>
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||||
<Plus className="size-3.5" /> New NET
|
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
||||||
</Button>
|
</Button>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||||
value={selId}
|
value={selId}
|
||||||
disabled={isOpen}
|
disabled={isOpen}
|
||||||
onChange={(e) => setSelId(e.target.value)}
|
onChange={(e) => setSelId(e.target.value)}
|
||||||
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
||||||
>
|
>
|
||||||
<option value="">— select a NET —</option>
|
<option value="">{t('ncp.selectNetOption')}</option>
|
||||||
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||||
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</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-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
On air: <strong className="text-foreground">{active.length}</strong> ·
|
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||||
Roster: <strong className="text-foreground">{roster.length}</strong>
|
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||||
</span>
|
</span>
|
||||||
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,9 +288,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
{/* ACTIVE USERS (left) */}
|
{/* ACTIVE USERS (left) */}
|
||||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
<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-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
On air — active QSOs
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
@@ -283,6 +300,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rowData={active}
|
rowData={active}
|
||||||
columnDefs={activeCols}
|
columnDefs={activeCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
@@ -294,17 +312,72 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<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]"
|
<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); }}>
|
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||||
<MinusCircle className="size-3.5" /> Log & end selected
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* NET USERS / roster (right) */}
|
{/* NET USERS / roster (right) */}
|
||||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
<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-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
NET users — roster
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
@@ -315,6 +388,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
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)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
getRowId={(p) => String((p.data as any).callsign)}
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
@@ -323,15 +397,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<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}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||||
<UserPlus className="size-3.5" /> Add contact
|
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
||||||
<MinusCircle className="size-3.5" /> Remove
|
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||||
</Button>
|
</Button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
<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); }}>
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||||
<PlusCircle className="size-3.5" /> Put selected on air
|
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,23 +430,23 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add contact to NET</DialogTitle>
|
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
||||||
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3 px-5 py-2">
|
<div className="grid gap-3 px-5 py-2">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-[11px]">Callsign</Label>
|
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
||||||
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||||
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||||
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[11px]">Name</Label>
|
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
||||||
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -380,14 +454,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[11px]">Country</Label>
|
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
||||||
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
||||||
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||||
<PlusCircle className="size-3.5" /> Save in NET
|
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type Band = { band: string; is_default: boolean };
|
type Band = { band: string; is_default: boolean };
|
||||||
type Antenna = {
|
type Antenna = {
|
||||||
@@ -38,6 +39,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function OperatingPanel({ bands, onError }: Props) {
|
export function OperatingPanel({ bands, onError }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [tree, setTree] = useState<Station[]>([]);
|
const [tree, setTree] = useState<Station[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// expanded keeps which stations show their antennas; everything open by
|
// 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)); }
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function removeAntenna(stationId: number, antId: number) {
|
async function removeAntenna(stationId: number, antId: number) {
|
||||||
if (!confirm('Delete this antenna?')) return;
|
if (!confirm(t('op.deleteAntConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await DeleteOperatingAntenna(antId);
|
await DeleteOperatingAntenna(antId);
|
||||||
setTree((prev) => prev.map((s) =>
|
setTree((prev) => prev.map((s) =>
|
||||||
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
Define your rigs (stations) and the antennas connected to each one.
|
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||||
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.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={addStation}>
|
<Button size="sm" onClick={addStation}>
|
||||||
<Plus className="size-3.5" /> Add rig
|
<Plus className="size-3.5" /> {t('op.addRig')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tree.length === 0 ? (
|
{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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -212,6 +209,7 @@ function StationRow({
|
|||||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||||
onUpdateAntenna, onDeleteAntenna,
|
onUpdateAntenna, onDeleteAntenna,
|
||||||
}: StationRowProps) {
|
}: StationRowProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const editKey = `station:${station.id}`;
|
const editKey = `station:${station.id}`;
|
||||||
const isEditing = editing === editKey;
|
const isEditing = editing === editKey;
|
||||||
const [draft, setDraft] = useState({
|
const [draft, setDraft] = useState({
|
||||||
@@ -242,7 +240,7 @@ function StationRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
className="p-0.5 hover:bg-accent/40 rounded"
|
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" />}
|
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -253,12 +251,12 @@ function StationRow({
|
|||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 text-sm flex-1"
|
className="h-7 text-sm flex-1"
|
||||||
placeholder="Rig name (also stamped as MY_RIG)"
|
placeholder={t('op.rigNamePh')}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -269,8 +267,8 @@ function StationRow({
|
|||||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={commit}>Save</Button>
|
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -279,13 +277,13 @@ function StationRow({
|
|||||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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" />
|
<Edit2 className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
<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>
|
||||||
<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" />
|
<Trash2 className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -296,7 +294,7 @@ function StationRow({
|
|||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{(station.antennas ?? []).length === 0 ? (
|
{(station.antennas ?? []).length === 0 ? (
|
||||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||||
No antenna yet — click "Antenna" above to add one.
|
{t('op.noAntenna')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(station.antennas ?? []).map((a) => (
|
(station.antennas ?? []).map((a) => (
|
||||||
@@ -329,6 +327,7 @@ type AntennaRowProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const editKey = `antenna:${antenna.id}`;
|
const editKey = `antenna:${antenna.id}`;
|
||||||
const isEditing = editing === editKey;
|
const isEditing = editing === editKey;
|
||||||
const [draft, setDraft] = useState({ name: antenna.name });
|
const [draft, setDraft] = useState({ name: antenna.name });
|
||||||
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 text-sm flex-1"
|
className="h-7 text-sm flex-1"
|
||||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
placeholder={t('op.antNamePh')}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -412,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
? 'border-warning-border bg-warning-muted shadow-sm'
|
||||||
: enabled
|
: enabled
|
||||||
? 'border-primary/30 bg-primary/5'
|
? 'border-primary/30 bg-primary/5'
|
||||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||||
@@ -431,14 +430,14 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'bg-amber-400 text-white'
|
? 'bg-warning text-warning-foreground'
|
||||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
: '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')} />
|
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||||
{isDefault ? 'Default' : 'Set'}
|
{isDefault ? t('op.default') : t('op.set')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-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 { AgGridReact } from 'ag-grid-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -12,18 +12,10 @@ import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHun
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
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 = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: string;
|
band: string; mode: string; country: string; status: string;
|
||||||
@@ -53,22 +45,30 @@ const SERVICES = [
|
|||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
{ v: '_', label: '— leave —' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Y', label: 'Yes' },
|
{ v: 'Y', label: 'qslm.yes' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'qslm.no' },
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'qslm.requested' },
|
||||||
{ v: 'I', label: 'Ignore' },
|
{ v: 'I', label: 'qslm.ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||||
const QSL_VIA_OPTIONS = [
|
const QSL_VIA_OPTIONS = [
|
||||||
{ v: '_', label: '— leave —' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Bureau', label: 'Bureau' },
|
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
||||||
{ v: 'Direct', label: 'Direct' },
|
{ v: 'Direct', label: 'qslm.viaDirect' },
|
||||||
{ v: 'Electronic', label: 'Electronic' },
|
{ 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 = {
|
type LogQSO = {
|
||||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
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;
|
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||||
@@ -77,13 +77,14 @@ type LogQSO = {
|
|||||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
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 };
|
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 = [
|
const SENT_STATUSES = [
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'qslm.sentRequested' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'qslm.sentNo' },
|
||||||
{ v: 'Q', label: 'Queued' },
|
{ v: 'Q', label: 'qslm.sentQueued' },
|
||||||
{ v: 'Y', label: 'Yes (already sent)' },
|
{ v: 'Y', label: 'qslm.sentYes' },
|
||||||
{ v: 'I', label: 'Invalid' },
|
{ v: 'I', label: 'qslm.sentInvalid' },
|
||||||
{ v: '_', label: '— blank —' },
|
{ v: '_', label: 'qslm.sentBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function fmtDate(iso: string): string {
|
function fmtDate(iso: string): string {
|
||||||
@@ -109,6 +110,7 @@ export function fmtQslDate(s?: string): string {
|
|||||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
// 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.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
// The callsign this profile signs/uploads/downloads as for the selected
|
// The callsign this profile signs/uploads/downloads as for the selected
|
||||||
// service (Force station callsign, else the profile call). Shown so the user
|
// service (Force station callsign, else the profile call). Shown so the user
|
||||||
@@ -178,7 +180,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
notes: qslNotes,
|
notes: qslNotes,
|
||||||
comment: qslComment,
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
setPaperMsg(`${n} QSO updated.`);
|
setPaperMsg(t('qslm.qsoUpdated', { n }));
|
||||||
await searchPaper();
|
await searchPaper();
|
||||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||||
finally { setPaperBusy(false); }
|
finally { setPaperBusy(false); }
|
||||||
@@ -287,18 +289,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{/* Search toolbar */}
|
{/* 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 items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex flex-col gap-1">
|
<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}>
|
<Select value={service} onValueChange={setService}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{uploadCall && (
|
{uploadCall && (
|
||||||
<div className="flex flex-col gap-1">
|
<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
|
<span
|
||||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
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}
|
{uploadCall}
|
||||||
</span>
|
</span>
|
||||||
@@ -308,91 +310,91 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<>
|
<>
|
||||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||||
Sync hunter log
|
{t('qslm.syncHunterLog')}
|
||||||
</Button>
|
</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)} />
|
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||||
Only my profile callsign
|
{t('qslm.onlyMyCall')}
|
||||||
</label>
|
</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)} />
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||||
Add not-found QSOs to my log
|
{t('qslm.addMissing')}
|
||||||
</label>
|
</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' ? (
|
) : service === 'paper' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<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}
|
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||||
onChange={(e) => setPaperCall(e.target.value)}
|
onChange={(e) => setPaperCall(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||||
placeholder="e.g. DL1ABC" />
|
placeholder={t('qslm.callsignPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
<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" />}
|
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
Search
|
{t('qslm.search')}
|
||||||
</Button>
|
</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">
|
<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}>
|
<Select value={sent} onValueChange={setSent}>
|
||||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
<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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
Select required
|
{t('qslm.selectRequired')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{service === 'paper' && paperRows.length > 0 && (
|
{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' && (
|
{!showLog && viewMode === 'confirmations' && (
|
||||||
<div className="flex flex-col gap-1">
|
<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}>
|
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
|
||||||
<SelectItem value="new">New (any)</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">New band</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
<SelectItem value="slot">New slot</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{logLines.length > 0 && (
|
{logLines.length > 0 && (
|
||||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{viewMode === 'confirmations'
|
{viewMode === 'confirmations'
|
||||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||||
: `${rows.length} found · ${selectedCount} selected`}
|
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content: log OR results grid */}
|
{/* Content: log OR results grid */}
|
||||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
<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' ? (
|
{service === 'paper' ? (
|
||||||
paperRows.length === 0 ? (
|
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>
|
||||||
) : (
|
) : (
|
||||||
// Same grid as Recent QSOs (full columns + column picker). Selection
|
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||||
// drives which QSOs the apply-form below updates; a search selects all.
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
@@ -409,25 +411,25 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<div className="space-y-3">
|
<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>}
|
{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 && (
|
{!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 && (
|
{potaRes && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||||
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
|
{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 > 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>
|
</div>
|
||||||
{potaRes.unmatched_list?.length > 0 && (
|
{potaRes.unmatched_list?.length > 0 && (
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<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">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</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">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
|
||||||
<th className="py-1.5 px-2">Why unmatched</th>
|
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -435,7 +437,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<tr key={i}
|
<tr key={i}
|
||||||
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
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)}
|
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 font-bold">{u.activator}</td>
|
||||||
<td className="py-1 px-2 font-mono">{u.date}</td>
|
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||||
<td className="py-1 px-2">{u.band}</td>
|
<td className="py-1 px-2">{u.band}</td>
|
||||||
@@ -455,27 +457,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : showLog ? (
|
) : showLog ? (
|
||||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||||
{logLines.length === 0 ? (
|
{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) => (
|
) : logLines.map((l, i) => (
|
||||||
<div key={i} className={cn(
|
<div key={i} className={cn(
|
||||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
/FAIL|failed|error/i.test(l) ? 'text-danger'
|
||||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
|
||||||
: 'text-foreground/90')}>{l}</div>
|
: '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>
|
</div>
|
||||||
) : viewMode === 'confirmations' ? (
|
) : viewMode === 'confirmations' ? (
|
||||||
shownConfs.length === 0 ? (
|
shownConfs.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<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">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</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">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</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.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -487,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">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
<td className="py-1 px-2">
|
<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_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-amber-100 text-amber-800 border border-amber-300">NEW BAND</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-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</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>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -498,7 +500,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
) : rows.length === 0 ? (
|
) : 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="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
@@ -515,44 +517,44 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{service === 'paper' && (
|
{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 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">
|
<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">
|
<div className="flex gap-1.5">
|
||||||
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
<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>
|
</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>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<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">
|
<div className="flex gap-1.5">
|
||||||
<Select value={qslSent} onValueChange={setQslSent}>
|
<Select value={qslSent} onValueChange={setQslSent}>
|
||||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
<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>
|
</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>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
||||||
<Select value={qslVia} onValueChange={setQslVia}>
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
|
<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="e.g. paid 3€" />
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
|
<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="comment" />
|
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||||
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||||
Apply to {paperSel.size} selected
|
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -562,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 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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
title="Fetch confirmations from the service and update received status">
|
title={t('qslm.downloadTitle')}>
|
||||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Date window */}
|
{/* Date window */}
|
||||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
<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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="last">Since last download</SelectItem>
|
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
|
||||||
<SelectItem value="date">Since date…</SelectItem>
|
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{sinceMode === 'date' && (
|
{sinceMode === 'date' && (
|
||||||
@@ -582,16 +584,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
value={sinceDate}
|
value={sinceDate}
|
||||||
onChange={(e) => setSinceDate(e.target.value)}
|
onChange={(e) => setSinceDate(e.target.value)}
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
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)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
Add not-found
|
{t('qslm.addNotFound')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } 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;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
@@ -20,12 +21,12 @@ type Props = {
|
|||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
{ service: 'qrz', name: 'QRZ.com' },
|
||||||
{ service: 'clublog', label: 'Send to Club Log' },
|
{ service: 'clublog', name: 'Club Log' },
|
||||||
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', label: 'Send to LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
@@ -35,6 +36,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
|||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// 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) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -60,29 +62,29 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Globe2 className="size-4 text-primary" />
|
<Globe2 className="size-4 text-primary" />
|
||||||
<span>Fix country & zones from cty.dat</span>
|
<span>{t('qctx.fixCountry')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4 text-sky-600" />
|
<RefreshCw className="size-4 text-info" />
|
||||||
<span>Update from QRZ.com</span>
|
<span>{t('qctx.updateQrz')}</span>
|
||||||
</button>
|
</button>
|
||||||
{onUpdateFromClublog && (
|
{onUpdateFromClublog && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<BadgeCheck className="size-4 text-violet-600" />
|
<BadgeCheck className="size-4 text-info" />
|
||||||
<span>Update from ClubLog (exceptions)</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-amber-600" />
|
<Mail className="size-4 text-warning" />
|
||||||
<span>Send OpsLog QSL by e-mail</span>
|
<span>{t('qctx.sendQslEmail')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSendRecording && (
|
{onSendRecording && (
|
||||||
@@ -103,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-rose-600" />
|
<Mail className="size-4 text-danger" />
|
||||||
<span>Send recording by e-mail</span>
|
<span>{t('qctx.sendRecording')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -117,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<PencilLine className="size-4 text-indigo-600" />
|
<PencilLine className="size-4 text-info" />
|
||||||
<span>Bulk edit field… ({n})</span>
|
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -131,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-sky-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>Export selected to ADIF ({n})</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
@@ -140,8 +142,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportFiltered(); onClose(); }}
|
onClick={() => { onExportFiltered(); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-violet-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>Export filtered view to ADIF (no limit)</span>
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExportCabrilloSelected && (
|
{onExportCabrilloSelected && (
|
||||||
@@ -149,8 +151,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-amber-600" />
|
<FileDown className="size-4 text-warning" />
|
||||||
<span>Export selected to Cabrillo ({n})</span>
|
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExportCabrilloFiltered && (
|
{onExportCabrilloFiltered && (
|
||||||
@@ -158,8 +160,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"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-amber-700" />
|
<FileDown className="size-4 text-warning" />
|
||||||
<span>Export filtered view to Cabrillo (no limit)</span>
|
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -168,14 +170,14 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
{onSendTo && (
|
{onSendTo && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
{UPLOAD_TARGETS.map((t) => (
|
{UPLOAD_TARGETS.map((target) => (
|
||||||
<button
|
<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"
|
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" />
|
<Upload className="size-4 text-success" />
|
||||||
<span>{t.label}</span>
|
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -185,11 +187,11 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
|
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(); }}
|
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
<span>Delete {n} QSO{n > 1 ? 's' : ''}…</span>
|
<span>{t('qctx.delete', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
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 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'];
|
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 = [
|
const QSL_STATUSES = [
|
||||||
{ value: '_', label: '—' },
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
{ value: 'Y', label: 'Yes' },
|
{ value: 'Y', label: 'qedit.qslYes' },
|
||||||
{ value: 'N', label: 'No' },
|
{ value: 'N', label: 'qedit.qslNo' },
|
||||||
{ value: 'R', label: 'Requested' },
|
{ value: 'R', label: 'qedit.qslRequested' },
|
||||||
{ value: 'I', label: 'Ignore' },
|
{ 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'];
|
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: '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 },
|
{ 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.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const v = (value || '').toUpperCase();
|
const v = (value || '').toUpperCase();
|
||||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||||
if (v === '') {
|
if (v === '') {
|
||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
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';
|
// One colour per state, and each colour means something:
|
||||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
// Yes green — confirmed, the thing you wanted
|
||||||
: v === 'R' ? 'bg-orange-400 text-white'
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
: v === 'I' ? 'bg-stone-400 text-white'
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
: 'bg-amber-400 text-amber-950';
|
// 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>;
|
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 }) {
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
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);
|
// 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
|
// 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.
|
// 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.
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||||
const awardFieldRef = useRef<Record<string, string>>({});
|
const awardFieldRef = useRef<Record<string, string>>({});
|
||||||
const [awardRefs, setAwardRefs] = useState('');
|
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 }>>([]);
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||||
|
|
||||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
// 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)
|
.filter((r: any) => r.pickable)
|
||||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
|
seedAwardRefsRef.current = seed;
|
||||||
setAwardRefs(seed);
|
setAwardRefs(seed);
|
||||||
} catch { /* leave manual refs empty on failure */ }
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
})
|
})
|
||||||
@@ -242,7 +268,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||||
async function fetchLookup() {
|
async function fetchLookup() {
|
||||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||||
if (!call) { setLocalErr('Callsign required'); return; }
|
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||||
setLooking(true);
|
setLooking(true);
|
||||||
setLocalErr('');
|
setLocalErr('');
|
||||||
try {
|
try {
|
||||||
@@ -266,14 +292,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
lon: r.lon || d.lon,
|
lon: r.lon || d.lon,
|
||||||
}));
|
}));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||||||
} finally {
|
} finally {
|
||||||
setLooking(false);
|
setLooking(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setLocalErr('');
|
setLocalErr('');
|
||||||
const out: any = {
|
const out: any = {
|
||||||
@@ -317,7 +343,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// the dedicated columns, then route the picked refs back onto the payload
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||||
onSave(out);
|
onSave(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,25 +363,25 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<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">
|
<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>
|
<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>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||||
<TabsList className="px-3 overflow-x-auto">
|
<TabsList className="px-3 overflow-x-auto">
|
||||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||||||
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||||||
<TabsTrigger value="extras">
|
<TabsTrigger value="extras">
|
||||||
ADIF fields
|
{t('qedit.tabAdifFields')}
|
||||||
{extrasCount > 0 && (
|
{extrasCount > 0 && (
|
||||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -373,7 +399,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{/* Top: Callsign + RST + Fetch */}
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<div className="flex items-end gap-2 mb-3">
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<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"
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -382,39 +408,39 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<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>
|
<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}
|
<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…">
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
{/* ── Left column ── */}
|
{/* ── Left column ── */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<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">
|
<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)}>
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)}>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)}>
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Country</Label>
|
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
|
||||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
|
||||||
onChange={onCountryChange} className="flex-1" />
|
onChange={onCountryChange} className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -422,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" />
|
<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>
|
<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.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" />}
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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={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" />
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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={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" />
|
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
@@ -439,7 +465,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* ── Right column ── */}
|
{/* ── Right column ── */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<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>
|
<div>
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||||
@@ -448,16 +474,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// Prefill an empty end with the start time so the user
|
// Prefill an empty end with the start time so the user
|
||||||
// only tweaks the minutes instead of typing a full date.
|
// only tweaks the minutes instead of typing a full date.
|
||||||
if (on && !dateOff) setDateOff(dateOn);
|
if (on && !dateOff) setDateOff(dateOn);
|
||||||
}} /> QSO End (UTC)
|
}} /> {t('qedit.qsoEnd')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<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 className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
<div><Label>{t('qedit.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.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -466,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">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
{/* Left column */}
|
{/* Left column */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<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>{t('qedit.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.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Continent</Label>
|
<Label>{t('qedit.continent')}</Label>
|
||||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -479,17 +505,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></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>
|
</div>
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<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 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>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 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>
|
||||||
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', 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>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', 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>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -503,13 +529,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<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">
|
<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>
|
</p>
|
||||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||||
{computedRefs.length === 0 ? (
|
{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) => (
|
computedRefs.map((r) => (
|
||||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||||
@@ -533,44 +559,48 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{/* Left: edit one confirmation channel at a time */}
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
<div className="flex-1 max-w-sm space-y-3">
|
<div className="flex-1 max-w-sm space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Manage Confirmation</Label>
|
<Label>{t('qedit.manageConf')}</Label>
|
||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
<div><Label>Received</Label>
|
<div><Label>{t('qedit.received')}</Label>
|
||||||
{def.rcvd
|
{def.rcvd
|
||||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
: <Input disabled value="—" />}
|
: <Input disabled value="—" />}
|
||||||
</div>
|
</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>{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>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.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 && (
|
{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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel */}
|
{/* Right: live status grid for every channel.
|
||||||
<div className="w-72 shrink-0">
|
Sized by its content, not pinned to a width: a fixed 288px box
|
||||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
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>
|
<thead>
|
||||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="text-left font-semibold">Type</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
<th className="font-semibold">Sent</th>
|
<th className="font-semibold">{t('qedit.sent')}</th>
|
||||||
<th className="font-semibold">Received</th>
|
<th className="font-semibold">{t('qedit.received')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
<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"><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>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -585,88 +615,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<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={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="rcvd exchange" onChange={(e) => setExchange('srx', 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="sent exchange" onChange={(e) => setExchange('stx', 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="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
<F label={t('qedit.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={t('qedit.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.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sat" className="mt-0">
|
<TabsContent value="sat" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<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)}>
|
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</F>
|
</F>
|
||||||
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', 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="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', 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="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></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="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', 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="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="mystation" className="mt-0 space-y-3">
|
<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">
|
<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={t('qedit.stationCallsign')} 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={t('qedit.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={t('qedit.myGrid')}><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={t('qedit.gridExt')}><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={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="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
<F label={t('qedit.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.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="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={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="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.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="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={t('qedit.sotaRef')}><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.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="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="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={t('qedit.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={t('qedit.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={t('qedit.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={t('qedit.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.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||||
<div>
|
<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">
|
<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"><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={t('qedit.sigInfo')} 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={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="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Power & propagation */}
|
{/* Power & propagation */}
|
||||||
<div>
|
<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">
|
<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={t('qedit.rxPower')}><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={t('qedit.distance')}><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={t('qedit.aIndex')}><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.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>
|
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Identity & clubs */}
|
{/* Identity & clubs */}
|
||||||
<div>
|
<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">
|
<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={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="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={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="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></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="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="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>
|
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||||
@@ -676,28 +706,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* Flags & credits */}
|
{/* Flags & credits */}
|
||||||
<div>
|
<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">
|
<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={t('qedit.qsoComplete')}><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={t('qedit.qsoRandom')}><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.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="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={t('qedit.creditGranted')} 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.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My station extras */}
|
{/* My station extras */}
|
||||||
<div>
|
<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">
|
<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={t('qedit.myName')} 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={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="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></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="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', 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="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', 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="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', 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="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.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>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -710,11 +740,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
<DialogFooter className="!flex-row gap-2">
|
<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}>
|
<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>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||||||
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
@@ -13,34 +14,15 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
// Shared theme (follows the app palette); this table runs slightly taller rows.
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
@@ -102,127 +84,138 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
|||||||
|
|
||||||
// Shared so the Worked-before grid (which now also shows full QSO records)
|
// Shared so the Worked-before grid (which now also shows full QSO records)
|
||||||
// can offer the exact same column choices without duplicating the catalog.
|
// 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 ──
|
// ── 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: 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: '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: 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: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
{ 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: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', 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: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
{ 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: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
{ 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: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
{ 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: '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: 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: '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: 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: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ 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: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' 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: '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.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 ──
|
// ── Contacted station ──
|
||||||
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
|
{ 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: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, 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: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
|
{ group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.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: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.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: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 },
|
||||||
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
|
{ group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 },
|
||||||
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
|
{ group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.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: 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: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
|
{ 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: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, 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: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', 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: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, 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: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' 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: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, 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: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, 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: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_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: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
|
{ 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: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ 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: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' 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: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.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.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── QSL ──
|
||||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
|
{ 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: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' 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: '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: 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: '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: 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: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
|
{ 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: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
|
{ 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: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' 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 ──
|
// ── LoTW ──
|
||||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
|
{ 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: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' 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: '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: 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: '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_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 ──
|
// ── eQSL ──
|
||||||
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
|
{ 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: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' 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: '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: 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: '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_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.
|
// 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.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec 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 },
|
{ 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) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// 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
|
// 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
|
// 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.
|
// 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: 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: '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: 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: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
{ 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: '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: 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: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
{ 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: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_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: '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: 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: '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.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 ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
{ 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: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
|
{ 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: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' 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: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
|
{ 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: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_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: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
|
{ group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 },
|
||||||
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
|
{ group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.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.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 },
|
||||||
|
|
||||||
// ── Propagation / antenna ──
|
// ── Propagation / antenna ──
|
||||||
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
|
{ 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: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
|
{ 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: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
|
{ 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: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
{ 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: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' 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: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
{ 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) ──
|
// ── 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: 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: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
|
{ 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: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, 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: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
|
{ 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: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
|
{ 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: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
|
{ 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: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
|
{ 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: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, 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: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_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: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
|
{ 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: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, 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: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_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: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, 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: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' 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: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
|
{ 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: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
|
{ 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: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
|
{ 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: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
|
{ 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: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' 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 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ 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: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
|
{ group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.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: 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: '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.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 = [
|
export const GROUP_ORDER = [
|
||||||
@@ -230,11 +223,25 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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) {
|
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 gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
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,
|
// 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.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
function onCellContextMenu(e: any) {
|
function onCellContextMenu(e: any) {
|
||||||
@@ -262,6 +269,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before 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.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -271,13 +302,29 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols]);
|
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||||
|
|
||||||
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
}
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -336,10 +383,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -367,17 +426,20 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<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)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title="Clear all column filters">
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> Clear filters
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
@@ -426,10 +488,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Columns</DialogTitle>
|
<DialogTitle>{t('rqg.columns')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Recent QSOs table.
|
{t('rqg.pickerDesc')}
|
||||||
Your selection is saved.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
@@ -439,10 +500,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<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">
|
<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">
|
<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-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)}>none</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -462,10 +523,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
{awardCols && awardCols.length > 0 && (
|
{awardCols && awardCols.length > 0 && (
|
||||||
<div className="rounded-md border border-border p-2.5">
|
<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">
|
<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">
|
<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-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`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)); }}>none</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -481,8 +542,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
|
||||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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">
|
<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" />
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
<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'} />
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{pattern && (
|
{pattern && (
|
||||||
<span
|
<span
|
||||||
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||||
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||||
: 'bg-emerald-200 text-emerald-900')}
|
: 'bg-success-muted text-success-muted-foreground')}
|
||||||
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
: 'Ultrabeam normal'}>
|
: 'Ultrabeam normal'}>
|
||||||
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
</span>
|
</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')}°` : '—'}
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
</span>
|
</span>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export interface RecentSpotQSO {
|
export interface RecentSpotQSO {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
@@ -33,6 +34,7 @@ interface Props {
|
|||||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||||
// contact into the form.
|
// contact into the form.
|
||||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [call, setCall] = useState('');
|
const [call, setCall] = useState('');
|
||||||
const [freqKHz, setFreqKHz] = useState('');
|
const [freqKHz, setFreqKHz] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@@ -56,8 +58,8 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
async function send() {
|
async function send() {
|
||||||
const c = call.trim().toUpperCase();
|
const c = call.trim().toUpperCase();
|
||||||
const f = parseFloat(freqKHz);
|
const f = parseFloat(freqKHz);
|
||||||
if (!c) { setError('Callsign required'); return; }
|
if (!c) { setError(t('spm.callRequired')); return; }
|
||||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@@ -84,25 +86,25 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-5 py-3 space-y-3">
|
<div className="px-5 py-3 space-y-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Label className="mb-1">Callsign</Label>
|
<Label className="mb-1">{t('spm.callsign')}</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={callRef}
|
ref={callRef}
|
||||||
className="font-mono uppercase font-bold"
|
className="font-mono uppercase font-bold"
|
||||||
value={call}
|
value={call}
|
||||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
placeholder="DX call"
|
placeholder={t('spm.callPh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1">Frequency (kHz)</Label>
|
<Label className="mb-1">{t('spm.frequency')}</Label>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={freqKHz}
|
value={freqKHz}
|
||||||
@@ -113,18 +115,18 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Label className="mb-1">Message</Label>
|
<Label className="mb-1">{t('spm.message')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
placeholder="e.g. CW · TNX QSO"
|
placeholder={t('spm.messagePh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recent.length > 0 && (
|
{recent.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||||
{recent.map((q, i) => (
|
{recent.map((q, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -142,17 +144,17 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
<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>
|
</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}>
|
<Button onClick={send} disabled={busy}>
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
{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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,15 +37,15 @@ export function ShutdownProgress() {
|
|||||||
) : steps.map((s) => (
|
) : steps.map((s) => (
|
||||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
<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 === '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" />}
|
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={
|
<div className={
|
||||||
s.status === 'done' ? 'text-foreground'
|
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'
|
: s.status === 'pending' ? 'text-muted-foreground'
|
||||||
: 'text-foreground font-medium'
|
: '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,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Local mirror of the Go struct — we duplicate the type rather than depend
|
// 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
|
// on the generated Wails model because the inline `as any` casts are
|
||||||
@@ -23,7 +24,7 @@ type UDPConfig = {
|
|||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
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: boolean;
|
||||||
multicast_group: string;
|
multicast_group: string;
|
||||||
destination_ip: string;
|
destination_ip: string;
|
||||||
@@ -44,43 +45,58 @@ const SERVICE_TYPES: Array<{
|
|||||||
{
|
{
|
||||||
id: 'wsjt',
|
id: 'wsjt',
|
||||||
direction: 'inbound',
|
direction: 'inbound',
|
||||||
label: 'WSJT-X / JTDX / MSHV',
|
label: 'udpp.svcWsjtLabel',
|
||||||
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
|
hint: 'udpp.svcWsjtHint',
|
||||||
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'adif',
|
id: 'adif',
|
||||||
direction: 'inbound',
|
direction: 'inbound',
|
||||||
label: 'ADIF message (JTAlert, GridTracker)',
|
label: 'udpp.svcAdifLabel',
|
||||||
hint: 'Receives a single ADIF record per packet and logs it.',
|
hint: 'udpp.svcAdifHint',
|
||||||
defaults: { port: 2333, multicast: false },
|
defaults: { port: 2333, multicast: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'n1mm',
|
id: 'n1mm',
|
||||||
direction: 'inbound',
|
direction: 'inbound',
|
||||||
label: 'N1MM Logger+ (contest XML)',
|
label: 'udpp.svcN1mmLabel',
|
||||||
hint: 'Receives contest QSOs as XML messages.',
|
hint: 'udpp.svcN1mmHint',
|
||||||
defaults: { port: 12060, multicast: false },
|
defaults: { port: 12060, multicast: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'remote_call',
|
id: 'remote_call',
|
||||||
direction: 'inbound',
|
direction: 'inbound',
|
||||||
label: 'Remote callsign (DXHunter, custom)',
|
label: 'udpp.svcRemoteLabel',
|
||||||
hint: 'A short text packet containing just a callsign — fills the entry field.',
|
hint: 'udpp.svcRemoteHint',
|
||||||
defaults: { port: 12090, multicast: false },
|
defaults: { port: 12090, multicast: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'db_updated',
|
id: 'db_updated',
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
label: 'DB updated → notify other apps',
|
label: 'udpp.svcDbLabel',
|
||||||
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
hint: 'udpp.svcDbHint',
|
||||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
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 };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
||||||
@@ -123,7 +139,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
async function remove(id: number) {
|
||||||
if (!confirm('Delete this UDP connection?')) return;
|
if (!confirm(t('udpp.deleteConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await DeleteUDPIntegration(id);
|
await DeleteUDPIntegration(id);
|
||||||
setItems((prev) => prev.filter((x) => x.id !== 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)); }
|
} 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 inbound = items.filter((i) => i.direction === 'inbound');
|
||||||
const outbound = items.filter((i) => i.direction === 'outbound');
|
const outbound = items.filter((i) => i.direction === 'outbound');
|
||||||
@@ -151,15 +167,11 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
UDP connections let OpsLog talk to other ham radio software. Inbound
|
{t('udpp.intro')}
|
||||||
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.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Inbound — OpsLog listens"
|
title={t('udpp.inboundTitle')}
|
||||||
icon={<ArrowDownToLine className="size-4" />}
|
icon={<ArrowDownToLine className="size-4" />}
|
||||||
items={inbound}
|
items={inbound}
|
||||||
onAdd={() => addNew('inbound')}
|
onAdd={() => addNew('inbound')}
|
||||||
@@ -168,7 +180,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
onToggle={toggleEnabled}
|
onToggle={toggleEnabled}
|
||||||
/>
|
/>
|
||||||
<Section
|
<Section
|
||||||
title="Outbound — OpsLog sends"
|
title={t('udpp.outboundTitle')}
|
||||||
icon={<ArrowUpFromLine className="size-4" />}
|
icon={<ArrowUpFromLine className="size-4" />}
|
||||||
items={outbound}
|
items={outbound}
|
||||||
onAdd={() => addNew('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">
|
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||||
<Button size="sm" variant="outline" onClick={reloadServers}>
|
<Button size="sm" variant="outline" onClick={reloadServers}>
|
||||||
<RefreshCcw className="size-3.5" /> Reload all
|
<RefreshCcw className="size-3.5" /> {t('udpp.reloadAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
Restarts every enabled listener after a manual change.
|
{t('udpp.reloadHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,6 +222,7 @@ function Section({
|
|||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onToggle: (c: UDPConfig) => void;
|
onToggle: (c: UDPConfig) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border bg-card">
|
<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">
|
<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>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
{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">
|
<div className="divide-y divide-border/60">
|
||||||
{items.map((c) => {
|
{items.map((c) => {
|
||||||
@@ -231,9 +244,9 @@ function Section({
|
|||||||
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground font-mono">
|
<div className="text-[11px] text-muted-foreground font-mono">
|
||||||
@@ -269,6 +282,7 @@ function EditDialog({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSave: (c: UDPConfig) => void;
|
onSave: (c: UDPConfig) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
||||||
// Service-type list filtered to this connection's direction.
|
// Service-type list filtered to this connection's direction.
|
||||||
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
||||||
@@ -292,31 +306,33 @@ function EditDialog({
|
|||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<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>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{currentService?.hint}
|
{currentService ? t(currentService.hint) : ''}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Name</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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'])}>
|
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{services.map((s) => (
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -324,7 +340,7 @@ function EditDialog({
|
|||||||
|
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Port</Label>
|
<Label>{t('udpp.port')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={65535}
|
min={1} max={65535}
|
||||||
@@ -341,13 +357,13 @@ function EditDialog({
|
|||||||
checked={draft.multicast}
|
checked={draft.multicast}
|
||||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
||||||
/>
|
/>
|
||||||
<span>Multicast</span>
|
<span>{t('udpp.multicast')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{draft.multicast && (
|
{draft.multicast && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Multicast group</Label>
|
<Label>{t('udpp.multicastGroup')}</Label>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="224.0.0.1"
|
placeholder="224.0.0.1"
|
||||||
@@ -355,14 +371,14 @@ function EditDialog({
|
|||||||
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.direction === 'outbound' && (
|
{draft.direction === 'outbound' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Destination IP</Label>
|
<Label>{t('udpp.destinationIp')}</Label>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="127.0.0.1"
|
placeholder="127.0.0.1"
|
||||||
@@ -377,17 +393,17 @@ function EditDialog({
|
|||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
||||||
/>
|
/>
|
||||||
<span>Enabled</span>
|
<span>{t('udpp.enabled')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onSave(draft)}
|
onClick={() => onSave(draft)}
|
||||||
disabled={!draft.name.trim() || !draft.port}
|
disabled={!draft.name.trim() || !draft.port}
|
||||||
>
|
>
|
||||||
Save
|
{t('udpp.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export interface WKMacro { label: string; text: string }
|
export interface WKMacro { label: string; text: string }
|
||||||
export interface WKStatus {
|
export interface WKStatus {
|
||||||
@@ -25,6 +26,9 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
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;
|
onSelectPort: (p: string) => void;
|
||||||
onRefreshPorts: () => void;
|
onRefreshPorts: () => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -48,12 +52,13 @@ interface Props {
|
|||||||
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
// 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.
|
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||||
export function WinkeyerPanel({
|
export function WinkeyerPanel({
|
||||||
status, ports, port, wpm, macros, sent,
|
status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
|
||||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
onSend, onSendMacro, onStop, onClose,
|
onSend, onSendMacro, onStop, onClose,
|
||||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [cwText, setCwText] = useState('');
|
const [cwText, setCwText] = useState('');
|
||||||
const [speed, setSpeed] = useState(wpm);
|
const [speed, setSpeed] = useState(wpm);
|
||||||
// Step the speed (compact +/- control replaces the old slider).
|
// Step the speed (compact +/- control replaces the old slider).
|
||||||
@@ -94,32 +99,39 @@ export function WinkeyerPanel({
|
|||||||
{/* Header / connection bar */}
|
{/* Header / connection bar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
<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" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
|
{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" />
|
<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)}>
|
<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>
|
<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>)}
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<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" />
|
<X className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,50 +142,70 @@ export function WinkeyerPanel({
|
|||||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||||
<div className={cn(
|
<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',
|
'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>}
|
{sent || <span className="opacity-50">—</span>}
|
||||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||||
</div>
|
</div>
|
||||||
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
{/* 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="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
||||||
<span className="text-[9px] text-muted-foreground">wpm</span>
|
<span className="text-[9px] text-muted-foreground">wpm</span>
|
||||||
<div className="flex flex-col -my-0.5">
|
<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>
|
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>
|
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* CW text */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
CW text
|
{t('wkp.cwText')}
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
{source === 'winkeyer' && (
|
||||||
title="Key each character live as you type (backspace removes un-sent chars)">
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
send on type
|
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||||
</label>
|
{t('wkp.sendOnType')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={cwText}
|
value={cwText}
|
||||||
onChange={(e) => onCwChange(e.target.value)}
|
onChange={(e) => onCwChange(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
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}
|
disabled={!connected}
|
||||||
className="font-mono uppercase"
|
className="font-mono uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
<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>
|
||||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
|
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||||
<Square className="size-3.5" /> Stop
|
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,19 +213,19 @@ export function WinkeyerPanel({
|
|||||||
someone answers. The seconds box is the gap AFTER the message. */}
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
<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}
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
Auto-call
|
{t('wkp.autoCall')}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[11px] text-muted-foreground">gap</span>
|
<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="Seconds to wait after the message before resending">
|
<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}
|
<input type="number" min={0} max={120}
|
||||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
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)} />
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* 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-[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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, Star } from 'lucide-react';
|
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -12,33 +13,14 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
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 { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
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 WorkedEntry = QSOForm; // entries are now full QSO records
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
@@ -69,10 +51,14 @@ function fmtDate(s: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, 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 gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
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>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
@@ -109,13 +95,13 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: `${a.name} — reference this QSO counts for`,
|
headerTooltip: t('wbg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols]);
|
}, [awardCols, COL_CATALOG, t]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -185,14 +171,14 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
if (!hasCall) {
|
if (!hasCall) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (busy && count === 0) {
|
if (busy && count === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||||
checking…
|
{t('wbg.checking')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,8 +186,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<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" />
|
<Star className="size-8 text-primary fill-current" />
|
||||||
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
|
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
|
||||||
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
|
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,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-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||||
<div className="flex items-baseline gap-2">
|
<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>
|
<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>
|
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||||
</div>
|
</div>
|
||||||
{wb?.dxcc_name && (
|
{wb?.dxcc_name && (
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<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 && (
|
{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>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title="Clear all column filters">
|
title={t('wbg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> Clear filters
|
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,16 +261,16 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
|
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Worked-before columns</DialogTitle>
|
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Worked-before table.
|
{t('wbg.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
@@ -294,10 +280,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<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">
|
<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">
|
<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-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)}>none</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -317,10 +303,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
{awardCols && awardCols.length > 0 && (
|
{awardCols && awardCols.length > 0 && (
|
||||||
<div className="rounded-md border border-border p-2.5">
|
<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">
|
<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">
|
<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-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)); }}>none</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>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -336,8 +322,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
|
||||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
|||||||
|
|
||||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
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 avail = box.w - 56;
|
||||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||||
let cursor = 28;
|
let cursor = 28;
|
||||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
<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">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{box.title}
|
{box.title}
|
||||||
</text>
|
</text>
|
||||||
{cols.map(({ f, x }) => (
|
{cols.map(({ f, x }) => (
|
||||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
<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">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
</text>
|
</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">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{values[f] ?? ''}
|
{values[f] ?? ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{box.footer && (
|
{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">
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||||
{box.footer}
|
{box.footer}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
onChange={(w) => onPatchBox({ w })} />
|
onChange={(w) => onPatchBox({ w })} />
|
||||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||||
onChange={(h) => onPatchBox({ h })} />
|
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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
<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]">
|
<DialogContent className="max-w-[1260px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="size-5 text-amber-500" />
|
<Sparkles className="size-5 text-warning" />
|
||||||
QSL card designer
|
QSL card designer
|
||||||
{view !== 'home' && (
|
{view !== 'home' && (
|
||||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
<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">
|
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||||
{error && (
|
{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' && (
|
{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="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">
|
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||||
<span className="truncate text-sm font-medium">
|
<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}
|
{t.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 gap-0.5">
|
<span className="flex shrink-0 gap-0.5">
|
||||||
@@ -299,7 +299,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm"
|
<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)}>
|
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -323,7 +323,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
{proposals.map((p, i) => (
|
{proposals.map((p, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
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)}
|
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||||
>
|
>
|
||||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
<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]">
|
<DialogContent className="max-w-[820px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 px-6 py-5">
|
<div className="space-y-3 px-6 py-5">
|
||||||
{error && (
|
{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 && (
|
{open && !templateId && !error && (
|
||||||
@@ -107,7 +107,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{sent ? (
|
{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.
|
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
|||||||
h: number;
|
h: number;
|
||||||
bg: string;
|
bg: string;
|
||||||
bg_opacity: number;
|
bg_opacity: number;
|
||||||
|
fg?: string; // text colour (default dark); set it when using a dark background
|
||||||
radius: number;
|
radius: number;
|
||||||
title: string;
|
title: string;
|
||||||
fields: string[];
|
fields: string[];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
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
|
// 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 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
|
// 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
|
// 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
|
// 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.
|
// 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);
|
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 extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
const overrides: string[] = [];
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
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,
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -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
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
// localStorage copy is just a fast, synchronous cache).
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
const PORTABLE_KEYS = [
|
const PORTABLE_KEYS = [
|
||||||
|
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||||
'hamlog.qsoLimit', // QSO list page size
|
'hamlog.qsoLimit', // QSO list page size
|
||||||
'bandmap.side', // band map docked left / right
|
'bandmap.side', // band map docked left / right
|
||||||
'bandmap.show', // band map open / closed
|
'bandmap.show', // band map open / closed
|
||||||
@@ -25,6 +26,12 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
// Cluster filter selections — restored on reopen.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
+10
-1
@@ -3,6 +3,8 @@ import {createRoot} from 'react-dom/client'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
|
import { I18nProvider } from './lib/i18n'
|
||||||
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
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.
|
// 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.
|
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||||
syncPortablePrefs().finally(() => {
|
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(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App/>
|
<I18nProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+607
-35
@@ -3,43 +3,616 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
/* ===== Warm taupe & burnt-orange palette =====
|
/* ============================================================================
|
||||||
A step darker than pure cream: the body is a soft taupe so white-ish
|
THEME SYSTEM
|
||||||
cards lift off the surface, giving real depth without going dark.
|
----------------------------------------------------------------------------
|
||||||
Borders are more defined; muted surfaces sit between bg and card so
|
Every color is a semantic CSS variable declared per theme below. The
|
||||||
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
`@theme inline` block points Tailwind's color utilities (bg-*, text-*,
|
||||||
*/
|
border-*, ring-*, …) directly at those variables, so switching the
|
||||||
@theme {
|
`data-theme` attribute on <html> reskins the whole UI at runtime with no
|
||||||
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
rebuild. Four themes ship: two light-neutral surfaces and status families
|
||||||
--color-foreground: #1c1917; /* stone-900 */
|
for OK / warning / caution / danger / info that adapt to each surface.
|
||||||
|
|
||||||
--color-card: #faf6ea; /* lifted off-white cream */
|
Semantic status vocabulary (use these, NOT raw palette colors):
|
||||||
--color-card-foreground: #1c1917;
|
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;
|
/* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
|
||||||
--color-popover-foreground: #1c1917;
|
: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 */
|
--success: #16a34a;
|
||||||
--color-primary-foreground: #fff7ed;
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
--color-secondary: #ddd2b8; /* a touch under the bg */
|
--warning: #d97706;
|
||||||
--color-secondary-foreground: #1c1917;
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
--color-muted: #ddd2b8; /* toolbars / table headers */
|
--caution: #ca8a04;
|
||||||
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
--color-accent: #f8d6a9; /* warm amber-cream */
|
--danger: #e11d48;
|
||||||
--color-accent-foreground: #7c2d12; /* orange-900 */
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
--color-destructive: #a51c1c;
|
--info: #0284c7;
|
||||||
--color-destructive-foreground: #ffffff;
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
--color-border: #c8b994; /* deeper warm beige border */
|
/* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
|
||||||
--color-input: #c8b994;
|
--mx-call-conf: #15803d; /* call confirmed */
|
||||||
--color-ring: #d97706; /* amber-600 focus ring */
|
--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;
|
--scrollbar-thumb: #b8a880;
|
||||||
--color-warn: #b45309;
|
--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;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
@@ -65,18 +638,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
/* 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 {
|
.bg-card {
|
||||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
box-shadow: var(--card-shadow);
|
||||||
0 0 0 1px rgba(28, 25, 23, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm scrollbar tuned to the deeper bg */
|
/* Scrollbar tuned to each theme's surface */
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #b8a880;
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 5px;
|
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).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.16.4';
|
export const APP_VERSION = '0.19.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+150
-3
@@ -11,11 +11,14 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
|
import {lotwusers} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
|
||||||
@@ -31,10 +34,26 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
|||||||
|
|
||||||
export function AntGeniusDeselect(arg1: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 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 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 AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
@@ -43,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 AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
@@ -67,6 +88,10 @@ export function ConnectAllClusters():Promise<void>;
|
|||||||
|
|
||||||
export function ConnectClusterServer(arg1:number):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 CountQSO():Promise<number>;
|
||||||
|
|
||||||
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
||||||
@@ -115,30 +140,40 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
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 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 ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):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 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 ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string,arg2:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
export function ExportCabrilloFiltered(arg1:string,arg2:string,arg3:qso.QueryFilter):Promise<main.CabrilloResult>;
|
export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
export function ExportCabrilloSelected(arg1:string,arg2:string,arg3:Array<number>):Promise<main.CabrilloResult>;
|
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
export function FilterFields():Promise<Array<string>>;
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function FlexATUBypass():Promise<void>;
|
export function FlexATUBypass():Promise<void>;
|
||||||
@@ -165,6 +200,8 @@ export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetATUMemories(arg1:boolean):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 FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
@@ -181,6 +218,8 @@ export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetMic(arg1: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 FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
@@ -201,6 +240,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):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 FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
@@ -209,6 +252,10 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):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 FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -217,6 +264,14 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetVoxLevel(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 FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
@@ -241,6 +296,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
|||||||
|
|
||||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
@@ -251,6 +308,8 @@ export function GetCATState():Promise<cat.RigState>;
|
|||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -259,6 +318,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
|
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
@@ -287,14 +348,20 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
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 GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
@@ -303,6 +370,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -313,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
@@ -333,16 +404,42 @@ export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
|||||||
|
|
||||||
export function IcomRefresh():Promise<void>;
|
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 IcomSetAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetANF(arg1:boolean):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 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 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 IcomSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||||
@@ -351,16 +448,54 @@ export function IcomSetNR(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function IcomSetNRLevel(arg1:number):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 IcomSetPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetRFGain(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 ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -375,6 +510,8 @@ export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
|
|||||||
|
|
||||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||||
|
|
||||||
|
export function ListContests():Promise<Array<contest.Def>>;
|
||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
@@ -391,6 +528,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
@@ -433,6 +572,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
|||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||||
@@ -497,6 +638,8 @@ export function QuitApp():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
@@ -513,8 +656,12 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
|||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function RestartApp():Promise<void>;
|
||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
|
|||||||
@@ -26,14 +26,46 @@ export function AntGeniusDeselect(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function ApplyAwardPreset(arg1, arg2) {
|
||||||
return window['go']['main']['App']['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) {
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['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) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -50,6 +82,10 @@ export function AwardRefsForQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardsFolder() {
|
||||||
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -98,6 +134,14 @@ export function ConnectClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function CountQSO() {
|
||||||
return window['go']['main']['App']['CountQSO']();
|
return window['go']['main']['App']['CountQSO']();
|
||||||
}
|
}
|
||||||
@@ -194,6 +238,10 @@ export function DiscoverFlexRadios() {
|
|||||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadAllReferenceLists() {
|
export function DownloadAllReferenceLists() {
|
||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
@@ -206,10 +254,18 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['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) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -222,26 +278,34 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportCabrillo(arg1, arg2) {
|
export function ExportCabrillo(arg1) {
|
||||||
return window['go']['main']['App']['ExportCabrillo'](arg1, arg2);
|
return window['go']['main']['App']['ExportCabrillo'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportCabrilloFiltered(arg1, arg2, arg3) {
|
export function ExportCabrilloFiltered(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportCabrilloSelected(arg1, arg2, arg3) {
|
export function ExportCabrilloSelected(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterFields() {
|
export function FilterFields() {
|
||||||
return window['go']['main']['App']['FilterFields']();
|
return window['go']['main']['App']['FilterFields']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FindDuplicates(arg1) {
|
||||||
|
return window['go']['main']['App']['FindDuplicates'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1, arg2) {
|
export function FindQSOsForUpload(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -294,6 +358,10 @@ export function FlexSetATUMemories(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1) {
|
export function FlexSetAudioLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -326,6 +394,10 @@ export function FlexSetMic(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetMicProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMon(arg1) {
|
export function FlexSetMon(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMon'](arg1);
|
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||||
}
|
}
|
||||||
@@ -366,6 +438,14 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -382,6 +462,14 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -398,6 +486,22 @@ export function FlexSetVoxLevel(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
@@ -446,6 +550,10 @@ export function GetAwardStats(arg1) {
|
|||||||
return window['go']['main']['App']['GetAwardStats'](arg1);
|
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAwardUpdates() {
|
||||||
|
return window['go']['main']['App']['GetAwardUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwards() {
|
export function GetAwards() {
|
||||||
return window['go']['main']['App']['GetAwards']();
|
return window['go']['main']['App']['GetAwards']();
|
||||||
}
|
}
|
||||||
@@ -466,6 +574,10 @@ export function GetCWDecoderPitch() {
|
|||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCatalogCodes() {
|
||||||
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -482,6 +594,10 @@ export function GetClusterStatus() {
|
|||||||
return window['go']['main']['App']['GetClusterStatus']();
|
return window['go']['main']['App']['GetClusterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetContestRuns() {
|
||||||
|
return window['go']['main']['App']['GetContestRuns']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetCtyDatInfo() {
|
export function GetCtyDatInfo() {
|
||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
@@ -538,10 +654,18 @@ export function GetLiveStatusEnabled() {
|
|||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus() {
|
||||||
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['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() {
|
export function GetLogbookRevision() {
|
||||||
return window['go']['main']['App']['GetLogbookRevision']();
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
}
|
}
|
||||||
@@ -554,6 +678,10 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOfflineStatus() {
|
||||||
|
return window['go']['main']['App']['GetOfflineStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetOnlineOperators() {
|
export function GetOnlineOperators() {
|
||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
@@ -570,6 +698,10 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPendingQSOs() {
|
||||||
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -590,6 +722,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSolarData() {
|
||||||
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -630,6 +766,14 @@ export function IcomRefresh() {
|
|||||||
return window['go']['main']['App']['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) {
|
export function IcomSetAFGain(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
||||||
}
|
}
|
||||||
@@ -642,14 +786,58 @@ export function IcomSetANF(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function IcomSetAtt(arg1) {
|
||||||
return window['go']['main']['App']['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) {
|
export function IcomSetFilter(arg1) {
|
||||||
return window['go']['main']['App']['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) {
|
export function IcomSetNB(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetNB'](arg1);
|
return window['go']['main']['App']['IcomSetNB'](arg1);
|
||||||
}
|
}
|
||||||
@@ -666,6 +854,26 @@ export function IcomSetNRLevel(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function IcomSetPreamp(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
||||||
}
|
}
|
||||||
@@ -674,6 +882,58 @@ export function IcomSetRFGain(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
@@ -686,6 +946,10 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InspectAwardImport() {
|
||||||
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -714,6 +978,10 @@ export function ListClusterServers() {
|
|||||||
return window['go']['main']['App']['ListClusterServers']();
|
return window['go']['main']['App']['ListClusterServers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListContests() {
|
||||||
|
return window['go']['main']['App']['ListContests']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListCountries() {
|
export function ListCountries() {
|
||||||
return window['go']['main']['App']['ListCountries']();
|
return window['go']['main']['App']['ListCountries']();
|
||||||
}
|
}
|
||||||
@@ -746,6 +1014,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1) {
|
||||||
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1) {
|
export function LogUDPLoggedADIF(arg1) {
|
||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -830,6 +1102,10 @@ export function OpenADIFFile() {
|
|||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenAwardsFolder() {
|
||||||
|
return window['go']['main']['App']['OpenAwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenDatabase(arg1) {
|
export function OpenDatabase(arg1) {
|
||||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -958,6 +1234,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSolar() {
|
||||||
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
@@ -990,10 +1270,18 @@ export function ResetDatabaseToDefault() {
|
|||||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RestartApp() {
|
||||||
|
return window['go']['main']['App']['RestartApp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RestartQSORecorder() {
|
export function RestartQSORecorder() {
|
||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetryOfflineSync() {
|
||||||
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.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>,
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
// 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.
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
|||||||
+104
-14
@@ -16,7 +16,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,9 +32,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Antenna is one configured antenna (index + name as stored on the device).
|
// 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 {
|
type Antenna struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Bands int `json:"bands"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status is the snapshot the UI renders.
|
// Status is the snapshot the UI renders.
|
||||||
@@ -47,29 +54,39 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes
|
mu sync.Mutex // guards conn + writes
|
||||||
conn net.Conn
|
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
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
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{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int, password string) *Client {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
|
password: strings.TrimSpace(password),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
antennas: map[int]string{},
|
antennas: map[int]string{},
|
||||||
|
antBands: map[int]int{},
|
||||||
status: Status{Host: host},
|
status: Status{Host: host},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,13 +163,13 @@ func (c *Client) runLoop() {
|
|||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
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())
|
||||||
|
|
||||||
// Subscribe to live updates and pull the initial state. Command set and
|
// Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
|
||||||
// order mirror a known-working Node-RED v4 client (WA9WUD).
|
// password we send "auth code=<pw>" and only send the subscribe/get commands
|
||||||
_ = c.send("antenna list")
|
// once it's accepted (retrying on FF); without AUTH they go out immediately.
|
||||||
_ = c.send("sub port all")
|
c.awaitingAuth, c.authTries = false, 0
|
||||||
_ = c.send("port get 1")
|
c.ready.Store(false)
|
||||||
_ = c.send("port get 2")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go c.keepalive(conn, done)
|
go c.keepalive(conn, done)
|
||||||
@@ -189,23 +206,51 @@ func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
|||||||
case <-c.stop:
|
case <-c.stop:
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
|
if !c.ready.Load() {
|
||||||
|
continue // not authenticated / subscribed yet
|
||||||
|
}
|
||||||
_ = c.send("port get 1")
|
_ = c.send("port get 1")
|
||||||
_ = c.send("port get 2")
|
_ = 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 {
|
func (c *Client) readLoop(conn net.Conn) error {
|
||||||
r := bufio.NewReader(conn)
|
r := bufio.NewReader(conn)
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
start := time.Now()
|
||||||
|
lines, bytesRx := 0, 0
|
||||||
for {
|
for {
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||||
b, err := r.ReadByte()
|
b, err := r.ReadByte()
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
bytesRx++
|
||||||
if b == '\r' || b == '\n' {
|
if b == '\r' || b == '\n' {
|
||||||
if sb.Len() > 0 {
|
if sb.Len() > 0 {
|
||||||
|
lines++
|
||||||
|
if lines <= 10 {
|
||||||
|
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
|
||||||
|
}
|
||||||
c.handleLine(sb.String())
|
c.handleLine(sb.String())
|
||||||
sb.Reset()
|
sb.Reset()
|
||||||
}
|
}
|
||||||
@@ -236,16 +281,31 @@ func (c *Client) handleLine(line string) {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Banner like "V4.1.16 AG" — just confirms the link is up.
|
// 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") {
|
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
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
|
return
|
||||||
}
|
}
|
||||||
// R<seq>|<hex>|<message> or S<seq>|<message>
|
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||||
var msg string
|
var hexCode, msg string
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "R"):
|
case strings.HasPrefix(line, "R"):
|
||||||
p := strings.SplitN(line, "|", 3)
|
p := strings.SplitN(line, "|", 3)
|
||||||
|
if len(p) >= 2 {
|
||||||
|
hexCode = p[1]
|
||||||
|
}
|
||||||
if len(p) == 3 {
|
if len(p) == 3 {
|
||||||
msg = p[2]
|
msg = p[2]
|
||||||
}
|
}
|
||||||
@@ -256,6 +316,25 @@ func (c *Client) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
msg = strings.TrimSpace(msg)
|
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 {
|
switch {
|
||||||
case strings.HasPrefix(msg, "antenna "):
|
case strings.HasPrefix(msg, "antenna "):
|
||||||
c.parseAntenna(msg)
|
c.parseAntenna(msg)
|
||||||
@@ -284,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
|
|||||||
// The device stores spaces as underscores in names.
|
// The device stores spaces as underscores in names.
|
||||||
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
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()
|
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) {
|
if name != "" && !isPlaceholderName(name) {
|
||||||
c.antennas[id] = name
|
c.antennas[id] = name
|
||||||
|
c.antBands[id] = bands
|
||||||
} else {
|
} else {
|
||||||
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||||
|
delete(c.antBands, id)
|
||||||
}
|
}
|
||||||
c.status.Antennas = sortedAntennas(c.antennas)
|
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -359,10 +449,10 @@ func isPlaceholderName(name string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedAntennas(m map[int]string) []Antenna {
|
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
|
||||||
out := make([]Antenna, 0, len(m))
|
out := make([]Antenna, 0, len(m))
|
||||||
for idx, name := range m {
|
for idx, name := range m {
|
||||||
out = append(out, Antenna{Index: idx, Name: name})
|
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 })
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package antgenius
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestHandleAntennaList(t *testing.T) {
|
func TestHandleAntennaList(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
// Names may contain spaces — must be captured up to " tx=".
|
// 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 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")
|
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||||
@@ -20,7 +20,7 @@ func TestHandleAntennaList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||||
c := New("x", 9007)
|
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 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 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
|
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
@@ -31,7 +31,7 @@ func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlePortStatus(t *testing.T) {
|
func TestHandlePortStatus(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
// 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 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")
|
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||||
@@ -50,7 +50,7 @@ func TestHandlePortStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPortTxFallbackToRx(t *testing.T) {
|
func TestPortTxFallbackToRx(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||||
if st := c.GetStatus(); st.PortA != 3 {
|
if st := c.GetStatus(); st.PortA != 3 {
|
||||||
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
|||||||
time.Sleep(10 * time.Millisecond)
|
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{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
playStop chan struct{}
|
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 {
|
type recResult struct {
|
||||||
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
|
|||||||
m.notify()
|
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
|
||||||
|
}
|
||||||
|
|||||||
+412
-89
@@ -13,6 +13,10 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -55,23 +59,33 @@ type Def struct {
|
|||||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
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)
|
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||||
Alias string `json:"alias,omitempty"`
|
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 & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix 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)
|
||||||
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
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
// 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
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// earns a reference if the primary match OR any of these match. Lets a
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
// French department (DDFM) be found from "D74" in the note AND from a postal
|
// yields a reference wins and the rest are skipped (short-circuit, like a
|
||||||
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
// 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"`
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
@@ -83,11 +97,45 @@ type Def struct {
|
|||||||
// --- Confirmation ---
|
// --- Confirmation ---
|
||||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
// 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)
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
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.
|
// 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
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||||
func Defaults() []Def {
|
//
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
// recompile and a release for every new one, which is absurd for something that
|
||||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
// changes far more often than the engine that reads it. They now live one JSON per
|
||||||
// accepts it (WAC).
|
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||||
lq := []string{"lotw", "qsl"}
|
//
|
||||||
lo := []string{"lotw"}
|
// This is the SEED only. Once a user has awards in their database, that database
|
||||||
return []Def{
|
// is the source of truth — the catalog never overwrites their edits behind their
|
||||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
// back.
|
||||||
{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},
|
//go:embed catalog/*.json
|
||||||
{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},
|
var catalogFS embed.FS
|
||||||
{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},
|
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||||
{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},
|
// The references are the point. An award's definition is a few lines; what makes
|
||||||
{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},
|
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||||
{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},
|
// 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.
|
// 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.
|
// 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 {
|
func NewRefList(metas []RefMeta) refList {
|
||||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||||
for _, m := range metas {
|
for _, m := range metas {
|
||||||
@@ -260,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
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
|
m.re = re
|
||||||
rl.withPattern = append(rl.withPattern, code)
|
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))
|
perr := make([]string, len(defs))
|
||||||
for i := range defs {
|
for i := range defs {
|
||||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||||
re, err := regexp.Compile(p)
|
re, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
perr[i] = "bad pattern: " + err.Error()
|
perr[i] = "bad pattern: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
@@ -427,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
var re *regexp.Regexp
|
var re *regexp.Regexp
|
||||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
if c, err := regexp.Compile(p); err == nil {
|
if c, err := compileAwardRE(p); err == nil {
|
||||||
re = c
|
re = c
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
found := candidates(&d, re, q, rl, len(metas) > 0)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
// 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
|
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
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
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)
|
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:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "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
|
// 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
|
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 {
|
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
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
// run executes one rule and, when tracing, records it.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
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 {
|
for i := range d.OrRules {
|
||||||
r := &d.OrRules[i]
|
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
|
var rre *regexp.Regexp
|
||||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
c, err := regexp.Compile(p)
|
c, err := compileAwardRE(p)
|
||||||
if err != nil {
|
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
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
}
|
}
|
||||||
rre = c
|
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 {
|
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
|
var out []string
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, c := range found {
|
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,
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
// bands, modes, emission category, validity dates).
|
// bands, modes, emission category, validity dates).
|
||||||
func inScope(d *Def, q *qso.QSO) bool {
|
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
|
||||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
|
||||||
|
// 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
|
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) {
|
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) {
|
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)) {
|
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 {
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"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
|
// 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
|
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||||
// 4-char prefix of GRIDSQUARE.
|
// 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 {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range 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))
|
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"
|
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
|
// 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:
|
// 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
|
// - standard call: letters+digits up to and including the LAST digit of the
|
||||||
|
|||||||
+167
-7
@@ -32,6 +32,16 @@ type Backend interface {
|
|||||||
SetPTT(on bool) error
|
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.
|
// RigState is the snapshot exchanged with the frontend.
|
||||||
//
|
//
|
||||||
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
||||||
@@ -156,6 +166,7 @@ func (m *Manager) stopLocked() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.stopCh
|
stop := m.stopCh
|
||||||
done := m.doneCh
|
done := m.doneCh
|
||||||
|
b := m.backend
|
||||||
m.stopCh = nil
|
m.stopCh = nil
|
||||||
m.doneCh = nil
|
m.doneCh = nil
|
||||||
m.cmdCh = nil
|
m.cmdCh = nil
|
||||||
@@ -164,6 +175,11 @@ func (m *Manager) stopLocked() {
|
|||||||
if stop != nil {
|
if stop != nil {
|
||||||
close(stop)
|
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 {
|
if done != nil {
|
||||||
<-done
|
<-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
|
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||||
// driven later by setting Color per spot.)
|
// driven later by setting Color per spot.)
|
||||||
type SpotInfo struct {
|
type SpotInfo struct {
|
||||||
FreqHz int64
|
FreqHz int64
|
||||||
Callsign string
|
Callsign string
|
||||||
Mode string
|
Mode string
|
||||||
Color string
|
Color string
|
||||||
Comment 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
|
// 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
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
// 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.)
|
// 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 {
|
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"`
|
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"`
|
RFPower int `json:"rf_power"`
|
||||||
TunePower int `json:"tune_power"`
|
TunePower int `json:"tune_power"`
|
||||||
Tune bool `json:"tune"` // tune carrier active
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
@@ -244,6 +276,11 @@ type FlexTXState struct {
|
|||||||
Mon bool `json:"mon"`
|
Mon bool `json:"mon"`
|
||||||
MonLevel int `json:"mon_level"`
|
MonLevel int `json:"mon_level"`
|
||||||
MicLevel int `json:"mic_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"`
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
@@ -265,6 +302,15 @@ type FlexTXState struct {
|
|||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
ANFLevel int `json:"anf_level"`
|
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.
|
// CW / mode-specific controls.
|
||||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
CWSpeed int `json:"cw_speed"`
|
CWSpeed int `json:"cw_speed"`
|
||||||
@@ -291,6 +337,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
@@ -313,6 +360,8 @@ type FlexController interface {
|
|||||||
SetMon(bool) error
|
SetMon(bool) error
|
||||||
SetMonLevel(int) error
|
SetMonLevel(int) error
|
||||||
SetMic(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
|
ATUStart() error
|
||||||
ATUBypass() error
|
ATUBypass() error
|
||||||
SetATUMemories(bool) error
|
SetATUMemories(bool) error
|
||||||
@@ -323,6 +372,8 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(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
|
SetSplit(bool) error
|
||||||
SetNB(bool) error
|
SetNB(bool) error
|
||||||
SetNBLevel(int) error
|
SetNBLevel(int) error
|
||||||
@@ -332,6 +383,12 @@ type FlexController interface {
|
|||||||
SetANFLevel(int) error
|
SetANFLevel(int) error
|
||||||
SetAPF(bool) error
|
SetAPF(bool) error
|
||||||
SetAPFLevel(int) 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.
|
// CW keyer + mode-specific controls.
|
||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
@@ -376,17 +433,50 @@ type IcomTXState struct {
|
|||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
AFGain int `json:"af_gain"`
|
// Transmit + live status (polled).
|
||||||
RFGain int `json:"rf_gain"`
|
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"`
|
NB bool `json:"nb"`
|
||||||
NBLevel int `json:"nb_level"`
|
NBLevel int `json:"nb_level"`
|
||||||
NR bool `json:"nr"`
|
NR bool `json:"nr"`
|
||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
|
APF bool `json:"apf"` // audio peak filter (CW only)
|
||||||
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||||
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
Att int `json:"att"` // dB attenuation, 0=off
|
Att int `json:"att"` // dB attenuation, 0=off
|
||||||
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
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
|
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
||||||
@@ -402,10 +492,52 @@ type IcomController interface {
|
|||||||
SetNR(bool) error
|
SetNR(bool) error
|
||||||
SetNRLevel(int) error
|
SetNRLevel(int) error
|
||||||
SetANF(bool) error
|
SetANF(bool) error
|
||||||
|
SetAPF(bool) error
|
||||||
SetAGC(string) error
|
SetAGC(string) error
|
||||||
SetPreamp(int) error
|
SetPreamp(int) error
|
||||||
SetAtt(int) error
|
SetAtt(int) error
|
||||||
SetIcomFilter(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
|
// IcomState returns the current Icom DSP state, or (zero, false) when the active
|
||||||
@@ -420,6 +552,19 @@ func (m *Manager) IcomState() (IcomTXState, bool) {
|
|||||||
return IcomTXState{}, false
|
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
|
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
|
||||||
// active backend isn't an Icom.
|
// active backend isn't an Icom.
|
||||||
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||||
@@ -490,6 +635,21 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
fn()
|
fn()
|
||||||
m.applyCommandDelay()
|
m.applyCommandDelay()
|
||||||
case <-ticker.C:
|
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 {
|
if !connected {
|
||||||
tryConnect()
|
tryConnect()
|
||||||
continue
|
continue
|
||||||
|
|||||||
+148
-6
@@ -32,22 +32,72 @@ const (
|
|||||||
CmdReadMode = 0x04
|
CmdReadMode = 0x04
|
||||||
CmdSetFreq = 0x05
|
CmdSetFreq = 0x05
|
||||||
CmdSetMode = 0x06
|
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
|
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||||
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||||
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
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)
|
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
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)
|
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
|
||||||
|
|
||||||
SubDataMode = 0x06
|
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||||
SubPTT = 0x00
|
|
||||||
|
// 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.
|
// CmdLevel sub-commands.
|
||||||
SubLevelAF = 0x01 // AF (volume)
|
SubLevelAF = 0x01 // AF (volume)
|
||||||
SubLevelRF = 0x02 // RF gain
|
SubLevelRF = 0x02 // RF gain
|
||||||
SubLevelNR = 0x06 // noise-reduction depth
|
SubLevelSQL = 0x03 // squelch level
|
||||||
SubLevelNB = 0x12 // noise-blanker depth
|
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.
|
// CmdSwitch sub-commands.
|
||||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
@@ -55,6 +105,19 @@ const (
|
|||||||
SubSwNB = 0x22 // noise blanker on/off
|
SubSwNB = 0x22 // noise blanker on/off
|
||||||
SubSwNR = 0x40 // noise reduction on/off
|
SubSwNR = 0x40 // noise reduction on/off
|
||||||
SubSwANF = 0x41 // auto-notch 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).
|
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||||
@@ -129,6 +192,83 @@ func BCDToLevel(b []byte) int {
|
|||||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
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
|
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||||
func ByteToBCD(v int) byte {
|
func ByteToBCD(v int) byte {
|
||||||
@@ -173,6 +313,8 @@ func ModelName(addr byte) string {
|
|||||||
return "IC-7300"
|
return "IC-7300"
|
||||||
case 0x98:
|
case 0x98:
|
||||||
return "IC-7610"
|
return "IC-7610"
|
||||||
|
case 0x7C:
|
||||||
|
return "IC-9100"
|
||||||
case 0xA2:
|
case 0xA2:
|
||||||
return "IC-9700"
|
return "IC-9700"
|
||||||
case 0xA4:
|
case 0xA4:
|
||||||
|
|||||||
+455
-115
@@ -4,6 +4,7 @@ package cat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -25,9 +26,10 @@ type Flex struct {
|
|||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
wmu sync.Mutex // serialises writes to conn
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
|
||||||
|
wmu sync.Mutex // serialises writes to conn
|
||||||
seq int
|
seq int
|
||||||
handle string
|
handle string
|
||||||
model string
|
model string
|
||||||
@@ -36,6 +38,8 @@ type Flex struct {
|
|||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
|
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||||
|
micProfile string // currently loaded mic profile
|
||||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
||||||
@@ -56,6 +60,7 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
@@ -83,8 +88,14 @@ type flexSlice struct {
|
|||||||
anfLevel int
|
anfLevel int
|
||||||
apf bool // CW audio peaking filter
|
apf bool // CW audio peaking filter
|
||||||
apfLevel int
|
apfLevel int
|
||||||
filterLo int // slice filter low cut (Hz)
|
wnb bool // wideband noise blanker
|
||||||
filterHi int // slice filter high cut (Hz)
|
wnbLevel int
|
||||||
|
rit bool // receive incremental tuning enabled
|
||||||
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
txAnt string // selected TX antenna
|
txAnt string // selected TX antenna
|
||||||
antList []string // antennas valid for this slice (RX side)
|
antList []string // antennas valid for this slice (RX side)
|
||||||
@@ -106,6 +117,8 @@ type flexTX struct {
|
|||||||
mon bool
|
mon bool
|
||||||
monLevel int
|
monLevel int
|
||||||
micLevel int
|
micLevel int
|
||||||
|
filterLow int // TX filter low cut (Hz)
|
||||||
|
filterHigh int // TX filter high cut (Hz)
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
// CW keyer params (set via the top-level "cw" commands).
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
@@ -131,6 +144,7 @@ type meterInfo struct {
|
|||||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
|
||||||
lo float64
|
lo float64
|
||||||
hi float64
|
hi float64
|
||||||
}
|
}
|
||||||
@@ -149,7 +163,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
@@ -171,7 +185,19 @@ func (f *Flex) Connect() error {
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
return fmt.Errorf("flex: no radio IP configured")
|
return fmt.Errorf("flex: no radio IP configured")
|
||||||
}
|
}
|
||||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second)
|
// Cancellable dial: Interrupt() (called by Stop/Start) aborts it at once so a
|
||||||
|
// dead radio's 5 s dial timeout doesn't make Stop / Settings "Save & Close"
|
||||||
|
// wait several seconds for the poll goroutine to give up.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.dialCancel = cancel
|
||||||
|
f.mu.Unlock()
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
cancel()
|
||||||
|
f.mu.Lock()
|
||||||
|
f.dialCancel = nil
|
||||||
|
f.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
||||||
}
|
}
|
||||||
@@ -195,6 +221,8 @@ func (f *Flex) Connect() error {
|
|||||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||||
|
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
|
||||||
|
f.send("profile mic info") // request the current mic profile list + selection
|
||||||
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||||
if f.spotsEnabled {
|
if f.spotsEnabled {
|
||||||
@@ -224,6 +252,23 @@ func (f *Flex) Disconnect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interrupt aborts an in-flight Connect dial so Stop()/Start() (Settings
|
||||||
|
// "Save & Close", CAT backend switch) don't block on a dead radio's 5 s dial
|
||||||
|
// timeout. Satisfies the Manager's optional interruptible interface. Safe to call
|
||||||
|
// anytime and from another goroutine; a no-op when not dialing.
|
||||||
|
func (f *Flex) Interrupt() {
|
||||||
|
f.mu.Lock()
|
||||||
|
cancel := f.dialCancel
|
||||||
|
c := f.conn
|
||||||
|
f.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
_ = c.Close() // unblock the reader if we're already past the dial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
|
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
|
||||||
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
|
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
|
||||||
// new spot's index). Returns 0 when not connected. Best effort.
|
// new spot's index). Returns 0 when not connected. Best effort.
|
||||||
@@ -313,6 +358,7 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A successful "slice create" for split returns the new slice's index;
|
// A successful "slice create" for split returns the new slice's index;
|
||||||
@@ -414,6 +460,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||||
case "mic_level", "miclevel":
|
case "mic_level", "miclevel":
|
||||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||||
|
// TX filter: the transmit STATUS reports the passband as lo/hi (the
|
||||||
|
// SET command is filter_low/filter_high — a SmartSDR quirk).
|
||||||
|
case "lo", "filter_low":
|
||||||
|
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
|
||||||
|
case "hi", "filter_high":
|
||||||
|
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -495,6 +547,35 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
|
||||||
|
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
|
||||||
|
// values are taken from the raw payload after the key. Logged once so the
|
||||||
|
// exact field names are confirmable on real hardware.
|
||||||
|
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
|
||||||
|
debugLog.Printf("Flex: profile status: %s", payload)
|
||||||
|
if i := strings.Index(payload, "list="); i >= 0 {
|
||||||
|
var profs []string
|
||||||
|
for _, p := range strings.Split(payload[i+len("list="):], "^") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
profs = append(profs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfiles = profs
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// The loaded profile arrives as current=<name> (some firmwares:
|
||||||
|
// selection=<name>); accept either.
|
||||||
|
for _, key := range []string{"current=", "selection="} {
|
||||||
|
if i := strings.Index(payload, key); i >= 0 {
|
||||||
|
cur := strings.TrimSpace(payload[i+len(key):])
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfile = cur
|
||||||
|
f.mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
||||||
if len(fields) >= 1 && fields[0] == "interlock" {
|
if len(fields) >= 1 && fields[0] == "interlock" {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -565,7 +646,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// One meter per token; its fields are '#'-separated:
|
// One meter per token; its fields are '#'-separated:
|
||||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
num := -1
|
num := -1
|
||||||
var mi meterInfo
|
mi := meterInfo{slc: -1}
|
||||||
for _, sub := range strings.Split(tok, "#") {
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
key, val, ok := splitKV(sub)
|
key, val, ok := splitKV(sub)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -585,6 +666,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
mi.src = val
|
mi.src = val
|
||||||
case "nam":
|
case "nam":
|
||||||
mi.name = val
|
mi.name = val
|
||||||
|
case "num":
|
||||||
|
// For a slice (SLC) meter this field is the slice index —
|
||||||
|
// how we tell slice A's S-meter from slice B's.
|
||||||
|
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
|
||||||
|
mi.slc = v
|
||||||
|
}
|
||||||
case "unit", "units":
|
case "unit", "units":
|
||||||
mi.unit = val
|
mi.unit = val
|
||||||
case "low", "lo":
|
case "low", "lo":
|
||||||
@@ -599,6 +686,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
old, seen := f.meterMeta[num]
|
old, seen := f.meterMeta[num]
|
||||||
if !seen {
|
if !seen {
|
||||||
newIDs = append(newIDs, num)
|
newIDs = append(newIDs, num)
|
||||||
|
old.slc = -1
|
||||||
}
|
}
|
||||||
if mi.src != "" {
|
if mi.src != "" {
|
||||||
old.src = mi.src
|
old.src = mi.src
|
||||||
@@ -609,6 +697,9 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if mi.unit != "" {
|
if mi.unit != "" {
|
||||||
old.unit = mi.unit
|
old.unit = mi.unit
|
||||||
}
|
}
|
||||||
|
if mi.slc >= 0 {
|
||||||
|
old.slc = mi.slc
|
||||||
|
}
|
||||||
if mi.lo != 0 {
|
if mi.lo != 0 {
|
||||||
old.lo = mi.lo
|
old.lo = mi.lo
|
||||||
}
|
}
|
||||||
@@ -620,7 +711,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,6 +804,18 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.apf = val == "1"
|
s.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
s.apfLevel = atoiDefault(val, s.apfLevel)
|
s.apfLevel = atoiDefault(val, s.apfLevel)
|
||||||
|
case "wnb":
|
||||||
|
s.wnb = val == "1"
|
||||||
|
case "wnb_level":
|
||||||
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "rit_on":
|
||||||
|
s.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
s.ritFreq = atoiDefault(val, s.ritFreq)
|
||||||
|
case "xit_on":
|
||||||
|
s.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
s.xitFreq = atoiDefault(val, s.xitFreq)
|
||||||
case "filter_lo":
|
case "filter_lo":
|
||||||
s.filterLo = atoiDefault(val, s.filterLo)
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
case "filter_hi":
|
case "filter_hi":
|
||||||
@@ -743,21 +846,19 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
if !f.gotHandle {
|
if !f.gotHandle {
|
||||||
return st, nil // connected TCP but radio hasn't handshaked yet
|
return st, nil // connected TCP but radio hasn't handshaked yet
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
main, rxS, txSplit := f.operatingLocked()
|
||||||
if rx == nil && tx == nil {
|
if main == nil {
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
if tx == nil {
|
// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
|
||||||
tx = rx
|
// genuine same-band split adds a separate TX freq; then ADIF convention wants
|
||||||
}
|
// FreqHz = TX and RxFreqHz = RX.
|
||||||
if rx == nil {
|
st.FreqHz = main.freqHz
|
||||||
rx = tx
|
st.Mode = flexModeToADIF(main.mode)
|
||||||
}
|
if rxS != nil && txSplit != nil {
|
||||||
st.FreqHz = tx.freqHz
|
|
||||||
st.Mode = flexModeToADIF(tx.mode)
|
|
||||||
if rx.freqHz != tx.freqHz {
|
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RxFreqHz = rx.freqHz
|
st.RxFreqHz = rxS.freqHz
|
||||||
|
st.FreqHz = txSplit.freqHz
|
||||||
}
|
}
|
||||||
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
if sig != f.lastStateSig {
|
if sig != f.lastStateSig {
|
||||||
@@ -767,76 +868,163 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
|
// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
|
||||||
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
|
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
|
||||||
// (preferring the active/focused one), NOT simply the active slice: tuning the
|
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
|
||||||
// TX slice makes it the active/focused slice, which would otherwise collapse RX
|
// follows this slice, so a second independent slice (e.g. monitoring another
|
||||||
// onto TX and hide the split. Caller holds f.mu.
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
|
// use. Caller holds f.mu.
|
||||||
idxs := make([]int, 0, len(f.slices))
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
for i, s := range f.slices {
|
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
|
||||||
if s.inUse {
|
// randomises. When two slices transiently BOTH report active=1 (e.g. an
|
||||||
idxs = append(idxs, i)
|
// external controller like DXHunter activates a slice on another band while
|
||||||
}
|
// ours still holds active, before SmartSDR sends active=0 to the old one),
|
||||||
}
|
// map order returned a RANDOM active slice each call → the operating frequency
|
||||||
sort.Ints(idxs)
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
var active, txS, nonTx, first *flexSlice
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
for _, i := range idxs {
|
firstInUse := -1
|
||||||
s := f.slices[i]
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if first == nil {
|
s := f.slices[idx]
|
||||||
first = s
|
|
||||||
}
|
|
||||||
if s.active {
|
|
||||||
active = s
|
|
||||||
}
|
|
||||||
if s.tx {
|
|
||||||
txS = s
|
|
||||||
} else if nonTx == nil {
|
|
||||||
nonTx = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx = txS
|
|
||||||
if tx == nil {
|
|
||||||
if active != nil {
|
|
||||||
tx = active
|
|
||||||
} else {
|
|
||||||
tx = first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// RX = the receive slice: the active one if it isn't the TX slice, else the
|
|
||||||
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
|
|
||||||
switch {
|
|
||||||
case active != nil && active != tx:
|
|
||||||
rx = active
|
|
||||||
case nonTx != nil:
|
|
||||||
rx = nonTx
|
|
||||||
default:
|
|
||||||
rx = tx
|
|
||||||
}
|
|
||||||
return rx, tx
|
|
||||||
}
|
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the active
|
|
||||||
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
|
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
|
||||||
best, found := 1<<30, false
|
|
||||||
for idx, s := range f.slices {
|
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.active {
|
if firstInUse < 0 {
|
||||||
return idx
|
firstInUse = idx
|
||||||
}
|
}
|
||||||
if idx < best {
|
if s.active {
|
||||||
best, found = idx, true
|
return idx, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if firstInUse >= 0 {
|
||||||
return best
|
return firstInUse, f.slices[firstInUse]
|
||||||
|
}
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedSliceIdxLocked returns the slice indices in ascending order so every
|
||||||
|
// slice-selection helper is deterministic (map iteration is randomised). Caller
|
||||||
|
// holds f.mu.
|
||||||
|
func (f *Flex) sortedSliceIdxLocked() []int {
|
||||||
|
idxs := make([]int, 0, len(f.slices))
|
||||||
|
for idx := range f.slices {
|
||||||
|
idxs = append(idxs, idx)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
return idxs
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
||||||
|
// slice, else 0). Caller holds f.mu.
|
||||||
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
|
if idx, _ := f.mainSliceLocked(); idx >= 0 {
|
||||||
|
return idx
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
|
||||||
|
func sliceLetter(idx int) string {
|
||||||
|
if idx < 0 || idx > 25 {
|
||||||
|
return fmt.Sprintf("%d", idx)
|
||||||
|
}
|
||||||
|
return string(rune('A' + idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; s.inUse && s.tx {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
||||||
|
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
||||||
|
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
||||||
|
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
|
||||||
|
// pair itself, NOT from which slice is active (the TX slice often steals focus
|
||||||
|
// right after "slice create", which must NOT read as "no split"). A slice on
|
||||||
|
// another band is an independent receiver, ignored; so is a same-band slice in a
|
||||||
|
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
||||||
|
_, main = f.mainSliceLocked()
|
||||||
|
txS := f.txSliceLocked()
|
||||||
|
if txS == nil {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
bt := BandFromHz(txS.freqHz)
|
||||||
|
if bt == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
|
||||||
|
ct := flexSplitClass(txS.mode)
|
||||||
|
if ct == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// A split partner is an in-use, same-band slice at a different freq in the
|
||||||
|
// SAME split class (so SSB + FT8 on one band is NOT a split).
|
||||||
|
sameSplit := func(s *flexSlice) bool {
|
||||||
|
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||||
|
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
|
||||||
|
}
|
||||||
|
// RX = the active slice when it qualifies, else the first other qualifying
|
||||||
|
// same-band slice.
|
||||||
|
if sameSplit(main) {
|
||||||
|
rx = main
|
||||||
|
} else {
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; sameSplit(s) {
|
||||||
|
rx = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rx == nil {
|
||||||
|
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
|
||||||
|
}
|
||||||
|
return main, rx, txS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveSlice focuses slice idx on the radio so every subsequent command
|
||||||
|
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
|
||||||
|
// operating slice from OpsLog (like SliceLogger's A/B selector).
|
||||||
|
func (f *Flex) SetActiveSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
|
||||||
|
// slice" so you transmit where you're listening. Only one slice can be TX; the
|
||||||
|
// radio clears the flag on the others.
|
||||||
|
func (f *Flex) SetTXSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetFrequency(hz int64) error {
|
func (f *Flex) SetFrequency(hz int64) error {
|
||||||
if hz <= 0 {
|
if hz <= 0 {
|
||||||
return fmt.Errorf("flex: invalid frequency")
|
return fmt.Errorf("flex: invalid frequency")
|
||||||
@@ -844,6 +1032,15 @@ func (f *Flex) SetFrequency(hz int64) error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
idx := f.activeSliceIndexLocked()
|
idx := f.activeSliceIndexLocked()
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
|
// Optimistically update the active slice's cached freq NOW, before the radio
|
||||||
|
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
|
||||||
|
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
|
||||||
|
// jumped to the new band while the slice cache — which the FlexPanel and the
|
||||||
|
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
|
||||||
|
// the stale value. The real echo confirms/corrects this a moment later.
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.freqHz = hz
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -869,6 +1066,13 @@ func (f *Flex) SetMode(mode string) error {
|
|||||||
if fm == "" {
|
if fm == "" {
|
||||||
return fmt.Errorf("flex: unsupported mode %q", mode)
|
return fmt.Errorf("flex: unsupported mode %q", mode)
|
||||||
}
|
}
|
||||||
|
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
|
||||||
|
// panel reflects it immediately instead of lagging the radio's echo.
|
||||||
|
f.mu.Lock()
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
||||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
return nil
|
return nil
|
||||||
@@ -892,8 +1096,30 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#FFFFA500" // opaque orange default
|
color = "#FFFFA500" // opaque orange default
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
life := s.LifetimeSec
|
||||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
if life <= 0 {
|
||||||
|
life = 1800 // default 30 min (cluster spots)
|
||||||
|
}
|
||||||
|
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||||
|
// spotted gets its previous spot removed first — one live spot per call,
|
||||||
|
// refreshed, instead of a pile that all expire independently. NB: capture the
|
||||||
|
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
|
||||||
|
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
|
||||||
|
// whole Flex goroutine → the radio drops OFFLINE).
|
||||||
|
upperCall := strings.ToUpper(s.Callsign)
|
||||||
|
f.mu.Lock()
|
||||||
|
old, hadOld := f.spotByCall[upperCall]
|
||||||
|
if hadOld {
|
||||||
|
delete(f.spotByCall, upperCall)
|
||||||
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotIdx, old)
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hadOld {
|
||||||
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
@@ -939,6 +1165,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
@@ -1017,19 +1244,10 @@ func clampLevel(v int) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
|
// rxSliceLocked returns the operator's (main/active) slice and its index — the
|
||||||
// the same RX-selection rule as ReadState. Caller holds f.mu.
|
// slice every RX-DSP control and read targets. Caller holds f.mu.
|
||||||
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
||||||
rx, _ := f.pickSlicesLocked()
|
return f.mainSliceLocked()
|
||||||
if rx == nil {
|
|
||||||
return -1, nil
|
|
||||||
}
|
|
||||||
for i, s := range f.slices {
|
|
||||||
if s == rx {
|
|
||||||
return i, rx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1, rx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
||||||
@@ -1053,6 +1271,10 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
Mon: f.tx.mon,
|
Mon: f.tx.mon,
|
||||||
MonLevel: f.tx.monLevel,
|
MonLevel: f.tx.monLevel,
|
||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
|
TXFilterLow: f.tx.filterLow,
|
||||||
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
MicProfile: f.micProfile,
|
||||||
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
ATUMemories: f.tx.atuMemories,
|
ATUMemories: f.tx.atuMemories,
|
||||||
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
||||||
@@ -1062,10 +1284,31 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
CWSidetone: f.tx.cwSidetone,
|
CWSidetone: f.tx.cwSidetone,
|
||||||
CWMonLevel: f.tx.cwMonLevel,
|
CWMonLevel: f.tx.cwMonLevel,
|
||||||
}
|
}
|
||||||
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
|
if _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RXFreqHz = rx.freqHz
|
st.RXFreqHz = rxS.freqHz
|
||||||
st.TXFreqHz = tx.freqHz
|
st.TXFreqHz = txSplit.freqHz
|
||||||
|
}
|
||||||
|
// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
|
||||||
|
// active/TX one — the active slice drives everything the operator does.
|
||||||
|
sidx := make([]int, 0, len(f.slices))
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s.inUse {
|
||||||
|
sidx = append(sidx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(sidx)
|
||||||
|
for _, i := range sidx {
|
||||||
|
s := f.slices[i]
|
||||||
|
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||||
|
Index: i,
|
||||||
|
Letter: sliceLetter(i),
|
||||||
|
FreqHz: s.freqHz,
|
||||||
|
Mode: flexModeToADIF(s.mode),
|
||||||
|
Band: BandFromHz(s.freqHz),
|
||||||
|
Active: s.active,
|
||||||
|
TX: s.tx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
st.RXAvail = true
|
st.RXAvail = true
|
||||||
@@ -1082,6 +1325,12 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.ANFLevel = rx.anfLevel
|
st.ANFLevel = rx.anfLevel
|
||||||
st.APF = rx.apf
|
st.APF = rx.apf
|
||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
|
st.WNB = rx.wnb
|
||||||
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.RIT = rx.rit
|
||||||
|
st.RITFreq = rx.ritFreq
|
||||||
|
st.XIT = rx.xit
|
||||||
|
st.XITFreq = rx.xitFreq
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
st.RXAnt = rx.rxAnt
|
st.RXAnt = rx.rxAnt
|
||||||
@@ -1106,7 +1355,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
@@ -1150,6 +1399,14 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
rx.txAnt = fmt.Sprint(val)
|
rx.txAnt = fmt.Sprint(val)
|
||||||
|
case "rit_on":
|
||||||
|
rx.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
rx.ritFreq = toInt(val)
|
||||||
|
case "xit_on":
|
||||||
|
rx.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
rx.xitFreq = toInt(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1198,35 +1455,40 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
// Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
|
||||||
|
// same-band-pair rule as the button state (operatingLocked) — otherwise two
|
||||||
|
// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
|
||||||
|
// nothing (the bug the user hit: A on 20m + B on 80m).
|
||||||
|
main, rxS, txS := f.operatingLocked()
|
||||||
rxIdx, txIdx := -1, -1
|
rxIdx, txIdx := -1, -1
|
||||||
for i, s := range f.slices {
|
for i, s := range f.slices {
|
||||||
if s == rx {
|
if s == rxS {
|
||||||
rxIdx = i
|
rxIdx = i
|
||||||
}
|
}
|
||||||
if s == tx {
|
if s == txS {
|
||||||
txIdx = i
|
txIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alreadySplit := rx != nil && tx != nil && rx != tx
|
alreadySplit := rxS != nil && txS != nil
|
||||||
var rxFreq int64
|
var baseFreq int64
|
||||||
var rxMode, rxAnt string
|
var baseMode, baseAnt string
|
||||||
if rx != nil {
|
if main != nil {
|
||||||
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
|
baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
if on {
|
if on {
|
||||||
if alreadySplit || rx == nil {
|
if alreadySplit || main == nil {
|
||||||
return nil // already split (or no slice)
|
return nil // already split, or no active slice
|
||||||
}
|
}
|
||||||
offset := int64(5000)
|
offset := int64(5000)
|
||||||
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
|
if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
|
||||||
offset = 1000
|
offset = 1000
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
|
// Create the TX slice at the ACTIVE slice's freq + offset (same band).
|
||||||
if rxAnt != "" {
|
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
|
||||||
cmd += " ant=" + rxAnt
|
if baseAnt != "" {
|
||||||
|
cmd += " ant=" + baseAnt
|
||||||
}
|
}
|
||||||
if seq := f.send(cmd); seq > 0 {
|
if seq := f.send(cmd); seq > 0 {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -1239,10 +1501,10 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if txIdx >= 0 {
|
if txIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice remove %d", txIdx))
|
f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
|
||||||
}
|
}
|
||||||
if rxIdx >= 0 {
|
if rxIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
|
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1252,8 +1514,30 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
|
// radio's own knob.
|
||||||
|
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
||||||
|
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
||||||
|
|
||||||
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
|
func clampOffset(hz int) int {
|
||||||
|
if hz > 99999 {
|
||||||
|
return 99999
|
||||||
|
}
|
||||||
|
if hz < -99999 {
|
||||||
|
return -99999
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
// ── CW keyer controls (top-level "cw" commands) ──
|
// ── CW keyer controls (top-level "cw" commands) ──
|
||||||
|
|
||||||
@@ -1471,6 +1755,45 @@ func (f *Flex) SetMic(l int) error {
|
|||||||
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
|
||||||
|
// clamps to legal values per mode; we just pass them through in one command so
|
||||||
|
// both edges move together.
|
||||||
|
func (f *Flex) SetTXFilter(low, high int) error {
|
||||||
|
if low < 0 {
|
||||||
|
low = 0
|
||||||
|
}
|
||||||
|
if high < low {
|
||||||
|
high = low
|
||||||
|
}
|
||||||
|
// Guard both status field names (the echo comes back as lo/hi, not
|
||||||
|
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.txSetAt["hi"] = time.Now()
|
||||||
|
f.mu.Unlock()
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
|
||||||
|
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
|
||||||
|
// spaces). Optimistically caches the selection.
|
||||||
|
func (f *Flex) SetMicProfile(name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("flex: empty mic profile")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if connected {
|
||||||
|
f.micProfile = name
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) ATUStart() error {
|
func (f *Flex) ATUStart() error {
|
||||||
if !f.connected() {
|
if !f.connected() {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -1690,6 +2013,23 @@ func parseFloatDefault(s string, def float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
|
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
|
||||||
|
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
|
||||||
|
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
|
||||||
|
// Flex) and digital voice never operate split, so they return "" (not
|
||||||
|
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
|
||||||
|
// independent operations, not a split.
|
||||||
|
func flexSplitClass(rawMode string) string {
|
||||||
|
switch flexModeToADIF(rawMode) {
|
||||||
|
case "SSB", "AM", "FM":
|
||||||
|
return "PHONE"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
default: // DATA, RTTY, DIGITALVOICE, "" → never split
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flexModeToADIF(m string) string {
|
func flexModeToADIF(m string) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
case "USB", "LSB":
|
case "USB", "LSB":
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A genuine split is two same-band slices in the SAME split class (both PHONE or
|
||||||
|
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
|
||||||
|
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
|
||||||
|
func TestOperatingSplitMode(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
slices map[int]*flexSlice
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
{"phone split (both USB)", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_200_000, "USB", false, true),
|
||||||
|
}, true},
|
||||||
|
{"CW split (both CW)", map[int]*flexSlice{
|
||||||
|
0: mk(7_010_000, "CW", true, false),
|
||||||
|
1: mk(7_025_000, "CW", false, true),
|
||||||
|
}, true},
|
||||||
|
{"SSB + FT8 same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_074_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_074_000, "DIGU", true, false),
|
||||||
|
1: mk(14_080_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"different bands → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(7_100_000, "LSB", false, true),
|
||||||
|
}, false},
|
||||||
|
{"simplex (single slice) → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, true),
|
||||||
|
}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
f := &Flex{slices: c.slices}
|
||||||
|
_, rx, tx := f.operatingLocked()
|
||||||
|
gotSplit := rx != nil && tx != nil
|
||||||
|
if gotSplit != c.wantSplit {
|
||||||
|
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
|
||||||
|
// It is the third stream alongside control (50001) and CI-V (50002): once the
|
||||||
|
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
|
||||||
|
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
|
||||||
|
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
|
||||||
|
// PROVEN transport — and hands each received audio payload to a sink callback
|
||||||
|
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
|
||||||
|
//
|
||||||
|
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
|
||||||
|
// icnLocalID, icnLE) and the same seq/retransmit discipline.
|
||||||
|
//
|
||||||
|
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
|
||||||
|
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
|
||||||
|
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
|
||||||
|
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
|
||||||
|
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
|
||||||
|
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
|
||||||
|
// confirmed/corrected on the first on-rig test without a packet capture, the same
|
||||||
|
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
|
||||||
|
// audio stream is opt-in and entirely separate from control/CI-V.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icaAudioOffset is where the PCM payload begins inside an audio data packet
|
||||||
|
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
|
||||||
|
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
|
||||||
|
// is a one-line edit.
|
||||||
|
const icaAudioOffset = 0x16
|
||||||
|
|
||||||
|
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
|
||||||
|
// offset verification. After the layout is confirmed on a real rig this can go to
|
||||||
|
// 0 (or the const above corrected).
|
||||||
|
const icaDumpFirst = 6
|
||||||
|
|
||||||
|
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
|
||||||
|
// 5) will add an encode+send path mirroring icomNet.Write.
|
||||||
|
type icomAudio struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
aID, aRemote uint32
|
||||||
|
|
||||||
|
sink func([]byte) // receives each raw audio payload (app decodes + plays)
|
||||||
|
|
||||||
|
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
|
||||||
|
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
|
||||||
|
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
||||||
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
||||||
|
func (a *icomAudio) Close() {
|
||||||
|
a.closeOnce.Do(func() {
|
||||||
|
close(a.done)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
}
|
||||||
|
_ = a.conn.Close()
|
||||||
|
debugLog.Printf("icom audio: stream closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
|
||||||
|
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
|
||||||
|
// audioport=50003) must already have authorized it. sink receives each raw audio
|
||||||
|
// payload. cancel aborts a slow dial (Stop/Start).
|
||||||
|
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
|
||||||
|
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aID := icnLocalID(conn)
|
||||||
|
aRemote, err := icnHandshake(conn, aID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
debugLog.Printf("icom audio: handshake FAILED: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetReadBuffer(1 << 20)
|
||||||
|
a := &icomAudio{
|
||||||
|
conn: conn, aID: aID, aRemote: aRemote,
|
||||||
|
sink: sink,
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
a.markRx()
|
||||||
|
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
|
||||||
|
go a.audioPump()
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
|
||||||
|
// requests retransmits for lost packets, and hands each audio payload to sink.
|
||||||
|
func (a *icomAudio) audioPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
|
||||||
|
a.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
|
||||||
|
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
|
||||||
|
case typ == 0x05: // rig-initiated disconnect
|
||||||
|
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
|
||||||
|
case typ == 0x00 && k > icaAudioOffset: // audio data packet
|
||||||
|
a.trackRxSeq(icnLE.Uint16(buf[6:]))
|
||||||
|
if a.dumped < icaDumpFirst {
|
||||||
|
a.dumped++
|
||||||
|
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
|
||||||
|
}
|
||||||
|
if a.sink != nil {
|
||||||
|
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
|
||||||
|
a.sink(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
a.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
|
||||||
|
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
|
||||||
|
// the audio stream owns its own seq state with no shared locking.
|
||||||
|
func (a *icomAudio) trackRxSeq(seq uint16) {
|
||||||
|
if !a.rxHaveSeq {
|
||||||
|
a.rxHaveSeq = true
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - a.rxLastSeq); {
|
||||||
|
case d == 0:
|
||||||
|
case d < 0:
|
||||||
|
delete(a.rxMissing, seq)
|
||||||
|
case d == 1:
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing:
|
||||||
|
for f := a.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
a.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
default:
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) sendRetransmitReq() {
|
||||||
|
if len(a.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(a.rxMissing) > icnMaxMissing {
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range a.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(a.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01)
|
||||||
|
icnLE.PutUint32(b[8:], a.aID)
|
||||||
|
icnLE.PutUint32(b[12:], a.aRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = a.conn.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomnet.go — the NETWORK transport for the Icom backend. It talks the Icom IP
|
||||||
|
// remote protocol (the LAN server built into the IC-7610, the one the Icom
|
||||||
|
// Remote Utility speaks) directly, and presents the tunnelled CI-V byte stream
|
||||||
|
// as a plain civTransport (Read/Write). So the entire IcomController surface —
|
||||||
|
// freq/mode, receive-DSP, TX, scope, RIT, CW — runs unchanged over the network;
|
||||||
|
// only the transport differs. OpsLog thus replaces BOTH the Remote Utility and
|
||||||
|
// RS-BA1.
|
||||||
|
//
|
||||||
|
// The protocol (framing, passcode table, packet offsets, power-on) was
|
||||||
|
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||||
|
// against real Remote-Utility captures. No GPLv3 code is copied.
|
||||||
|
//
|
||||||
|
// Three UDP streams exist on the rig (control 50001 / CI-V 50002 / audio 50003);
|
||||||
|
// this transport uses control + CI-V (audio is not needed for CAT). Connect:
|
||||||
|
// control: areYouThere→iAmHere→areYouReady→iAmReady → login → token → conninfo
|
||||||
|
// civ: areYouThere→…→iAmReady → openClose(open) → power-on
|
||||||
|
// then CI-V flows in data packets. A pump goroutine keeps both streams alive
|
||||||
|
// (ping replies + idle keepalives) and feeds received CI-V bytes to Read.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var icnLE = binary.LittleEndian
|
||||||
|
var icnBE = binary.BigEndian
|
||||||
|
|
||||||
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
||||||
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
||||||
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
||||||
|
//
|
||||||
|
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
|
||||||
|
// non-nil the conninfo asks the rig to stream audio and each received payload is
|
||||||
|
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
|
||||||
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
|
civAddr = 0x98 // IC-7610
|
||||||
|
}
|
||||||
|
if digitalDefault == "" {
|
||||||
|
digitalDefault = "FT8"
|
||||||
|
}
|
||||||
|
b := &IcomSerial{
|
||||||
|
portName: host,
|
||||||
|
rigAddr: byte(civAddr),
|
||||||
|
digital: strings.ToUpper(digitalDefault),
|
||||||
|
model: "Icom",
|
||||||
|
scopeFixed: true,
|
||||||
|
}
|
||||||
|
b.open = func() (civTransport, error) {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return nil, fmt.Errorf("no rig host configured")
|
||||||
|
}
|
||||||
|
b.dialMu.Lock()
|
||||||
|
cancel := b.dialCancel
|
||||||
|
b.dialMu.Unlock()
|
||||||
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// errDialCanceled is returned by dialIcomNet when Interrupt() aborts the dial
|
||||||
|
// (Stop/Start). The Manager treats it like any connect error and simply stops.
|
||||||
|
var errDialCanceled = fmt.Errorf("dial canceled")
|
||||||
|
|
||||||
|
// icnCanceled reports whether the dial has been asked to abort.
|
||||||
|
func icnCanceled(cancel <-chan struct{}) bool {
|
||||||
|
if cancel == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancel:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icomNet is the connected network transport. It satisfies civTransport.
|
||||||
|
type icomNet struct {
|
||||||
|
ctrl *net.UDPConn // control stream (50001)
|
||||||
|
civ *net.UDPConn // CI-V stream (50002)
|
||||||
|
|
||||||
|
cID, cRemote uint32 // control stream ids
|
||||||
|
vID, vRemote uint32 // civ stream ids
|
||||||
|
|
||||||
|
// Tracked-packet sequence + CI-V data sequence. Written only by Write (on the
|
||||||
|
// CAT goroutine) and during dial — never by the pump — so no lock is needed.
|
||||||
|
vTracked uint16
|
||||||
|
vCivSeq uint16
|
||||||
|
|
||||||
|
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
||||||
|
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
||||||
|
// stream can't crowd control replies out (→ ScopeChan)
|
||||||
|
leftover []byte // partial chunk not yet returned by Read (Read-only)
|
||||||
|
readTO time.Duration // Read timeout (SetReadTimeout)
|
||||||
|
|
||||||
|
// sentBuf keeps recently-sent tracked civ packets (by outer seq) so we can
|
||||||
|
// answer the rig's UDP retransmit requests. Written by Write (CAT goroutine),
|
||||||
|
// read by the pumps → guarded by sentMu.
|
||||||
|
sentMu sync.Mutex
|
||||||
|
sentBuf map[uint16][]byte
|
||||||
|
|
||||||
|
// Control-stream auth state, carried out of dial so ctrlPump can RENEW the
|
||||||
|
// login token every ~45 s. The rig invalidates the session ~2 min after login
|
||||||
|
// without renewal (this was the "loses control after 2 min" drop — RS-BA1/the
|
||||||
|
// Remote Utility renew too). Owned solely by ctrlPump after dial → no lock.
|
||||||
|
cTracked uint16 // control-stream tracked seq (continues after dial)
|
||||||
|
cAuthSeq uint16 // token-packet innerseq
|
||||||
|
cToken uint32 // login token (opaque, echoed back verbatim)
|
||||||
|
cTokReq uint16 // token-request id (echoed)
|
||||||
|
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
|
||||||
|
|
||||||
|
// Receive-side retransmit (CI-V stream): track the rig's data-packet send seq
|
||||||
|
// and ask it to resend any gap. Under the scope stream, UDP drops are common;
|
||||||
|
// without recovering them the gaps accumulate and the rig drops the WHOLE
|
||||||
|
// session after ~20 s (RS-BA1/wfview request retransmits, which is why they
|
||||||
|
// stay up with the panadapter on). Owned solely by civPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
// lastRx is the UnixNano of the last packet received from the rig (any type),
|
||||||
|
// updated by both pumps. The rig's network server answers pings/idles even
|
||||||
|
// when the RADIO is in standby, so this tracks the CONTROL-LINK liveness
|
||||||
|
// independently of whether CI-V is replying — letting ReadState tell "rig off
|
||||||
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
|
audio *icomAudio
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeChan exposes the raw scope (0x27) CI-V frames for the scope feeder.
|
||||||
|
// Satisfies scopeTransport in icomserial.go.
|
||||||
|
func (n *icomNet) ScopeChan() <-chan []byte { return n.scopeRx }
|
||||||
|
|
||||||
|
// icnEnqueueDrop pushes onto a bounded channel, discarding the oldest entry when
|
||||||
|
// full — a lagging consumer never blocks the producer (used for the scope stream,
|
||||||
|
// where only the latest sweep matters).
|
||||||
|
func icnEnqueueDrop(ch chan []byte, v []byte) {
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *icomNet) SetReadTimeout(d time.Duration) error { n.readTO = d; return nil }
|
||||||
|
func (n *icomNet) SetDTR(bool) error { return nil } // n/a on the network
|
||||||
|
func (n *icomNet) SetRTS(bool) error { return nil }
|
||||||
|
|
||||||
|
// markRx records that a packet just arrived from the rig (control-link liveness).
|
||||||
|
func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Alive reports whether the rig's network server is still talking to us. The rig
|
||||||
|
// pings/idles continuously (even in standby), so a gap means the link — not just
|
||||||
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
|
func (n *icomNet) Alive() bool {
|
||||||
|
last := n.lastRx.Load()
|
||||||
|
if last == 0 {
|
||||||
|
return true // just connected, nothing received yet — give it a chance
|
||||||
|
}
|
||||||
|
return time.Since(time.Unix(0, last)) < 6*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns tunnelled CI-V bytes, mimicking a serial port: (0,nil) on
|
||||||
|
// timeout, (n,nil) with data, (0,err) when the link is closed.
|
||||||
|
func (n *icomNet) Read(p []byte) (int, error) {
|
||||||
|
if len(n.leftover) > 0 {
|
||||||
|
k := copy(p, n.leftover)
|
||||||
|
n.leftover = n.leftover[k:]
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
to := n.readTO
|
||||||
|
if to <= 0 {
|
||||||
|
to = 60 * time.Millisecond
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case f, ok := <-n.rx:
|
||||||
|
if !ok {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
k := copy(p, f)
|
||||||
|
if k < len(f) {
|
||||||
|
n.leftover = append(n.leftover[:0], f[k:]...)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
case <-time.After(to):
|
||||||
|
return 0, nil // timeout, no data
|
||||||
|
case <-n.done:
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write wraps raw CI-V bytes (FE FE … FD) in a data packet and sends them.
|
||||||
|
func (n *icomNet) Write(p []byte) (int, error) {
|
||||||
|
if icnTrace {
|
||||||
|
debugLog.Printf("icom net TX: % X", p)
|
||||||
|
}
|
||||||
|
seq := n.vTracked
|
||||||
|
pkt := icnCivData(seq, n.vID, n.vRemote, n.vCivSeq, p)
|
||||||
|
n.vTracked++
|
||||||
|
n.vCivSeq++
|
||||||
|
n.sentMu.Lock()
|
||||||
|
n.sentBuf[seq] = pkt
|
||||||
|
delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so
|
||||||
|
// the rig's retransmit requests still hit even under sustained CW + poll load
|
||||||
|
n.sentMu.Unlock()
|
||||||
|
if _, err := n.civ.Write(pkt); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
|
||||||
|
// the network transport. Off by default (the connect-step logs stay); flip to
|
||||||
|
// true to trace every TX/RX again.
|
||||||
|
var icnTrace = false
|
||||||
|
|
||||||
|
func (n *icomNet) Close() error {
|
||||||
|
n.closeOnce.Do(func() {
|
||||||
|
close(n.done)
|
||||||
|
if n.audio != nil {
|
||||||
|
n.audio.Close()
|
||||||
|
}
|
||||||
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
||||||
|
// once. If it never gets a disconnect it holds the session for minutes and
|
||||||
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
||||||
|
// left the rig un-reconnectable, even from the Icom Remote Utility. UDP is
|
||||||
|
// lossy, so send openClose(close) + disconnect on both streams a few times.
|
||||||
|
// The whole teardown is bounded to ~90 ms so it never stalls the caller
|
||||||
|
// (Settings "Save & Close" / a reconnect's Disconnect).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close CI-V
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect civ
|
||||||
|
_, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) // disconnect ctrl
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: sent disconnect to rig (session released)")
|
||||||
|
_ = n.civ.Close()
|
||||||
|
_ = n.ctrl.Close()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings,
|
||||||
|
// sends idle keepalives, RENEWS the login token every ~45 s (without this the rig
|
||||||
|
// invalidates the session after ~2 min → total loss of control), and answers the
|
||||||
|
// rig's retransmit requests for those tracked control packets. Its own goroutine
|
||||||
|
// so it never throttles civPump.
|
||||||
|
func (n *icomNet) ctrlPump() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastToken := time.Now() // token was just granted during dial
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = n.ctrl.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := n.ctrl.Read(buf); err == nil && k >= 16 {
|
||||||
|
n.markRx()
|
||||||
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
|
case 0x07: // ping
|
||||||
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||||
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
|
if k >= 8 {
|
||||||
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
|
}
|
||||||
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastToken) > 45*time.Second {
|
||||||
|
n.renewToken()
|
||||||
|
lastToken = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewToken re-authorizes the session (control 0x40 token packet, requesttype
|
||||||
|
// 0x05). Tracked so a lost renewal can be retransmitted. Runs only on ctrlPump,
|
||||||
|
// the sole owner of the control-stream auth state, so no locking is needed.
|
||||||
|
func (n *icomNet) renewToken() {
|
||||||
|
seq := n.cTracked
|
||||||
|
pkt := icnTokenRenew(seq, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken)
|
||||||
|
n.cTracked++
|
||||||
|
n.cAuthSeq++
|
||||||
|
n.cSentBuf[seq] = pkt
|
||||||
|
delete(n.cSentBuf, seq-256)
|
||||||
|
_, _ = n.ctrl.Write(pkt)
|
||||||
|
debugLog.Printf("icom net: token renewed (seq %d)", seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlResend answers a control-stream retransmit request from the control
|
||||||
|
// sent-buffer (token renewals). Separate from resend(), which owns the CI-V
|
||||||
|
// buffer — the two streams have independent sequence spaces.
|
||||||
|
func (n *icomNet) ctrlResend(seq uint16) {
|
||||||
|
if pkt := n.cSentBuf[seq]; pkt != nil {
|
||||||
|
_, _ = n.ctrl.Write(pkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// civPump owns the CI-V stream (50002): drains it as fast as packets arrive
|
||||||
|
// (its own goroutine — not throttled by the control reads), replies to pings,
|
||||||
|
// answers retransmit requests, skips scope frames, and feeds control CI-V bytes
|
||||||
|
// to Read via n.rx.
|
||||||
|
func (n *icomNet) civPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := n.civ.Read(buf); err == nil && k >= 16 {
|
||||||
|
n.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = n.civ.Write(icnPingReply(buf[:k], n.vID, n.vRemote))
|
||||||
|
case typ == 0x01: // retransmit request — resend that seq
|
||||||
|
if k >= 8 {
|
||||||
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
|
}
|
||||||
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
civBytes := buf[0x15:k]
|
||||||
|
cp := append([]byte(nil), civBytes...)
|
||||||
|
// Scope (0x27) frames go to their OWN channel: the panadapter streams
|
||||||
|
// continuously as large frames and would otherwise crowd control
|
||||||
|
// replies out of rx (every command would then time out). The scope
|
||||||
|
// feeder in IcomSerial picks them up. Everything else is a control
|
||||||
|
// reply → rx → Read.
|
||||||
|
if len(civBytes) >= 5 && civBytes[4] == 0x27 {
|
||||||
|
icnEnqueueDrop(n.scopeRx, cp)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if icnTrace {
|
||||||
|
debugLog.Printf("icom net RX: % X", civBytes)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n.rx <- cp:
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default: // buffer full — drop oldest, enqueue newest
|
||||||
|
select {
|
||||||
|
case <-n.rx:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n.rx <- cp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 150*time.Millisecond {
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
n.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnMaxMissing caps the outstanding retransmit backlog; a bigger jump is treated
|
||||||
|
// as a wrap/desync and the tracker resets rather than requesting a storm.
|
||||||
|
const icnMaxMissing = 50
|
||||||
|
|
||||||
|
// trackRxSeq records the rig's data-packet send seq (outer seq @0x06) and flags
|
||||||
|
// any forward gap as missing so sendRetransmitReq can ask for it. Handles uint16
|
||||||
|
// wrap via the signed distance; ignores duplicates and already-seen packets.
|
||||||
|
func (n *icomNet) trackRxSeq(seq uint16) {
|
||||||
|
if !n.rxHaveSeq {
|
||||||
|
n.rxHaveSeq = true
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - n.rxLastSeq); {
|
||||||
|
case d == 0: // duplicate
|
||||||
|
case d < 0: // an older seq arrived — a retransmit we were missing
|
||||||
|
delete(n.rxMissing, seq)
|
||||||
|
case d == 1: // in order
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing: // forward gap — mark the in-between seqs missing
|
||||||
|
for f := n.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
n.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
default: // huge jump (wrap/desync) — reset to avoid a false retransmit storm
|
||||||
|
n.rxMissing = make(map[uint16]int)
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRetransmitReq asks the rig to resend any CI-V data packets we detected as
|
||||||
|
// missing. Each seq is requested up to 4 times then dropped. Mirrors the Remote
|
||||||
|
// Utility/wfview format: a single miss = a 16-byte control (type 0x01, seq set);
|
||||||
|
// several = a control header + a list of [lo hi lo hi] per seq.
|
||||||
|
func (n *icomNet) sendRetransmitReq() {
|
||||||
|
if len(n.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(n.rxMissing) > icnMaxMissing {
|
||||||
|
n.rxMissing = make(map[uint16]int) // hopelessly behind — flush and move on
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range n.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(n.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x01, seqs[0], n.vID, n.vRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01) // type = retransmit request
|
||||||
|
icnLE.PutUint32(b[8:], n.vID)
|
||||||
|
icnLE.PutUint32(b[12:], n.vRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = n.civ.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resend re-transmits a previously-sent tracked CI-V packet the rig asks for
|
||||||
|
// (its UDP retransmit mechanism). Without this the rig drops the whole session
|
||||||
|
// after a few seconds when a packet is lost under load.
|
||||||
|
func (n *icomNet) resend(seq uint16) {
|
||||||
|
n.sentMu.Lock()
|
||||||
|
pkt := n.sentBuf[seq]
|
||||||
|
n.sentMu.Unlock()
|
||||||
|
if pkt != nil {
|
||||||
|
_, _ = n.civ.Write(pkt)
|
||||||
|
} else {
|
||||||
|
// The rig asked for a packet we've already evicted (>256 sent since). It
|
||||||
|
// can't fill its gap → it eventually drops the session. If this shows up in
|
||||||
|
// the log around a disconnect, the send buffer is too small for the load.
|
||||||
|
debugLog.Printf("icom net: retransmit MISS for seq %d (already evicted)", seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- connect -------------------------
|
||||||
|
|
||||||
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
|
||||||
|
wantAudio := audioSink != nil
|
||||||
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
|
||||||
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
||||||
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrl, err := net.DialUDP("udp4", nil, craddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial control: %w", err)
|
||||||
|
}
|
||||||
|
cID := icnLocalID(ctrl)
|
||||||
|
cRemote, err := icnHandshake(ctrl, cID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: control handshake FAILED (rig unreachable at %s:50001?): %v", host, err)
|
||||||
|
return nil, fmt.Errorf("control handshake: %w", err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: control link up (rig id 0x%08X) — logging in", cRemote)
|
||||||
|
|
||||||
|
var cTracked, cInner uint16 = 1, 1
|
||||||
|
tokReq := uint16(0x0c77)
|
||||||
|
_, _ = ctrl.Write(icnLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
|
||||||
|
var token uint32
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for token == 0 && time.Now().Before(deadline) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(ctrl, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
length := icnLE.Uint32(p[0:])
|
||||||
|
typ := icnLE.Uint16(p[4:])
|
||||||
|
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||||
|
token = icnLE.Uint32(p[0x1c:])
|
||||||
|
if e := icnLE.Uint32(p[0x30:]); e != 0 || token == 0 {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: LOGIN REJECTED (err=0x%08X) — wrong Network User1 ID/Password", e)
|
||||||
|
return nil, fmt.Errorf("login rejected — check the rig's Network User1 ID/Password")
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
debugLog.Printf("icom net: LOGIN OK, token 0x%08X", token)
|
||||||
|
} else if typ == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == 0 {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: login TIMED OUT (no token in 5s) — check host/credentials")
|
||||||
|
return nil, fmt.Errorf("login timed out (no token) — check host/credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn the rig's MAC from its conninfo push (144B) to echo in our conninfo.
|
||||||
|
var rigMAC []byte
|
||||||
|
macEnd := time.Now().Add(1200 * time.Millisecond)
|
||||||
|
for time.Now().Before(macEnd) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(ctrl, 150, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p) >= 0x30 && icnLE.Uint32(p[0:]) == 0x90 { // 144-byte conninfo push
|
||||||
|
rigMAC = append([]byte(nil), p[0x2a:0x30]...)
|
||||||
|
}
|
||||||
|
if icnLE.Uint16(p[4:]) == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
if rigMAC != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rigMAC == nil {
|
||||||
|
rigMAC = make([]byte, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rxEnable byte
|
||||||
|
if wantAudio {
|
||||||
|
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(drainEnd) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
if p, ok := icnRecv(ctrl, 100, buf); ok && icnLE.Uint16(p[4:]) == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CI-V stream (50002): bind LOCAL :50002 (the announced civport) ----
|
||||||
|
vraddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50002"))
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: conninfo sent (rig mac % X) — opening CI-V stream", rigMAC)
|
||||||
|
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, vraddr)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: cannot bind local :50002 — the Icom Remote Utility is probably still running: %v", err)
|
||||||
|
return nil, fmt.Errorf("dial CI-V (local :50002 — is the Icom Remote Utility still running?): %w", err)
|
||||||
|
}
|
||||||
|
vID := icnLocalID(civ)
|
||||||
|
vRemote, err := icnHandshake(civ, vID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = civ.Close()
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: CI-V handshake FAILED: %v", err)
|
||||||
|
return nil, fmt.Errorf("CI-V handshake: %w", err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: CI-V link up — opening the CI-V data flow (rig power left to the ON button)")
|
||||||
|
|
||||||
|
// Bigger receive buffers so a burst of scope/CI-V packets doesn't overflow
|
||||||
|
// (dropped packets → the rig's retransmit requests → session drop).
|
||||||
|
_ = ctrl.SetReadBuffer(1 << 20)
|
||||||
|
_ = civ.SetReadBuffer(1 << 20)
|
||||||
|
|
||||||
|
n := &icomNet{
|
||||||
|
ctrl: ctrl, civ: civ,
|
||||||
|
cID: cID, cRemote: cRemote, vID: vID, vRemote: vRemote,
|
||||||
|
vTracked: 1, vCivSeq: 1,
|
||||||
|
rx: make(chan []byte, 256),
|
||||||
|
scopeRx: make(chan []byte, 8),
|
||||||
|
sentBuf: make(map[uint16][]byte),
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
// Auth state for periodic token renewal (see ctrlPump). cTracked/cAuthSeq
|
||||||
|
// continue the control-stream sequences from where the dial's login/token/
|
||||||
|
// conninfo left off.
|
||||||
|
cTracked: cTracked, cAuthSeq: cInner,
|
||||||
|
cToken: token, cTokReq: tokReq,
|
||||||
|
cSentBuf: make(map[uint16][]byte),
|
||||||
|
}
|
||||||
|
n.markRx() // the successful handshake counts as initial rig activity
|
||||||
|
// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
|
||||||
|
// rig on here — that's a manual ON button now (the user asked not to wake the
|
||||||
|
// rig at launch). If the rig is in standby the control/CI-V streams still stay
|
||||||
|
// up and Alive() stays true (the rig's server answers pings even when the radio
|
||||||
|
// is off), so the session doesn't flap; CI-V just stays silent until ON.
|
||||||
|
ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04)
|
||||||
|
n.sentBuf[n.vTracked] = ocPkt
|
||||||
|
_, _ = civ.Write(ocPkt)
|
||||||
|
n.vTracked++
|
||||||
|
n.vCivSeq++
|
||||||
|
|
||||||
|
go n.ctrlPump()
|
||||||
|
go n.civPump()
|
||||||
|
|
||||||
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
||||||
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
||||||
|
// CAT works without audio, so we log and continue rather than tear down a
|
||||||
|
// perfectly good control/CI-V session.
|
||||||
|
if wantAudio {
|
||||||
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
||||||
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
||||||
|
} else {
|
||||||
|
n.audio = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||||
|
func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32, error) {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
||||||
|
var remoteID uint32
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
lastTry := time.Now()
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
return 0, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(c, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||||
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
||||||
|
lastTry = time.Now()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
typ := icnLE.Uint16(p[4:])
|
||||||
|
sentid := icnLE.Uint32(p[8:])
|
||||||
|
switch typ {
|
||||||
|
case 0x04: // iAmHere
|
||||||
|
remoteID = sentid
|
||||||
|
_, _ = c.Write(icnCtrl(0x06, 1, myID, remoteID))
|
||||||
|
case 0x06: // iAmReady
|
||||||
|
if remoteID != 0 {
|
||||||
|
return remoteID, nil
|
||||||
|
}
|
||||||
|
case 0x07: // ping
|
||||||
|
_, _ = c.Write(icnPingReply(p, myID, remoteID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("handshake timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnRecv(c *net.UDPConn, ms int, buf []byte) ([]byte, bool) {
|
||||||
|
_ = c.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||||
|
k, err := c.Read(buf)
|
||||||
|
if err != nil || k < 16 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return buf[:k], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnLocalID(c *net.UDPConn) uint32 {
|
||||||
|
a := c.LocalAddr().(*net.UDPAddr)
|
||||||
|
ip := a.IP.To4()
|
||||||
|
if ip == nil {
|
||||||
|
ip = []byte{192, 168, 0, 1}
|
||||||
|
}
|
||||||
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(uint16(a.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- packet builders -------------------------
|
||||||
|
// (offsets verified vs wfview structs + real captures)
|
||||||
|
|
||||||
|
func icnCtrl(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
icnLE.PutUint32(b[0:], 0x10)
|
||||||
|
icnLE.PutUint16(b[4:], typ)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnPingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||||
|
r := append([]byte(nil), pkt...)
|
||||||
|
if len(r) >= 17 {
|
||||||
|
icnLE.PutUint32(r[8:], myID)
|
||||||
|
icnLE.PutUint32(r[12:], remoteID)
|
||||||
|
r[16] = 0x01
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||||
|
b := make([]byte, 0x80)
|
||||||
|
icnLE.PutUint32(b[0:], 0x80)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x80-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x00
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
copy(b[0x40:0x50], icnPasscode(user))
|
||||||
|
copy(b[0x50:0x60], icnPasscode(pass))
|
||||||
|
nm := name
|
||||||
|
if len(nm) > 16 {
|
||||||
|
nm = nm[:16]
|
||||||
|
}
|
||||||
|
copy(b[0x60:0x70], []byte(nm))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
icnLE.PutUint32(b[0:], 0x40)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x02
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnTokenRenew builds the periodic token-renewal packet (control 0x40). Same as
|
||||||
|
// the login-time token confirm but requesttype 0x05 (renew) with the resetcap
|
||||||
|
// field (0x0798 BE @0x24) the Remote Utility sends on renewals. Keeps the rig
|
||||||
|
// from invalidating the session (~2-min timeout without renewal). Offsets per the
|
||||||
|
// wfview token_packet struct (verified) — protocol facts, not copied code.
|
||||||
|
func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
icnLE.PutUint32(b[0:], 0x40)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply = request
|
||||||
|
b[0x15] = 0x05 // requesttype = token renewal
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
icnBE.PutUint16(b[0x24:], 0x0798) // resetcap
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
|
||||||
|
b := make([]byte, 0x90)
|
||||||
|
icnLE.PutUint32(b[0:], 0x90)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x90-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
icnLE.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||||
|
copy(b[0x2a:0x30], rigMAC)
|
||||||
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
|
copy(b[0x60:0x70], icnPasscode(user))
|
||||||
|
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
|
||||||
|
b[0x71] = 0x00 // txenable (Phase 5)
|
||||||
|
b[0x72] = 0x10 // rxcodec
|
||||||
|
b[0x73] = 0x04 // txcodec
|
||||||
|
icnBE.PutUint32(b[0x74:], 16000)
|
||||||
|
icnBE.PutUint32(b[0x78:], 8000)
|
||||||
|
icnBE.PutUint32(b[0x7c:], uint32(civPort))
|
||||||
|
icnBE.PutUint32(b[0x80:], uint32(audioPort))
|
||||||
|
icnBE.PutUint32(b[0x84:], 100)
|
||||||
|
b[0x88] = 0x00
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||||
|
b := make([]byte, 0x16)
|
||||||
|
icnLE.PutUint32(b[0:], 0x16)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnLE.PutUint16(b[0x10:], 0x01c0)
|
||||||
|
icnBE.PutUint16(b[0x13:], civSeq)
|
||||||
|
b[0x15] = magic
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||||
|
nn := 0x15 + len(civ)
|
||||||
|
b := make([]byte, nn)
|
||||||
|
icnLE.PutUint32(b[0:], uint32(nn))
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
b[0x10] = 0xc1
|
||||||
|
icnLE.PutUint16(b[0x11:], uint16(len(civ)))
|
||||||
|
icnBE.PutUint16(b[0x13:], civSeq)
|
||||||
|
copy(b[0x15:], civ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnPasscodeSeq — Icom's obfuscation table (values at index 0x20..0x7e).
|
||||||
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||||
|
var icnPasscodeSeq = [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 icnPasscode(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, icnPasscodeSeq[p])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+1131
-46
File diff suppressed because it is too large
Load Diff
@@ -414,6 +414,16 @@ func (o *OmniRig) SetPTT(on bool) error {
|
|||||||
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
||||||
return fmt.Errorf("set Tx=%s: %w", name, err)
|
return fmt.Errorf("set Tx=%s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
// Read the Tx param straight back. OmniRig is async — this may still show the
|
||||||
|
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
|
||||||
|
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
|
||||||
|
txState := "PM_RX"
|
||||||
|
if v.Val&pmTX != 0 {
|
||||||
|
txState = "PM_TX"
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-4
@@ -3,6 +3,7 @@
|
|||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -27,9 +28,10 @@ type TCI struct {
|
|||||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes + state
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
ready bool
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||||
|
ready bool
|
||||||
|
|
||||||
// Cached state pushed by the radio.
|
// Cached state pushed by the radio.
|
||||||
device string
|
device string
|
||||||
@@ -70,8 +72,18 @@ func (t *TCI) Connect() error {
|
|||||||
return fmt.Errorf("tci: no host configured")
|
return fmt.Errorf("tci: no host configured")
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
|
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
// Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at
|
||||||
|
// once instead of waiting out a dead server's 5 s handshake timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
t.mu.Lock()
|
||||||
|
t.dialCancel = cancel
|
||||||
|
t.mu.Unlock()
|
||||||
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||||
conn, _, err := dialer.Dial(url, nil)
|
conn, _, err := dialer.DialContext(ctx, url, nil)
|
||||||
|
cancel()
|
||||||
|
t.mu.Lock()
|
||||||
|
t.dialCancel = nil
|
||||||
|
t.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tci: connect %s: %w", url, err)
|
return fmt.Errorf("tci: connect %s: %w", url, err)
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,22 @@ func (t *TCI) Disconnect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a
|
||||||
|
// dead server's handshake timeout. Satisfies the Manager's interruptible
|
||||||
|
// interface. Safe from another goroutine; a no-op when not dialing.
|
||||||
|
func (t *TCI) Interrupt() {
|
||||||
|
t.mu.Lock()
|
||||||
|
cancel := t.dialCancel
|
||||||
|
c := t.conn
|
||||||
|
t.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ReadState returns the cached state pushed by the radio.
|
// ReadState returns the cached state pushed by the radio.
|
||||||
func (t *TCI) ReadState() (RigState, error) {
|
func (t *TCI) ReadState() (RigState, error) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user