fix: bulk edit refused the fields it offered (#3)
My mistake yesterday: the new confirmation columns went into uploadStatusCols — the QSL Manager's FILTER whitelist — instead of bulkEditableCols. The dialog listed QRZ.com received status and the ten dates, and the repository refused them at Apply, after the operator had selected 54 QSOs. Moved to the right map. And two fields have been broken this way since long before: State and County were offered under "Contacted station" (whose IOTA / POTA / SOTA / SIG siblings are all allowed) but were missing from the whitelist, so choosing one always failed. Added — they are exactly what an import loses and what a whole run shares. The real fix is the test. Two lists in two languages had to agree by hand and nothing checked it, so the failure only ever surfaced as an error message at the last click. The new tests read the ACTUAL field ids out of BulkEditModal.tsx and FilterBuilder.tsx and assert the repository accepts every one — a field added to the UI alone now fails the build instead of the operator.
This commit is contained in:
@@ -98,6 +98,8 @@ const (
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||
@@ -330,6 +332,11 @@ type CATSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
|
||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||
// OmniRigVFO overrides which VFO OpsLog reads: "" follows what the rig file
|
||||
// reports, "A"/"B" force one. Needed because that report is only as good as
|
||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||
@@ -6470,7 +6477,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6518,6 +6525,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if out.DigitalDefault == "" {
|
||||
out.DigitalDefault = "FT8"
|
||||
}
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||
out.OmniRigVFO = v
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||
out.OmniRigNum = n
|
||||
}
|
||||
@@ -6586,6 +6596,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
@@ -11907,7 +11918,7 @@ func (a *App) reloadCAT() {
|
||||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||||
// reloadCAT raised the existing instance's window to the front,
|
||||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
|
||||
case "flex":
|
||||
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
|
||||
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||
//
|
||||
// These two lists live in different packages — a TypeScript field table on one
|
||||
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||
// could not write and the operator got "field … is not bulk-editable" only after
|
||||
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||
//
|
||||
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||
// field added to the UI alone fails here immediately.
|
||||
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
col, ok := bulkFieldColumns[id]
|
||||
if !ok {
|
||||
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||
continue
|
||||
}
|
||||
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||
// written into extras_json, not a column, so the column whitelist does not
|
||||
// apply to them.
|
||||
if strings.HasPrefix(col, "extras.") {
|
||||
continue
|
||||
}
|
||||
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract for the filter builder: every field it offers must be one the
|
||||
// query layer will accept.
|
||||
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
col := m[1]
|
||||
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -17,7 +17,9 @@
|
||||
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
|
||||
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
|
||||
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
|
||||
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else."
|
||||
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
|
||||
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
|
||||
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in this version — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works."
|
||||
],
|
||||
"fr": [
|
||||
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
|
||||
@@ -34,7 +36,9 @@
|
||||
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
|
||||
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
|
||||
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
|
||||
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste."
|
||||
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
|
||||
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
|
||||
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées dans cette version — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1052,7 +1052,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
@@ -2334,6 +2334,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.omnirigVfo')}</Label>
|
||||
<Select value={catCfg.omnirig_vfo || 'auto'}
|
||||
onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
|
||||
<SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
|
||||
<SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -282,7 +282,7 @@ const en: Dict = {
|
||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.omnirigVfo': 'VFO to read', 'cat.omnirigVfoAuto': 'As reported by the rig file', 'cat.omnirigVfoA': 'Always VFO A (main)', 'cat.omnirigVfoB': 'Always VFO B (sub)', 'cat.omnirigVfoHint': 'OmniRig reports the active VFO from the rig file, and some files get it wrong — the frequency then follows the other VFO and appears frozen. Force one here if that happens.', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||
@@ -684,7 +684,7 @@ const fr: Dict = {
|
||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.omnirigVfo': 'VFO à lire', 'cat.omnirigVfoAuto': 'Selon le fichier radio', 'cat.omnirigVfoA': 'Toujours le VFO A (principal)', 'cat.omnirigVfoB': 'Toujours le VFO B (secondaire)', 'cat.omnirigVfoHint': "OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — la fréquence suit alors l'autre VFO et paraît figée. Forcez-en un ici le cas échéant.", 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||
|
||||
@@ -1829,6 +1829,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
backend: string;
|
||||
omnirig_rig: number;
|
||||
omnirig_vfo: string;
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
@@ -1857,6 +1858,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.backend = source["backend"];
|
||||
this.omnirig_rig = source["omnirig_rig"];
|
||||
this.omnirig_vfo = source["omnirig_vfo"];
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
|
||||
+23
-3
@@ -29,6 +29,14 @@ const (
|
||||
type OmniRig struct {
|
||||
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||
|
||||
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
|
||||
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
|
||||
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
|
||||
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||
// while it was following the other one all along.
|
||||
ForceVFO string
|
||||
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
@@ -62,11 +70,17 @@ type OmniRig struct {
|
||||
}
|
||||
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
func NewOmniRig(rigNum int) *OmniRig {
|
||||
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||
if rigNum < 1 || rigNum > 2 {
|
||||
rigNum = 1
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum}
|
||||
v := strings.ToUpper(strings.TrimSpace(forceVFO))
|
||||
if v != "A" && v != "B" {
|
||||
v = ""
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
||||
}
|
||||
|
||||
func (o *OmniRig) Name() string { return "omnirig" }
|
||||
@@ -306,7 +320,13 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
}
|
||||
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
|
||||
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
|
||||
// A forced VFO replaces whatever the rig file reported, BEFORE anything is
|
||||
// derived from it. Nothing downstream then has to know about the override.
|
||||
vfo := s.Vfo
|
||||
if o.ForceVFO != "" {
|
||||
vfo = o.ForceVFO
|
||||
}
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
|
||||
|
||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
|
||||
// operation stays quiet but toggling split or SUB VFO on the radio is
|
||||
|
||||
@@ -47,6 +47,14 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
|
||||
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
|
||||
// (SetSimplexMode acts on main) and read B — "the frequency never follows
|
||||
// the knob". Honouring the enum is right in general, which is why the cure
|
||||
// is an explicit override in the settings, applied before this function.
|
||||
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
|
||||
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
|
||||
|
||||
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||
// order — freqA, then the generic Freq, then freqB.
|
||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
+52
-27
@@ -536,23 +536,11 @@ type UploadRow struct {
|
||||
// uploadStatusCols whitelists the per-service sent-status columns the QSL
|
||||
// Manager may filter on (guards the dynamic column name in the query).
|
||||
var uploadStatusCols = map[string]bool{
|
||||
"lotw_sent": true,
|
||||
"eqsl_sent": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
"lotw_sent": true,
|
||||
"eqsl_sent": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
}
|
||||
|
||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||
@@ -755,16 +743,28 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||
// which would be corrupted or meaningless if bulk-set to a single value.
|
||||
var bulkEditableCols = map[string]bool{
|
||||
// QSL / upload status
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
"operator": true,
|
||||
@@ -787,6 +787,14 @@ var bulkEditableCols = map[string]bool{
|
||||
"my_arrl_sect": true,
|
||||
"my_darc_dok": true,
|
||||
"my_vucc_grids": true,
|
||||
// Contacted station: the LOCATION fields only. State and county are the ones
|
||||
// an import loses and a whole run shares (a park activation, a county line
|
||||
// operation), and the dialog has always offered them — but they were missing
|
||||
// here, so choosing one failed at Apply. The rest of the contacted station
|
||||
// (callsign, name, RST…) stays deliberately out: bulk-setting those would
|
||||
// corrupt the log.
|
||||
"state": true,
|
||||
"cnty": true,
|
||||
// Contest — the exchange/label fields that are constant across a run.
|
||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||
"contest_id": true,
|
||||
@@ -2820,3 +2828,20 @@ func parseTimeLoose(s string) time.Time {
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// IsBulkEditable reports whether a column may be written by a bulk edit. Used by
|
||||
// the app layer's own test to prove that every field the UI offers is actually
|
||||
// accepted here — the two lists live in different packages and drifted apart
|
||||
// once already: the new confirmation columns were added to the QSL Manager's
|
||||
// filter whitelist instead of this one, so the UI offered fields that the
|
||||
// repository then refused with "field … is not bulk-editable".
|
||||
func IsBulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||
|
||||
// IsFilterable reports whether a column may appear in a query filter. Same
|
||||
// reason as IsBulkEditable.
|
||||
func IsFilterable(column string) bool { return filterableColumns[column] }
|
||||
|
||||
// IsBulkEditableExtra / IsFilterableExtra cover the ADIF fields that have no
|
||||
// column of their own and live in extras_json.
|
||||
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
|
||||
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
|
||||
|
||||
Reference in New Issue
Block a user