Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c103140c | ||
|
|
b60e8285a4 | ||
|
|
0f1424335e | ||
|
|
506a1e6e4c | ||
|
|
8b7756beb4 | ||
|
|
dee4ffce2a | ||
|
|
fbf3cd116b | ||
|
|
b2165173cf | ||
|
|
912d727d7a | ||
|
|
32036a93ed | ||
|
|
7f4b0b78c0 | ||
|
|
775daa233e | ||
|
|
4fb013701b | ||
|
|
2dd68da284 | ||
|
|
65991be09d | ||
|
|
1c4697ecb2 | ||
|
|
ab89611eca | ||
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 |
@@ -782,6 +782,13 @@ type App struct {
|
||||
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
|
||||
confDLMu sync.Mutex
|
||||
confDLCancel context.CancelFunc
|
||||
|
||||
// hamlogUnmatched holds the confirmations the last HAMLOG.online import
|
||||
// could not place onto a QSO, kept so they can be exported and worked
|
||||
// through — see ExportHamlogUnmatched. Replaced by each import, never
|
||||
// accumulated: it describes one run, not a history.
|
||||
hamlogUnmatchedMu sync.Mutex
|
||||
hamlogUnmatched []qso.QSO
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file
|
||||
@@ -6451,6 +6458,26 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
// Failures never block the local delete. The operator asked for this QSO to go;
|
||||
// a refusing website is not a reason to keep it, and the log says what happened
|
||||
// so it can be sorted out on the site afterwards.
|
||||
// deleteSnapshot reads the rows behind a set of ids in ONE query.
|
||||
//
|
||||
// The delete hooks used to fetch them one by one. That is a round trip per
|
||||
// contact, and on a remote MySQL logbook 200 deletions became 400 sequential
|
||||
// queries before the DELETE was even issued — the reason deleting a selection
|
||||
// felt broken rather than slow.
|
||||
func (a *App) deleteSnapshot(ids []int64) []qso.QSO {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]qso.QSO, 0, len(ids))
|
||||
if err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||
out = append(out, q)
|
||||
return nil
|
||||
}); err != nil {
|
||||
applog.Printf("delete: reading %d QSO(s) before deletion failed: %v", len(ids), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
@@ -6469,9 +6496,53 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if !doQRZ && !doClublog {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
// Snapshot first — the rows are about to disappear — then withdraw them in
|
||||
// the background. Each service is an HTTP round trip PER CONTACT, so doing
|
||||
// this on the caller's goroutine held the whole UI for as long as the
|
||||
// websites took: minutes for a large selection, with nothing on screen
|
||||
// saying why. The local log is the operator's own copy and must not wait on
|
||||
// two remote sites to answer.
|
||||
rows := a.deleteSnapshot(ids)
|
||||
go a.withdrawRemoteCopies(rows, cfg, doQRZ, doClublog)
|
||||
}
|
||||
|
||||
// maxClublogRefusals is how many consecutive refusals are tolerated before the
|
||||
// withdrawals stop. Three is enough to tell a one-off from a blocked account.
|
||||
const maxClublogRefusals = 3
|
||||
|
||||
// Club Log's delete endpoint is a REAL-TIME one: it exists for an operator
|
||||
// removing a contact they just logged wrongly, at human pace. Club Log watches
|
||||
// the rate and blocks the IP of anything that batches through it — they wrote
|
||||
// to this station about 167 requests in four minutes, which was one deletion of
|
||||
// a couple of hundred rows, not a pile-up.
|
||||
//
|
||||
// There is no bulk-delete API to move to, so the only honest answer is to go at
|
||||
// the pace the endpoint is meant for and to stop rather than push a large
|
||||
// deletion through it. Whoever needs to remove hundreds of QSOs from Club Log
|
||||
// does it on their site, where the tool for it exists.
|
||||
const (
|
||||
clublogDeletePace = 1200 * time.Millisecond
|
||||
maxClublogDeletes = 25
|
||||
)
|
||||
|
||||
// clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
|
||||
// (modified since upload) counts: the copy is there, it is merely out of date.
|
||||
func clublogWasUploaded(status string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case "Y", "M":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
|
||||
started := time.Now()
|
||||
clublogRefused := 0
|
||||
clublogSent := 0
|
||||
for i := range rows {
|
||||
q := rows[i]
|
||||
id := q.ID
|
||||
if q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
if doQRZ {
|
||||
@@ -6490,14 +6561,42 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
if doClublog {
|
||||
// Only ask Club Log about contacts it was actually given. A QSO that was
|
||||
// never uploaded has no copy to withdraw, and asking anyway is not merely
|
||||
// pointless: Club Log answers 403 and starts blocking the account, so a
|
||||
// selection of never-uploaded QSOs turned into hundreds of refused
|
||||
// requests, one after another, each waiting on the network.
|
||||
if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
|
||||
applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
|
||||
} else if doClublog {
|
||||
if clublogSent >= maxClublogDeletes {
|
||||
applog.Printf("extsvc: %d QSOs already withdrawn from Club Log — stopping there. Their delete endpoint is for one contact at a time; remove the rest on clublog.org, which has a tool for it.", clublogSent)
|
||||
doClublog = false
|
||||
continue
|
||||
}
|
||||
if clublogSent > 0 {
|
||||
// Paced deliberately: see clublogDeletePace. This runs in the
|
||||
// background, so the wait costs the operator nothing.
|
||||
time.Sleep(clublogDeletePace)
|
||||
}
|
||||
clublogSent++
|
||||
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
|
||||
clublogRefused++
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||
// Club Log blocks an account that keeps sending requests it
|
||||
// refuses. Stop after a few in a row rather than work through
|
||||
// the whole selection earning a longer block.
|
||||
if clublogRefused >= maxClublogRefusals {
|
||||
applog.Printf("extsvc: Club Log refused %d deletions in a row — giving up on the rest", clublogRefused)
|
||||
doClublog = false
|
||||
}
|
||||
} else {
|
||||
clublogRefused = 0
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
applog.Printf("extsvc: withdrew %d deleted QSO(s) from the remote services in %s", len(rows), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
@@ -6506,9 +6605,23 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
started := time.Now()
|
||||
a.deleteRemoteCopies(ids)
|
||||
a.syncPublishDeletes(ids)
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
beforeDelete := time.Since(started)
|
||||
n, err := a.qso.DeleteMany(a.ctx, ids)
|
||||
// Logged because a delete that does nothing is otherwise indistinguishable
|
||||
// from a delete that worked: the rows leave the grid either way once it
|
||||
// reloads, and the only place the truth survives is here.
|
||||
if err != nil {
|
||||
applog.Printf("delete: %d QSO(s) requested, FAILED after %s: %v", len(ids), time.Since(started).Round(time.Millisecond), err)
|
||||
} else {
|
||||
// Both timings, because they blame different things: a long prelude is
|
||||
// the sync/withdraw hooks, a long DELETE is the database itself.
|
||||
applog.Printf("delete: %d QSO(s) requested, %d row(s) removed — %s before the delete, %s total",
|
||||
len(ids), n, beforeDelete.Round(time.Millisecond), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DuplicateGroup is a set of QSOs the log considers the same contact.
|
||||
@@ -15054,6 +15167,9 @@ func (a *App) reloadCAT() {
|
||||
// looking at the same signal, and only the click's origin differs.
|
||||
a.FlexZoomForSpot(mode, hz)
|
||||
}
|
||||
// Spots posted by ANOTHER program — a CW skimmer, in practice. See
|
||||
// flexrstchase.go: a marked report is where the DX was just listening.
|
||||
fb.OnForeignSpot = a.handleForeignSpot
|
||||
a.cat.Start(fb)
|
||||
case "xiegu":
|
||||
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
[
|
||||
{
|
||||
"version": "0.26.11",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Withdrawing deleted QSOs from Club Log is now paced and capped at 25 per deletion. Their delete endpoint is a real-time one, meant for an operator removing a contact they just mis-logged; Club Log watches the rate and blocks the IP of anything that batches through it. Past the cap OpsLog stops and says so — there is no bulk-delete API, and hundreds of removals belong on clublog.org, which has a tool for it.",
|
||||
"Auto-call, withdrawn earlier, is now disarmed where it was remembered: a stored 'enabled' is switched off and written back the first time OpsLog reads it. The running guard already stopped this build from calling anyone, but the stored flag survived — and any build without that guard would key the transmitter for a feature with no switch left to turn it off."
|
||||
],
|
||||
"fr": [
|
||||
"Le retrait des QSO supprimés chez Club Log est désormais cadencé et limité à 25 par suppression. Leur point d'entrée de suppression est temps réel, prévu pour un opérateur qui retire un contact qu'il vient de mal enregistrer ; Club Log surveille le rythme et bloque l'IP de ce qui passe des lots par là. Au-delà de la limite, OpsLog s'arrête et le dit — il n'existe pas d'API de suppression en masse, et des centaines de retraits se font sur clublog.org, qui a l'outil pour ça.",
|
||||
"L'appel automatique, retiré précédemment, est maintenant désarmé là où il était mémorisé : un « activé » enregistré est éteint et réécrit dès la première lecture par OpsLog. Le garde-fou à l'exécution empêchait déjà cette version d'appeler qui que ce soit, mais l'indicateur enregistré survivait — et toute version sans ce garde-fou passait à l'émission pour une fonction dont il ne reste aucun interrupteur."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.10",
|
||||
"date": "",
|
||||
"en": [
|
||||
"The HAMLOG.online confirmation import now fills the Results view with the contacts it confirmed, flagged new entity / band / mode / slot, and can export the unmatched ones as ADIF. Those are the interesting half: each is a contact their site holds and the log does not confirm — a minute of drift, a portable call, or a QSO genuinely missing.",
|
||||
"Awards: 'county' joins the searchable QSO fields — the CNTY field as it stands. The existing 'us_county' keys the county to its state, which is right for the United States and wrong everywhere else: an RDA district or a Japanese city code is already unique.",
|
||||
"*** FlexRadio: a split pile-up chaser. When a CW skimmer (SDC) marks a report on the panadapter, OpsLog moves the TRANSMIT slice there — where the DX was listening a second ago — plus a signed offset in Hz; the receive slice never moves. The button sits on the FlexRadio panel beside SPLIT, with the offset next to it. Right-click the button to set the marker text — it has to match what SDC is set to write (599 by default, several allowed), so it is edited where it is switched on rather than in a settings page.***",
|
||||
"The radio's spot feed is now subscribed to even when OpsLog draws no spots of its own — that feed is how another program's spots arrive. Clearing the panadapter at connect still only happens when the overlay is OpsLog's: 'spot clear' removes every spot on the radio, a skimmer's included.",
|
||||
"CW keyer: fixed Enter sending nothing on the Icom, Yaesu and Kenwood engines. Send-on-type only exists on the WinKeyer and Flex CWX, but the setting survived a change of engine — so the switch stayed on, invisibly, and the text field believed everything had already been keyed. Macros were unaffected, which is what made it puzzling."
|
||||
],
|
||||
"fr": [
|
||||
"L'import des confirmations HAMLOG.online alimente maintenant la vue Résultats avec les contacts confirmés, marqués nouvelle entité / bande / mode / slot, et peut exporter les non-rapprochés en ADIF. C'est la moitié intéressante : chacun est un contact que leur site détient et que le journal ne confirme pas — minute décalée, indicatif portable, ou QSO réellement absent.",
|
||||
"Diplômes : « county » rejoint les champs de QSO interrogeables — le champ CNTY tel quel. Le « us_county » existant associe le comté à son État, ce qui est juste aux États-Unis et faux ailleurs : un district RDA ou un code de ville japonais est déjà unique.",
|
||||
"*** FlexRadio : chasseur de pile-up en split. Quand un skimmer CW (SDC) marque un report sur le panadapter, OpsLog déplace la slice d'ÉMISSION dessus — là où le DX écoutait une seconde plus tôt — plus un décalage signé en Hz ; la slice de réception ne bouge jamais. Le bouton est sur le panneau FlexRadio à côté de SPLIT, avec le décalage juste à côté. Clic droit sur le bouton pour régler le texte du marqueur — il doit correspondre à ce que SDC est réglé à écrire (599 par défaut, plusieurs possibles), donc il se modifie là où on l'active plutôt que dans les réglages.***",
|
||||
"Le flux de spots de la radio est désormais suivi même si OpsLog n'affiche aucun spot : c'est par lui qu'arrivent les spots des autres programmes. Le nettoyage du panadapter à la connexion reste réservé au cas où l'affichage est celui d'OpsLog : « spot clear » efface tous les spots de la radio, y compris ceux d'un skimmer.",
|
||||
"Manipulateur CW : correction d'Entrée qui n'envoyait rien sur les moteurs Icom, Yaesu et Kenwood. L'émission au fil de la frappe n'existe que sur le WinKeyer et le Flex CWX, mais le réglage survivait à un changement de moteur — l'interrupteur restait donc actif, invisible, et le champ de texte croyait que tout avait déjà été émis. Les macros n'étaient pas touchées, ce qui rendait la chose incompréhensible."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Edit QSO: HAMLOG.online joins the QSL Info tab — its own channel in the picker, with sent/received and both dates, and a row in the status table. Its four columns also move from the QSL group to Uploads, next to Club Log and QRZ.com.",
|
||||
"The filter can now match on when a QSO was added to the log ('Added to the log on'), which is the only way to isolate what an import brought in — an import of old contacts carries old QSO dates and otherwise hides inside the log.",
|
||||
"QSL Manager: HAMLOG.online gains 'Import confirmations (ADIF)…'. Their site exports a log, this reads it back and stamps the confirmations on QSOs already present — it never inserts. Importing that same file through the ordinary ADIF import does insert, because it matches on the UTC minute and their export rarely agrees to the minute.",
|
||||
"Deleting QSOs now says how many rows were actually removed, and writes it to the log. A delete that removes nothing used to look exactly like one that worked.",
|
||||
"Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block."
|
||||
],
|
||||
"fr": [
|
||||
"Édition de QSO : HAMLOG.online rejoint l'onglet QSL Info — son propre canal dans la liste, avec envoyé/reçu et les deux dates, et une ligne dans le tableau de statut. Ses quatre colonnes passent aussi du groupe QSL au groupe Uploads, à côté de Club Log et QRZ.com.",
|
||||
"Le filtre peut maintenant porter sur la date d'ajout au journal (« Ajouté au journal le »), seul moyen d'isoler ce qu'un import a apporté : un import de vieux contacts porte de vieilles dates de QSO et se fond sinon dans le journal.",
|
||||
"Gestionnaire QSL : HAMLOG.online reçoit « Importer les confirmations (ADIF)… ». Leur site exporte un log, cette fonction le relit et appose les confirmations sur les QSO déjà présents — elle n'ajoute jamais rien. Le même fichier passé par l'import ADIF ordinaire, lui, ajoute : il compare à la minute UTC près et leur export s'accorde rarement à la minute.",
|
||||
"La suppression de QSO indique maintenant combien de lignes ont réellement été supprimées, et l'écrit dans le journal. Une suppression sans effet ressemblait exactement à une suppression réussie.",
|
||||
"La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.8",
|
||||
"date": "",
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
// Chasing a split pile-up by the report the DX just sent.
|
||||
//
|
||||
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||
// answers one station, sends "5NN", and moves on; the useful information is
|
||||
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||
// transmitting on, because the DX's receiver was there a second ago.
|
||||
//
|
||||
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||
// the radio's spot feed, and every spot another program posts arrives on
|
||||
// Flex.OnForeignSpot.
|
||||
//
|
||||
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||
//
|
||||
// Three deliberate choices:
|
||||
//
|
||||
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||
// any guess made here would be wrong for the operator who chose otherwise.
|
||||
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||
// a worse outcome than a missed call.
|
||||
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||
// last station was answered is exactly how a pile-up is chased.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
const keyFlexRSTChase = "flex.rst_chase"
|
||||
|
||||
// FlexRSTChase is the whole configuration.
|
||||
type FlexRSTChase struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||
// would drag in any station whose call happens to contain the digits.
|
||||
Markers string `json:"markers"`
|
||||
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||
// the last station worked is the usual tactic, downward happens too.
|
||||
OffsetHz int `json:"offset_hz"`
|
||||
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||
// slice" would take the operator off the DX they are listening to.
|
||||
SplitOnly bool `json:"split_only"`
|
||||
}
|
||||
|
||||
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||
|
||||
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||
// and a busy pile-up produces several a second; without a floor the transmit
|
||||
// slice would twitch continuously and never be anywhere long enough to call.
|
||||
const rstChaseMinGap = 700 * time.Millisecond
|
||||
|
||||
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||
// slice status back, several times a second, for no change at all.
|
||||
const rstChaseMinStep = 20 // Hz
|
||||
|
||||
var (
|
||||
rstChaseMu sync.Mutex
|
||||
rstChaseLast time.Time
|
||||
rstChaseFreq int64
|
||||
)
|
||||
|
||||
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||
s := defaultFlexRSTChase
|
||||
if a.settings == nil {
|
||||
return s
|
||||
}
|
||||
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
||||
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
||||
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
||||
// the feature did nothing at all with no sign of why.
|
||||
if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
|
||||
_ = json.Unmarshal([]byte(v), &s)
|
||||
}
|
||||
return normRSTChase(s)
|
||||
}
|
||||
|
||||
// SaveFlexRSTChase stores the configuration.
|
||||
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||
b, err := json.Marshal(normRSTChase(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyFlexRSTChase, string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||
//
|
||||
// Its own binding because this belongs on a button in the panel, not in a
|
||||
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||
// worked, which is a thing done mid-QSO with one hand.
|
||||
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||
s := a.GetFlexRSTChase()
|
||||
s.Enabled = on
|
||||
return a.SaveFlexRSTChase(s)
|
||||
}
|
||||
|
||||
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||
if strings.TrimSpace(s.Markers) == "" {
|
||||
s.Markers = defaultFlexRSTChase.Markers
|
||||
}
|
||||
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||
if s.OffsetHz > 10000 {
|
||||
s.OffsetHz = 10000
|
||||
}
|
||||
if s.OffsetHz < -10000 {
|
||||
s.OffsetHz = -10000
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||
cfg := a.GetFlexRSTChase()
|
||||
if !cfg.Enabled || a.cat == nil {
|
||||
return
|
||||
}
|
||||
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||
return // an ordinary spot: another station's callsign, not a report
|
||||
}
|
||||
// From here on every refusal is logged. A marker WAS recognised, so the
|
||||
// operator is entitled to know why the slice stayed where it was — silence
|
||||
// at this point is indistinguishable from a feature that does not work.
|
||||
if cfg.SplitOnly && !a.cat.State().Split {
|
||||
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
||||
strings.ToUpper(callsign), hzText(freqHz))
|
||||
return
|
||||
}
|
||||
target := freqHz + int64(cfg.OffsetHz)
|
||||
if target <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
rstChaseMu.Lock()
|
||||
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
rstChaseLast, rstChaseFreq = now, target
|
||||
rstChaseMu.Unlock()
|
||||
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SetTXSliceFrequency(target)
|
||||
}); err != nil {
|
||||
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||
return
|
||||
}
|
||||
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||
}
|
||||
|
||||
// hzText renders a frequency the way an operator reads one on a dial.
|
||||
func hzText(hz int64) string {
|
||||
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||
}
|
||||
@@ -4297,8 +4297,14 @@ export default function App() {
|
||||
if (deletingIds.length === 0) return;
|
||||
const ids = deletingIds;
|
||||
try {
|
||||
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||
else await DeleteQSOs(ids as any);
|
||||
// Report what was actually removed rather than assuming it matched what
|
||||
// was asked: a delete that silently removes nothing looks exactly like one
|
||||
// that worked, since the rows leave the grid when it reloads either way.
|
||||
if (ids.length === 1) { await DeleteQSO(ids[0]); showToast(t('toast.deletedOne')); }
|
||||
else {
|
||||
const n = await DeleteQSOs(ids as any);
|
||||
showToast(t('toast.deletedN', { n: Number(n ?? 0), asked: ids.length }));
|
||||
}
|
||||
setDeletingIds([]);
|
||||
setSelectedId(null);
|
||||
setSelectedIds([]);
|
||||
|
||||
@@ -33,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
// When the record entered the logbook — the way to isolate what an import added.
|
||||
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
@@ -304,6 +305,25 @@ function powerLevelLabel(pl?: string): string {
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
// Split-pile-up chaser (see flexrstchase.go). Held here so the button can
|
||||
// paint its state without polling the backend for it.
|
||||
const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>(
|
||||
{ enabled: false, markers: '599', offset_hz: 0, split_only: true });
|
||||
const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0');
|
||||
// The marker text is edited as raw text and only parsed on blur: it is a
|
||||
// comma-separated list, and normalising it on every keystroke would eat the
|
||||
// comma the moment it is typed.
|
||||
const [rstChaseMarkersText, setRstChaseMarkersText] = useState('599');
|
||||
// The marker field is summoned by right-clicking the button, not parked on
|
||||
// the row: it is set once to agree with SDC and then never touched, while the
|
||||
// row it was sitting in is the busiest in the panel.
|
||||
const [rstChaseEditing, setRstChaseEditing] = useState(false);
|
||||
useEffect(() => {
|
||||
GetFlexRSTChase().then((c: any) => {
|
||||
if (!c) return;
|
||||
setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); setRstChaseMarkersText(String(c.markers ?? '599'));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||
@@ -652,6 +672,59 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||
SPLIT
|
||||
</button>
|
||||
{/* The split chaser. On the panel and not in a settings page:
|
||||
it is switched on when a DXpedition appears and off when it is
|
||||
in the log, which is done mid-QSO with one hand. The offset
|
||||
sits beside it because it is the one number retuned while
|
||||
chasing — everything else about the feature is configured once. */}
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
|
||||
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
|
||||
onClick={() => { const on = !rstChase.enabled; setRstChase({ ...rstChase, enabled: on }); SetFlexRSTChaseEnabled(on).catch(() => {}); }}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
rstChase.enabled ? 'bg-success text-success-foreground border-success shadow-[0_0_12px] shadow-success/50' : 'bg-card text-success border-success hover:bg-success-muted')}>
|
||||
{rstChase.markers.split(',')[0].trim() || '599'}
|
||||
</button>
|
||||
{/* The marker is whatever SDC was told to write — "599", "5NN", or
|
||||
several. It has to agree with the skimmer or nothing ever fires,
|
||||
so it is edited here rather than in a settings page — but only
|
||||
when asked for, since it is set once and then left alone. */}
|
||||
{rstChaseEditing && (
|
||||
<input type="text" autoFocus value={rstChaseMarkersText}
|
||||
onChange={(e) => setRstChaseMarkersText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') { setRstChaseMarkersText(rstChase.markers); setRstChaseEditing(false); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
const v = rstChaseMarkersText.trim() || '599';
|
||||
setRstChaseMarkersText(v);
|
||||
setRstChaseEditing(false);
|
||||
if (v === rstChase.markers) return;
|
||||
const next = { ...rstChase, markers: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
title={t('flxp.rstChaseMarkerHint')}
|
||||
className="w-16 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono uppercase" />
|
||||
)}
|
||||
{rstChase.enabled && (
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground whitespace-nowrap ml-2">
|
||||
{t('flxp.rstChaseOffset')}
|
||||
<input type="number" step={10} value={rstChaseOffsetText}
|
||||
onChange={(e) => setRstChaseOffsetText(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Kept as text while typing: normalising every keystroke
|
||||
// makes a minus sign impossible to enter.
|
||||
const n = Math.round(Number(rstChaseOffsetText));
|
||||
const v = Number.isFinite(n) ? n : 0;
|
||||
setRstChaseOffsetText(String(v));
|
||||
const next = { ...rstChase, offset_hz: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono" />
|
||||
Hz
|
||||
</label>
|
||||
)}
|
||||
{st.split && !!st.tx_freq_hz && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -350,6 +350,34 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// HAMLOG.online has no download verb — their agent protocol only uploads —
|
||||
// so confirmations come back as an ADIF exported from their site. It is read
|
||||
// here rather than through the ordinary ADIF import because that one matches
|
||||
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
|
||||
// of contacts already in the log. This path only ever stamps QSOs it finds.
|
||||
async function importHamlogCfm() {
|
||||
const path = await OpenADIFFile();
|
||||
if (!path) return;
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await ImportHamlogConfirmations(path); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// The unmatched half of an import. Offered as a file because a few hundred
|
||||
// discrepancies are a list to work through, not something to read in a log
|
||||
// window.
|
||||
async function exportHamlogUnmatched() {
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const n = await ExportHamlogUnmatched(path);
|
||||
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||
setShowLog(true);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||
}
|
||||
}
|
||||
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
@@ -666,11 +694,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
{service !== 'pota' && service !== 'paper' && (
|
||||
<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">
|
||||
{service === 'hamlog' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||
title={t('qslm.hamlogImportTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Date window */}
|
||||
{service !== 'hamlog' && (<>
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
@@ -694,6 +736,7 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</>)}
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
|
||||
@@ -85,6 +85,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||
// editor rather than a special one. The received key is THEIR field name, the
|
||||
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||
const HAMLOG_CONF = 'HAMLOG';
|
||||
const HAMLOG_KEYS = {
|
||||
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -715,6 +728,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||
// The extras-backed channel reads and writes the same way, one
|
||||
// level down. Save merges the extras rather than replacing them,
|
||||
// so an emptied field keeps its stored value — which is why the
|
||||
// statuses are written as explicit Y/N and never removed.
|
||||
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||
const exPut = (k: string, v: any) => {
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
next[k] = String(v ?? '');
|
||||
set('extras', next as any);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
@@ -731,11 +754,24 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{confSel === OPSLOG_CONF ? (
|
||||
{confSel === HAMLOG_CONF ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
@@ -827,6 +863,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
||||
</tr>
|
||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
||||
<tr className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -213,10 +213,10 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||
// like the OpsLog card columns above. Hidden by default: a column per
|
||||
// service, shown to everyone, is how a grid becomes unreadable.
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { loadAutoCall, autoCallKey, type AutoCallSettings, type AutoCallCriteria } from '@/lib/autocall';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
@@ -1933,7 +1932,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// feed up or down — so the write has to go where those live.
|
||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||
// How a worked square is matched, and what counts as still wanted.
|
||||
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
|
||||
const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
|
||||
useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
|
||||
const saveGridScope = async (next: any) => {
|
||||
|
||||
@@ -73,9 +73,17 @@ export function WinkeyerPanel({
|
||||
|
||||
const connected = status.connected;
|
||||
|
||||
// Send-on-type only exists where the engine can key one character at a time:
|
||||
// the WinKeyer and Flex CWX. The stored preference survives an engine change,
|
||||
// though, and an Icom or Yaesu operator inherited a hidden switch that was on
|
||||
// — the checkbox is not shown for them, so nothing keyed as they typed AND
|
||||
// Enter sent nothing either, since it believes the text has already gone.
|
||||
// Macros kept working, which is exactly how it looked from the outside.
|
||||
const liveType = sendOnType && (source === 'winkeyer' || source === 'flex');
|
||||
|
||||
function sendText() {
|
||||
const t = cwText.trim();
|
||||
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||
if (t && !liveType) onSend(t); // in send-on-type the text already went out
|
||||
setCwText('');
|
||||
}
|
||||
|
||||
@@ -83,7 +91,7 @@ export function WinkeyerPanel({
|
||||
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||
function onCwChange(v: string) {
|
||||
if (sendOnType && connected) {
|
||||
if (liveType && connected) {
|
||||
const old = cwText;
|
||||
if (v.length > old.length && v.startsWith(old)) {
|
||||
onSendRaw(v.slice(old.length));
|
||||
@@ -200,13 +208,13 @@ export function WinkeyerPanel({
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
placeholder={liveType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
<Send className="size-3.5" /> {liveType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
|
||||
@@ -72,13 +72,26 @@ export function loadAutoCall(): AutoCallSettings {
|
||||
const raw = localStorage.getItem(AC_KEY);
|
||||
if (!raw) return { ...defaultAutoCall };
|
||||
const v = JSON.parse(raw);
|
||||
return {
|
||||
const out: AutoCallSettings = {
|
||||
...defaultAutoCall,
|
||||
...v,
|
||||
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
||||
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
||||
watch: Array.isArray(v?.watch) ? v.watch : [],
|
||||
};
|
||||
// DISARMED ON SIGHT, and written back disabled.
|
||||
//
|
||||
// The runtime guard in App.tsx stops this build from calling anyone, but it
|
||||
// leaves "enabled": true sitting in storage, where any build without the
|
||||
// guard — an older one an operator reinstalls, a machine that upgrades
|
||||
// later — reads it and keys the transmitter for a feature with no switch
|
||||
// left to turn off. A withdrawn feature that keys a radio has to be
|
||||
// disarmed where it is REMEMBERED, not only where it runs.
|
||||
if (out.enabled) {
|
||||
out.enabled = false;
|
||||
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
|
||||
}
|
||||
return out;
|
||||
} catch { return { ...defaultAutoCall }; }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.8';
|
||||
export const APP_VERSION = '0.26.11';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+10
@@ -233,6 +233,8 @@ export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise
|
||||
|
||||
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportHamlogUnmatched(arg1:string):Promise<number>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
@@ -469,6 +471,8 @@ export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexRSTChase():Promise<main.FlexRSTChase>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetFlexZoom():Promise<main.FlexZoomSettings>;
|
||||
@@ -693,6 +697,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
||||
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function ImportHamlogConfirmations(arg1:string):Promise<main.HamlogCfmResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
@@ -987,6 +993,8 @@ export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Prom
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveFlexRSTChase(arg1:main.FlexRSTChase):Promise<void>;
|
||||
|
||||
export function SaveFlexZoom(arg1:main.FlexZoomSettings):Promise<void>;
|
||||
|
||||
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
|
||||
@@ -1089,6 +1097,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||
|
||||
@@ -406,6 +406,10 @@ export function ExportCabrilloSelected(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportHamlogUnmatched(arg1) {
|
||||
return window['go']['main']['App']['ExportHamlogUnmatched'](arg1);
|
||||
}
|
||||
|
||||
export function FilterFields() {
|
||||
return window['go']['main']['App']['FilterFields']();
|
||||
}
|
||||
@@ -878,6 +882,10 @@ export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexRSTChase() {
|
||||
return window['go']['main']['App']['GetFlexRSTChase']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -1326,6 +1334,10 @@ export function ImportAwards() {
|
||||
return window['go']['main']['App']['ImportAwards']();
|
||||
}
|
||||
|
||||
export function ImportHamlogConfirmations(arg1) {
|
||||
return window['go']['main']['App']['ImportHamlogConfirmations'](arg1);
|
||||
}
|
||||
|
||||
export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
@@ -1914,6 +1926,10 @@ export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexRSTChase(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexRSTChase'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexZoom(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexZoom'](arg1);
|
||||
}
|
||||
@@ -2118,6 +2134,10 @@ export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
@@ -2537,6 +2537,24 @@ export namespace main {
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
export class FlexRSTChase {
|
||||
enabled: boolean;
|
||||
markers: string;
|
||||
offset_hz: number;
|
||||
split_only: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FlexRSTChase(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.markers = source["markers"];
|
||||
this.offset_hz = source["offset_hz"];
|
||||
this.split_only = source["split_only"];
|
||||
}
|
||||
}
|
||||
export class FlexZoomSettings {
|
||||
enabled: boolean;
|
||||
cw_khz: number;
|
||||
@@ -2699,6 +2717,28 @@ export namespace main {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class HamlogCfmResult {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
matched: number;
|
||||
by_class: number;
|
||||
unmatched: number;
|
||||
samples: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new HamlogCfmResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.total = source["total"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.matched = source["matched"];
|
||||
this.by_class = source["by_class"];
|
||||
this.unmatched = source["unmatched"];
|
||||
this.samples = source["samples"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
// Reading confirmations back from a HAMLOG.online ADIF export.
|
||||
//
|
||||
// Their agent protocol only uploads — it has no verb for asking what has been
|
||||
// confirmed — so the return path is a file: their site exports an ADIF, and the
|
||||
// operator feeds it back here.
|
||||
//
|
||||
// That file must NOT be imported the ordinary way. A plain import matches a
|
||||
// record to a local QSO on callsign + UTC MINUTE + band + mode, and their
|
||||
// export is rebuilt from their own database: a minute of rounding, SSB where
|
||||
// the log says USB, and the key no longer matches. "Update duplicates" then
|
||||
// does what it is told — no duplicate found, so it inserts — and a few hundred
|
||||
// copies of contacts the operator already had land in the log. That is exactly
|
||||
// what happened once, and it is why this path exists instead.
|
||||
//
|
||||
// So: match only, never insert. Confirmations are stamped onto the QSOs already
|
||||
// in the log, and anything that cannot be matched is REPORTED rather than
|
||||
// added, because an unmatched confirmation is a question about the log (a
|
||||
// minute off, a portable call) and not a contact to create.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// hamlogUnmatchedMax bounds what is kept for export. A whole log's worth of
|
||||
// unmatched records means the file belongs to another station, not that the
|
||||
// operator wants 50 000 of them written back out.
|
||||
const hamlogUnmatchedMax = 20000
|
||||
|
||||
// HamlogCfmResult is what the import did.
|
||||
type HamlogCfmResult struct {
|
||||
Total int `json:"total"` // records read from the file
|
||||
Confirmed int `json:"confirmed"` // records carrying HAMLOG's confirmation flag
|
||||
Matched int `json:"matched"` // local QSOs stamped
|
||||
ByClass int `json:"by_class"` // matched on mode CLASS rather than exact mode
|
||||
Unmatched int `json:"unmatched"` // confirmations with no local QSO
|
||||
// Samples names a few unmatched contacts, so "12 unmatched" can be looked
|
||||
// into rather than merely worried about. The full list is kept for export —
|
||||
// see ExportHamlogUnmatched — because 395 of them is not a sample-sized
|
||||
// problem: it is a list to work through.
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
|
||||
// hamlogCfmSamples caps the reported list — enough to see the pattern, not so
|
||||
// many that the dialog becomes a log file.
|
||||
const hamlogCfmSamples = 30
|
||||
|
||||
// ImportHamlogConfirmations stamps the confirmations from a HAMLOG.online ADIF
|
||||
// export onto the matching local QSOs. It inserts nothing.
|
||||
func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
var res HamlogCfmResult
|
||||
if a.qso == nil {
|
||||
return res, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return res, fmt.Errorf("empty path")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// The same two indexes the LoTW download uses: the exact key, then the
|
||||
// mode-CLASS key for the contacts whose mode was written differently at the
|
||||
// other end (FT8 exported as DATA, SSB where the log says USB).
|
||||
keyIDs, err := a.qso.DedupeKeyIDs(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read local log: %w", err)
|
||||
}
|
||||
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
|
||||
|
||||
emit := func(line string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||
}
|
||||
}
|
||||
emit("Reading " + path + "…")
|
||||
|
||||
// What already counts towards an award, so each confirmation can be flagged
|
||||
// NEW. LoTW and paper QSL are the two award-valid sources; a HAMLOG
|
||||
// confirmation is "new" when it lands on a slot neither of them holds — which
|
||||
// is the only sense in which it changes anything.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []qso.QSO
|
||||
|
||||
perr := adif.Parse(f, func(rec adif.Record) error {
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
res.Total++
|
||||
// Only the records they actually confirmed. The export carries the whole
|
||||
// log, and stamping "confirmed" on all of it would turn every uploaded
|
||||
// contact into a confirmed one.
|
||||
if !hamlogRecordConfirmed(rec) {
|
||||
return nil
|
||||
}
|
||||
res.Confirmed++
|
||||
|
||||
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
|
||||
id, found := keyIDs[qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)]
|
||||
if !found {
|
||||
// id 0 means the class key is ambiguous — several local QSOs share
|
||||
// it — and guessing between them would stamp the wrong one.
|
||||
if cid, ok := classIDs[qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)]; ok && cid != 0 {
|
||||
id, found = cid, true
|
||||
res.ByClass++
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
res.Unmatched++
|
||||
if len(res.Samples) < hamlogCfmSamples {
|
||||
res.Samples = append(res.Samples, fmt.Sprintf("%s · %s · %s · %s",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
|
||||
}
|
||||
if len(unmatched) < hamlogUnmatchedMax {
|
||||
unmatched = append(unmatched, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
date := hamlogCfmDate(rec)
|
||||
if e := a.qso.SetExtra(ctx, id, award.HamlogQSLKey, "Y"); e != nil {
|
||||
return nil
|
||||
}
|
||||
_ = a.qso.SetExtra(ctx, id, hamlogQSLDateKey, date)
|
||||
// A confirmed contact is by definition one they hold, so the sent side
|
||||
// is true whether or not OpsLog is what uploaded it — a log uploaded
|
||||
// from their website would otherwise read "never sent, yet confirmed".
|
||||
_ = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y")
|
||||
res.Matched++
|
||||
|
||||
// Feed the Results view, the same rows the LoTW download produces: an
|
||||
// import that only prints counts leaves the operator with no way to see
|
||||
// WHICH contacts were confirmed, which is the reason they ran it.
|
||||
a.enrichContactedFromCty(&q) // country/dxcc, for the entity flags
|
||||
it := ConfirmationItem{
|
||||
Callsign: q.Callsign,
|
||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||
Band: q.Band,
|
||||
Mode: q.Mode,
|
||||
Country: q.Country,
|
||||
}
|
||||
if q.DXCC != nil && *q.DXCC != 0 {
|
||||
n := *q.DXCC
|
||||
it.NewDXCC = !sets.DXCC[n]
|
||||
it.NewBand = !sets.Band[qso.BandKey(n, q.Band)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(n, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)]
|
||||
// Fold it in, so a repeat inside the same file isn't flagged twice.
|
||||
sets.DXCC[n] = true
|
||||
sets.Band[qso.BandKey(n, q.Band)] = true
|
||||
sets.Mode[qso.ModeClassKey(n, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
if perr != nil {
|
||||
return res, perr
|
||||
}
|
||||
a.invalidateAwardStats() // confirmations move award counts
|
||||
// Kept for ExportHamlogUnmatched. Held rather than written now: the operator
|
||||
// decides whether a list of 395 is worth a file.
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
a.hamlogUnmatched = unmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
applog.Printf("hamlog cfm import: %d records, %d confirmed, %d matched (%d by mode class), %d unmatched",
|
||||
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched)
|
||||
emit(fmt.Sprintf("%d records read, %d confirmed by HAMLOG.online, %d matched in the log (%d by mode class), %d unmatched",
|
||||
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched))
|
||||
for _, s := range res.Samples {
|
||||
emit(" unmatched: " + s)
|
||||
}
|
||||
if res.Unmatched > len(res.Samples) {
|
||||
emit(fmt.Sprintf(" …and %d more — use \"Export unmatched (ADIF)…\" to get the whole list.",
|
||||
res.Unmatched-len(res.Samples)))
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ExportHamlogUnmatched writes the confirmations the last import could not
|
||||
// place onto a QSO.
|
||||
//
|
||||
// They are the interesting half of the result: each one is a contact HAMLOG
|
||||
// believes it holds and this log does not agree about — a minute of drift, a
|
||||
// portable call, a band written differently, or a QSO genuinely missing. A
|
||||
// count cannot be worked through; a file can be opened, sorted and compared.
|
||||
//
|
||||
// Written as ADIF because that is what every other tool reads, and because it
|
||||
// can be handed straight back to an import once the discrepancies are settled.
|
||||
func (a *App) ExportHamlogUnmatched(path string) (int, error) {
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
rows := a.hamlogUnmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if len(rows) == 0 {
|
||||
return 0, fmt.Errorf("nothing to export: the last import left no unmatched confirmations")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return 0, fmt.Errorf("empty path")
|
||||
}
|
||||
recs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
recs = append(recs, adif.FullRecordADIF(rows[i]))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(adif.BatchRecordsADIF(recs)), 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applog.Printf("hamlog cfm import: exported %d unmatched confirmation(s) to %s", len(rows), path)
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// hamlogQSLDateKey stamps WHEN the confirmation was read back. Their export
|
||||
// carries no confirmation date of its own, so this is the import date — which
|
||||
// is honest about what it knows, unlike borrowing the QSO date.
|
||||
const hamlogQSLDateKey = "APP_OPSLOG_HAMLOG_QSL_DATE"
|
||||
|
||||
// hamlogRecordConfirmed reads their flag off a raw ADIF record. The primary key
|
||||
// is the one their own export writes; the alternates are the names OpsLog and
|
||||
// other tools have used, so a file that went through another logger still reads.
|
||||
func hamlogRecordConfirmed(rec adif.Record) bool {
|
||||
keys := append([]string{award.HamlogQSLKey}, award.HamlogAltKeys()...)
|
||||
for _, k := range keys {
|
||||
v := strings.ToUpper(strings.TrimSpace(rec[strings.ToLower(k)]))
|
||||
if v != "" && v != "N" && v != "NO" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hamlogCfmDate is the date to stamp: the import date, in ADIF form.
|
||||
func hamlogCfmDate(rec adif.Record) string {
|
||||
// If a future export ever carries one, take it rather than today's date.
|
||||
for _, k := range []string{"app_hamlog_qso_cfm_date", "qslrdate"} {
|
||||
if v := strings.TrimSpace(rec[k]); len(v) == 8 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().Format("20060102")
|
||||
}
|
||||
+12
-1
@@ -344,7 +344,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
||||
func Fields() []string {
|
||||
return []string{
|
||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||
"state", "county", "us_county", "cont", "country", "grid", "grid4",
|
||||
"iota", "sota_ref", "pota_ref", "wwff",
|
||||
"name", "qth", "address", "comment", "note",
|
||||
}
|
||||
@@ -1373,6 +1373,13 @@ func fieldRaw(field string, q *qso.QSO) string {
|
||||
return q.Callsign
|
||||
case "state":
|
||||
return q.State
|
||||
case "county":
|
||||
// CNTY as it stands, with nothing prepended. us_county below answers a
|
||||
// different question — it keys the county to its state, because two US
|
||||
// states each have a Jefferson County. Outside the United States that
|
||||
// prefixing is wrong: an RDA district (RO-19) and a Japanese city code
|
||||
// are already unique, and a state is not what qualifies them.
|
||||
return q.County
|
||||
case "us_county":
|
||||
return USCountyKey(q.State, q.County)
|
||||
case "cont":
|
||||
@@ -1632,3 +1639,7 @@ func sortedBands(m map[string]int) []string {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HamlogAltKeys exposes the alternate spellings so a reader outside this package
|
||||
// (the confirmations import) accepts exactly what the award engine accepts.
|
||||
func HamlogAltKeys() []string { return append([]string(nil), hamlogAltKeys...) }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// A district lives in CNTY as-is; the US award field keys it to the state
|
||||
// because county names repeat across states. Confusing the two silently breaks
|
||||
// whichever award is not American.
|
||||
func TestCountyFieldIsRawWhileUSCountyIsKeyed(t *testing.T) {
|
||||
q := &qso.QSO{State: "TX", County: "RO-19"}
|
||||
if got := fieldRaw("county", q); got != "RO-19" {
|
||||
t.Fatalf("county = %q, want RO-19", got)
|
||||
}
|
||||
if got := fieldRaw("us_county", q); got == "RO-19" {
|
||||
t.Fatalf("us_county should key the county to its state, got %q", got)
|
||||
}
|
||||
var found bool
|
||||
for _, f := range Fields() {
|
||||
if f == "county" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("county missing from the award field list")
|
||||
}
|
||||
}
|
||||
@@ -487,6 +487,8 @@ type FlexController interface {
|
||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||
ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error
|
||||
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||
// SetTXSliceFrequency moves the TRANSMIT slice only — split pile-up chasing.
|
||||
SetTXSliceFrequency(int64) error
|
||||
SetSplit(bool) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
|
||||
+127
-4
@@ -60,6 +60,9 @@ type Flex struct {
|
||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
// foreignSpotSeen counts what probeForeignSpot has already reported, so a
|
||||
// skimmer posting all evening cannot turn the log into its own transcript.
|
||||
foreignSpotSeen int
|
||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
@@ -92,6 +95,16 @@ type Flex struct {
|
||||
// the spot, since the radio's own notification carries only an index. The host
|
||||
// wires this to fill the entry form and to size the panadapter. Set before Connect.
|
||||
OnSpotClick func(callsign string, freqHz int64, mode string)
|
||||
|
||||
// OnForeignSpot is called for every spot posted to the radio by a program
|
||||
// OTHER than OpsLog, with the spot's callsign field and its frequency.
|
||||
//
|
||||
// A CW skimmer posts what it decodes, so the marker a DX operator's report
|
||||
// leaves on the panadapter arrives here — that is what the split chaser acts
|
||||
// on. Deliberately raw: this package reports what the radio said and does not
|
||||
// decide what a marker looks like, because the marker text is configured in
|
||||
// the skimmer, by the operator, and only they know what they chose.
|
||||
OnForeignSpot func(callsign string, freqHz int64)
|
||||
}
|
||||
|
||||
// panView is one panadapter's visible window, in MHz.
|
||||
@@ -278,11 +291,18 @@ func (f *Flex) Connect() error {
|
||||
f.send("sub pan all") // panadapter centre/bandwidth, so a zoom knows where the display already is
|
||||
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
|
||||
// Always subscribed, even when OpsLog draws no spots of its own: the feed is
|
||||
// read-only and it is how the spots posted by OTHER programs arrive — a CW
|
||||
// skimmer's decoded reports, which the split chaser acts on. Tying the
|
||||
// subscription to our own overlay meant a station using SDC and no OpsLog
|
||||
// spots heard nothing at all.
|
||||
f.send("sub spot all")
|
||||
if f.spotsEnabled {
|
||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||
// then wipe the panadapter so stale spots from a previous session or
|
||||
// another logger are cleared before we start adding our own.
|
||||
f.send("sub spot all")
|
||||
// Wipe the panadapter so stale spots from a previous session or another
|
||||
// logger are cleared before we start adding our own. Only when the
|
||||
// overlay is ours to manage: "spot clear" removes EVERY spot on the
|
||||
// radio, a skimmer's included, and taking those away from an operator
|
||||
// who never asked us to draw anything would be pure vandalism.
|
||||
go f.clearSpotsOnConnect(conn)
|
||||
}
|
||||
return nil
|
||||
@@ -883,6 +903,10 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !removed {
|
||||
f.probeForeignSpot(payload)
|
||||
f.reportForeignSpot(payload)
|
||||
}
|
||||
}
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
}
|
||||
@@ -1377,6 +1401,105 @@ func (f *Flex) ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignSpotProbeMax bounds the probe below. Enough lines to see what another
|
||||
// program writes into a spot, few enough that a running skimmer — which posts
|
||||
// hundreds an hour — cannot fill the log file with them.
|
||||
const foreignSpotProbeMax = 60
|
||||
|
||||
// probeForeignSpot records, verbatim, the spots this radio receives from
|
||||
// programs OTHER than OpsLog.
|
||||
//
|
||||
// A CW skimmer (SDC, for one) posts what it decodes as panadapter spots, the
|
||||
// exchange included: chasing a split pileup means finding the "5NN" the DX just
|
||||
// sent and moving the transmit slice there. Acting on that requires knowing
|
||||
// exactly what the spot looks like — which field carries the text, whether the
|
||||
// report is the callsign or the comment, what source names the skimmer — and no
|
||||
// amount of reasoning substitutes for reading real lines off a real radio.
|
||||
//
|
||||
// So this logs and does nothing else. It is deliberately not a feature.
|
||||
func (f *Flex) probeForeignSpot(payload string) {
|
||||
if strings.Contains(payload, "source=OpsLog") {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.foreignSpotSeen >= foreignSpotProbeMax {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.foreignSpotSeen++
|
||||
n := f.foreignSpotSeen
|
||||
f.mu.Unlock()
|
||||
debugLog.Printf("flex: foreign spot %d/%d: %s", n, foreignSpotProbeMax, payload)
|
||||
if n == foreignSpotProbeMax {
|
||||
debugLog.Printf("flex: that is enough foreign spots to go on — no more will be logged this session")
|
||||
}
|
||||
}
|
||||
|
||||
// reportForeignSpot hands another program's spot to OnForeignSpot.
|
||||
//
|
||||
// Off the reader goroutine, like OnSpotClick: the handler tunes the radio, and
|
||||
// a command sent from inside the reader would deadlock against the socket it is
|
||||
// reading.
|
||||
func (f *Flex) reportForeignSpot(payload string) {
|
||||
if strings.Contains(payload, "source=OpsLog") {
|
||||
return
|
||||
}
|
||||
handler := f.OnForeignSpot
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
var call string
|
||||
var hz int64
|
||||
for _, kv := range strings.Fields(payload) {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
switch kv[:eq] {
|
||||
case "callsign":
|
||||
call = kv[eq+1:]
|
||||
case "rx_freq":
|
||||
if mhz, err := strconv.ParseFloat(kv[eq+1:], 64); err == nil {
|
||||
hz = int64(math.Round(mhz * 1e6))
|
||||
}
|
||||
}
|
||||
}
|
||||
if call == "" || hz <= 0 {
|
||||
return
|
||||
}
|
||||
go handler(call, hz)
|
||||
}
|
||||
|
||||
// SetTXSliceFrequency tunes the TRANSMIT slice, leaving the receive slice where
|
||||
// it is. That distinction is the whole point in split: the operator listens to
|
||||
// the DX on one slice and moves the other around the pile-up.
|
||||
func (f *Flex) SetTXSliceFrequency(hz int64) error {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("flex: invalid frequency")
|
||||
}
|
||||
f.mu.Lock()
|
||||
idx := -1
|
||||
for i, s := range f.slices {
|
||||
if s != nil && s.inUse && s.tx {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 && f.slices[idx] != nil {
|
||||
f.slices[idx].freqHz = hz // optimistic, like SetFrequency
|
||||
}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("flex: no transmit slice")
|
||||
}
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
|
||||
// a lifetime so the radio expires them on its own (the API has no "spot clear").
|
||||
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
|
||||
|
||||
+6
-1
@@ -1291,11 +1291,16 @@ var filterableColumns = map[string]bool{
|
||||
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
|
||||
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
|
||||
"tx_pwr": true, "comment": true, "notes": true,
|
||||
// When the record entered THIS logbook, as opposed to when the contact was
|
||||
// made. The question it answers is "what did that import actually add?" —
|
||||
// unanswerable otherwise, since an import of old contacts carries old QSO
|
||||
// dates and hides inside the log.
|
||||
"created_at": true,
|
||||
}
|
||||
|
||||
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
|
||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true, "created_at": true}
|
||||
|
||||
// numericColumns are the filterable columns holding numbers rather than text.
|
||||
//
|
||||
|
||||
+5
-5
@@ -315,11 +315,11 @@ func (a *App) syncPublishDeletes(ids []int64) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// One read for the whole set: see deleteSnapshot. Tombstones are written
|
||||
// before the rows go, because a QSO that never had a sync identity has to be
|
||||
// given one here — after the DELETE there would be nothing left to stamp.
|
||||
for _, q := range a.deleteSnapshot(ids) {
|
||||
id := q.ID
|
||||
// A contact never touched since the sync was switched on has no identity,
|
||||
// and giving it one now is what makes the deletion addressable at all.
|
||||
a.syncMu.Lock()
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.26.8"
|
||||
appVersion = "0.26.11"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user