20 Commits
Author SHA1 Message Date
rouggy 2712902057 chore: release v0.15 2026-06-29 23:27:11 +02:00
rouggy 9281645359 feat: better looking buttons in flexradio panel 2026-06-29 23:21:54 +02:00
rouggy a05dd6b3a9 feat: Added Antenna selection to Flex FlexPanel
feat: New settings flexradio to select antennas per band
2026-06-29 23:07:52 +02:00
rouggy a2401d7fd3 fix: when in autocall cw writing a call stop transmission 2026-06-28 20:55:59 +02:00
rouggy 053b351dab feat: adding choice for recent qso in main tab 2026-06-28 20:49:14 +02:00
rouggy 64f2d38d87 chore: release v0.14.1 2026-06-28 20:39:15 +02:00
rouggy 299184712a fix: beam heading full color 2026-06-28 20:38:59 +02:00
rouggy 76c1e2df60 fix: forcing 3 sec for each notification instead of one replacing the other 2026-06-28 19:12:55 +02:00
rouggy 165f33caa5 fix: recording not being sent if mail enable was not checked 2026-06-28 19:08:32 +02:00
rouggy 464a1c702c feat: added mute and AGC-T to Flex control 2026-06-27 21:03:13 +02:00
rouggy 19c5045dc6 fix: autocall in remote not working properly 2026-06-27 20:28:29 +02:00
rouggy fa09251039 chore: release v0.14 2026-06-25 01:09:37 +02:00
rouggy d6626d96d0 fix: added functionality to net control 2026-06-24 19:09:34 +02:00
rouggy 8b831145ad feat: Added Net control 2026-06-22 23:40:25 +02:00
rouggy 81c60628c6 feat: While recording a QSO the count is clickable to start the recording again 2026-06-22 21:55:21 +02:00
rouggy 678787ec62 fix: app crashed sometimes while converting qso in MP3 2026-06-22 21:51:29 +02:00
rouggy 79dc20a859 fix: Showing beam heading on map even if no call is entered 2026-06-22 21:46:41 +02:00
rouggy 824971d0a1 fix: bug on the antenna genius 2026-06-22 19:48:29 +02:00
rouggy 60bcd2422d fix: small bug for PGXL fan and ultrabeam direction 2026-06-22 17:29:29 +02:00
rouggy 9b0d7ce1dc feat: While importing ADIF, can import default QSL confirmation
as well as My working conditions, locator, owner callsign
2026-06-22 13:03:57 +02:00
28 changed files with 2154 additions and 330 deletions
+483 -12
View File
@@ -18,6 +18,7 @@ import (
"time" "time"
"hamlog/internal/adif" "hamlog/internal/adif"
"hamlog/internal/antgenius"
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/audio" "hamlog/internal/audio"
"hamlog/internal/award" "hamlog/internal/award"
@@ -25,23 +26,23 @@ import (
"hamlog/internal/backup" "hamlog/internal/backup"
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/clublog" "hamlog/internal/clublog"
"hamlog/internal/cwdecode"
"hamlog/internal/cluster" "hamlog/internal/cluster"
"hamlog/internal/cwdecode"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
"hamlog/internal/email" "hamlog/internal/email"
"hamlog/internal/extsvc" "hamlog/internal/extsvc"
"hamlog/internal/integrations/udp" "hamlog/internal/integrations/udp"
"hamlog/internal/lookup" "hamlog/internal/lookup"
"hamlog/internal/netctl"
"hamlog/internal/operating" "hamlog/internal/operating"
"hamlog/internal/pota" "hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/profile" "hamlog/internal/profile"
"hamlog/internal/qslcard" "hamlog/internal/qslcard"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/antgenius"
"hamlog/internal/powergenius"
"hamlog/internal/ultrabeam" "hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer" "hamlog/internal/winkeyer"
@@ -399,6 +400,15 @@ type App struct {
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
netStore *netctl.Store
netMu sync.Mutex
netOpenID string // id of the currently open net ("" = none)
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
cwMu sync.Mutex // guards the CW decoder lifecycle cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -827,6 +837,13 @@ func (a *App) startup(ctx context.Context) {
a.qsoRec = audio.NewRecorder() a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// NET Control store (global JSON, shared across logbooks).
if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil {
applog.Printf("netctl: open failed: %v", err)
} else {
a.netStore = ns
}
// Ultrabeam antenna: connect in the background if enabled. // Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam() a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled. // Antenna Genius switch: connect in the background if enabled.
@@ -1055,7 +1072,6 @@ func userDataDir() (string, error) {
return filepath.Join(filepath.Dir(exe), "data"), nil return filepath.Join(filepath.Dir(exe), "data"), nil
} }
// fileExists reports whether path exists and is a regular file. // fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool { func fileExists(path string) bool {
fi, err := os.Stat(path) fi, err := os.Stat(path)
@@ -1703,6 +1719,20 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
if q.MyPOTARef == "" { if q.MyPOTARef == "" {
q.MyPOTARef = p.MyPOTARef q.MyPOTARef = p.MyPOTARef
} }
// Per-band rig/antenna from Operating conditions (the antenna ticked as
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
if q.MyRig == "" {
q.MyRig = d.StationName
}
if q.MyAntenna == "" {
q.MyAntenna = d.AntennaName
}
}
}
// Fall back to the profile's static rig/antenna for anything still empty.
if q.MyRig == "" { if q.MyRig == "" {
q.MyRig = p.MyRig q.MyRig = p.MyRig
} }
@@ -3326,6 +3356,8 @@ type QSLBulkUpdate struct {
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged Via string `json:"via"` // QSL_VIA — "" = unchanged
Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged
} }
// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the // BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
@@ -3358,6 +3390,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.Via); v != "" { if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true q.QSLVia, changed = v, true
} }
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
}
if v := strings.TrimSpace(u.Comment); v != "" {
q.Comment, changed = v, true
}
if changed { if changed {
if a.qso.Update(a.ctx, q) == nil { if a.qso.Update(a.ctx, q) == nil {
n++ n++
@@ -3569,6 +3607,9 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
// Backfill empty MY_* descriptive fields from the active profile // Backfill empty MY_* descriptive fields from the active profile
// (identity fields left alone to keep mixed-call routing intact). // (identity fields left alone to keep mixed-call routing intact).
a.applyStationDefaults(q, false) a.applyStationDefaults(q, false)
// Also stamp the default QSL/LoTW/eQSL confirmation statuses on
// any that are still empty (same defaults new QSOs get).
a.applyQSLDefaults(q)
} }
} }
} }
@@ -4174,13 +4215,33 @@ func (a *App) saveQSORecording(q *qso.QSO) {
return return
} }
applog.Printf("qso-rec: saved %s", path) applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists. // Auto-send the recording once the file exists. Gated ONLY on its own
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" { // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
_ = a.sendRecordingEmail(qc, path) // master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
// Enabled silently skipped sending with no log when only this box was on.)
// Give the operator visible feedback either way.
if es, _ := a.GetEmailSettings(); es.AutoSend {
if strings.TrimSpace(qc.Email) == "" {
applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
} else if err := a.sendRecordingEmail(qc, path); err != nil {
a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
} else {
a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
a.markRecordingSent(qc.ID)
}
} }
}() }()
} }
// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", msg)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.). // sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string { func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ToUpper(strings.TrimSpace(s))
@@ -4220,6 +4281,18 @@ func (a *App) QSOAudioRestart() bool {
return a.qsoRec.Active() return a.qsoRec.Active()
} }
// QSOAudioResetClock restarts the in-progress recording from zero, dropping
// everything captured so far (pre-roll included). Lets the operator click the
// REC timer to record only their own exchange when the station was already in a
// long QSO before they entered the call. Returns whether a recording is active.
func (a *App) QSOAudioResetClock() bool {
if a.qsoRec == nil {
return false
}
a.qsoRec.ResetQSOClock()
return a.qsoRec.Active()
}
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging). // abandoned without logging).
func (a *App) QSOAudioCancel() { func (a *App) QSOAudioCancel() {
@@ -4231,6 +4304,284 @@ func (a *App) QSOAudioCancel() {
// RestartQSORecorder applies new audio settings to the running recorder. // RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() } func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// ── NET Control ────────────────────────────────────────────────────────────
//
// A NET is a named net with a station roster (persisted globally in nets.json).
// Opening a net starts an in-memory live session: stations moved "on the air"
// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs
// the QSO into the active logbook with live CAT freq/mode and removes it from
// the session. The session is RAM-only — closing the app mid-net drops any
// active stations that were never logged.
// NetList returns all nets (with rosters), ordered by name.
func (a *App) NetList() []netctl.Net {
if a.netStore == nil {
return nil
}
return a.netStore.List()
}
// NetCreate adds a new named net (defaults 59/59).
func (a *App) NetCreate(name string) (netctl.Net, error) {
if a.netStore == nil {
return netctl.Net{}, fmt.Errorf("net store unavailable")
}
return a.netStore.Create(name)
}
// NetRename renames a net.
func (a *App) NetRename(id, name string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.Rename(id, name)
}
// NetSetDefaults updates a net's default report/comment values.
func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment)
}
// NetDelete removes a net and its roster (closing it first if it's open).
func (a *App) NetDelete(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
a.netMu.Lock()
if a.netOpenID == id {
a.netOpenID, a.netActive = "", nil
}
a.netMu.Unlock()
return a.netStore.Delete(id)
}
// NetRoster returns a net's roster, sorted by callsign.
func (a *App) NetRoster(id string) ([]netctl.Station, error) {
if a.netStore == nil {
return nil, fmt.Errorf("net store unavailable")
}
return a.netStore.Roster(id)
}
// NetRosterUpsert adds or updates a roster station.
func (a *App) NetRosterUpsert(id string, s netctl.Station) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterUpsert(id, s)
}
// NetRosterRemove deletes a callsign from a net's roster.
func (a *App) NetRosterRemove(id, callsign string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterRemove(id, callsign)
}
// NetLookup resolves a callsign via the configured provider (QRZ) so the
// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns
// just the callsign if no provider or no match.
func (a *App) NetLookup(callsign string) netctl.Station {
st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))}
if a.lookup == nil || st.Callsign == "" {
return st
}
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
}
return st
}
// NetOpen starts a live session for the given net (clears any prior session).
func (a *App) NetOpen(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
if _, ok := a.netStore.Get(id); !ok {
return fmt.Errorf("net not found")
}
a.netMu.Lock()
a.netOpenID, a.netActive = id, nil
a.netMu.Unlock()
return nil
}
// NetClose ends the live session (does NOT log remaining actives — the operator
// moves each station back to the roster side to log it before closing).
func (a *App) NetClose() {
a.netMu.Lock()
a.netOpenID, a.netActive = "", nil
a.netMu.Unlock()
}
// NetOpenID returns the id of the currently open net ("" = none).
func (a *App) NetOpenID() string {
a.netMu.Lock()
defer a.netMu.Unlock()
return a.netOpenID
}
// NetActiveList returns the stations currently on the air, in check-in order.
// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient
// id so the same QSOEditModal as Recent QSOs can edit every field.
func (a *App) NetActiveList() []qso.QSO {
a.netMu.Lock()
defer a.netMu.Unlock()
out := make([]qso.QSO, len(a.netActive))
for i, e := range a.netActive {
out[i] = *e
}
return out
}
// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
// UI-reported values when CAT is off.
func (a *App) netLiveFreq() (freq int64, band, mode string) {
var st cat.RigState
if a.cat != nil {
st = a.cat.State()
}
freq, band, mode = st.FreqHz, st.Band, st.Mode
if freq == 0 {
a.liveActMu.Lock()
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
a.liveActMu.Unlock()
}
if band == "" && freq > 0 {
band = bandForHz(freq)
}
return
}
// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
// live freq/mode, defaults + roster info) with a transient negative id and
// returns it. No-op (returns the existing draft) if already active.
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return qso.QSO{}, fmt.Errorf("callsign required")
}
a.netMu.Lock()
defer a.netMu.Unlock()
if a.netOpenID == "" {
return qso.QSO{}, fmt.Errorf("no net open")
}
for _, e := range a.netActive {
if strings.EqualFold(e.Callsign, call) {
return *e, nil // already on the air
}
}
a.netSeq--
q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()}
if net, ok := a.netStore.Get(a.netOpenID); ok {
q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
if st.DXCC != 0 {
d := st.DXCC
q.DXCC = &d
}
if st.CQ != 0 {
c := st.CQ
q.CQZ = &c
}
if st.ITU != 0 {
i := st.ITU
q.ITUZ = &i
}
break
}
}
}
if q.RSTSent == "" {
q.RSTSent = "59"
}
if q.RSTRcvd == "" {
q.RSTRcvd = "59"
}
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
a.applyDXCCNumber(q) // fill country/dxcc/zones for display
a.refineDistrictZones(q)
a.netActive = append(a.netActive, q)
return *q, nil
}
// NetUpdateActive replaces an on-air QSO draft (matched by its transient id)
// with the edited version from the QSOEditModal. Lets the operator change every
// field of a station before it's logged.
func (a *App) NetUpdateActive(q qso.QSO) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, cur := range a.netActive {
if cur.ID == q.ID {
qq := q
a.netActive[i] = &qq
return nil
}
}
return fmt.Errorf("station not active")
}
// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it
// — i.e. cancel a station added by mistake (the modal's Delete button).
func (a *App) NetDiscardActive(id int64) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, e := range a.netActive {
if e.ID == id {
a.netActive = append(a.netActive[:i], a.netActive[i+1:]...)
return nil
}
}
return nil
}
// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the
// active logbook (time_off = now; freq/mode refreshed from the rig only if the
// draft still has none, so manual edits are respected) and removes it from the
// session. Returns the new QSO id.
func (a *App) NetDeactivate(id int64) (int64, error) {
a.netMu.Lock()
var draft *qso.QSO
idx := -1
for i, e := range a.netActive {
if e.ID == id {
draft, idx = e, i
break
}
}
if draft == nil {
a.netMu.Unlock()
return 0, fmt.Errorf("station not active")
}
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
a.netMu.Unlock()
q := *draft
q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row)
q.QSODateOff = time.Now().UTC()
if q.FreqHz == nil && q.Band == "" {
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
}
return a.AddQSO(q)
}
// ── E-mail / SMTP (send QSO recordings) ─────────────────────────────── // ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
const ( const (
@@ -4364,6 +4715,28 @@ func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
return r.Replace(tmpl) return r.Replace(tmpl)
} }
// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
}
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator. // sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings() s, _ := a.GetEmailSettings()
@@ -4409,7 +4782,11 @@ func (a *App) SendQSORecordingEmail(id int64) error {
if _, e := os.Stat(path); e != nil { if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name) return fmt.Errorf("recording file missing: %s", name)
} }
return a.sendRecordingEmail(q, path) if err := a.sendRecordingEmail(q, path); err != nil {
return err
}
a.markRecordingSent(id)
return nil
} }
// ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
@@ -5303,7 +5680,9 @@ func uploadColumnFor(service string) string {
// FindQSOsForUpload returns QSOs whose sent status for the given service // FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required. // matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { // FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
@@ -5311,7 +5690,7 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, er
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
} }
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
} }
// UploadQSOsManual uploads the given QSO ids to a service on demand // UploadQSOsManual uploads the given QSO ids to a service on demand
@@ -7040,6 +7419,90 @@ func (a *App) FlexSetAudioLevel(l int) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
} }
func (a *App) FlexSetMute(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
}
func (a *App) FlexSetRXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
}
func (a *App) FlexSetTXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"
// FlexBandAnt is the antenna pair to select on a given band.
type FlexBandAnt struct {
RX string `json:"rx"`
TX string `json:"tx"`
}
// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
// e.g. "20M").
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
out := map[string]FlexBandAnt{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandAntennas persists the band→antenna map.
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
}
// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
// changes and spot clicks so the right antennas follow the frequency.
func (a *App) FlexApplyBandAntenna(band string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
if band == "" {
return nil
}
m, _ := a.GetFlexBandAntennas()
ant, ok := m[band]
if !ok {
return nil
}
return a.cat.FlexDo(func(fc cat.FlexController) error {
if strings.TrimSpace(ant.RX) != "" {
_ = fc.SetRXAntenna(ant.RX)
}
if strings.TrimSpace(ant.TX) != "" {
_ = fc.SetTXAntenna(ant.TX)
}
return nil
})
}
func (a *App) FlexSetNB(on bool) error { func (a *App) FlexSetNB(on bool) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -8480,7 +8943,8 @@ type SpotQuery struct {
// //
// "new" — entity never worked // "new" — entity never worked
// "new-band" — entity worked but never on this band // "new-band" — entity worked but never on this band
// "new-slot" — entity worked on this band but not in this mode // "new-mode" — band worked, but this MODE never worked on the entity (any band)
// "new-slot" — band & mode each worked before, but not this band+mode together
// "worked" — exact band+mode already in the log // "worked" — exact band+mode already in the log
// "" — couldn't resolve the entity (no cty.dat match) // "" — couldn't resolve the entity (no cty.dat match)
type SpotStatus struct { type SpotStatus struct {
@@ -8569,12 +9033,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out[i].Status = "new-band" out[i].Status = "new-band"
continue continue
} }
// Without a mode we can't distinguish "new slot" from "worked"; // Without a mode we can't distinguish the rest from "worked";
// the safer default is "worked" so we never falsely claim "new". // the safer default is "worked" so we never falsely claim "new".
if out[i].Mode == "" { if out[i].Mode == "" {
out[i].Status = "worked" out[i].Status = "worked"
continue continue
} }
// Band already worked. If this MODE was never worked on the entity (any
// band) → new-mode. If the mode was worked elsewhere but not on THIS
// band+mode → new-slot. Otherwise → worked.
if _, m := e.Modes[out[i].Mode]; !m {
out[i].Status = "new-mode"
continue
}
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
out[i].Status = "new-slot" out[i].Status = "new-slot"
continue continue
+194 -39
View File
@@ -13,7 +13,7 @@ import {
GetStartupStatus, CheckForUpdate, GetStartupStatus, CheckForUpdate,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
@@ -32,7 +32,7 @@ import {
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetUIPref,
ReportLiveActivity, ReportLiveActivity,
@@ -66,6 +66,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { BulkEditModal } from '@/components/BulkEditModal'; import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
@@ -116,7 +117,7 @@ const emptyDetails: DetailsState = {
prop_mode: '', my_rig: '', my_antenna: '', prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined, tx_pwr: undefined,
sat_name: '', sat_mode: '', sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx_string: '', stx_string: '',
email: '', email: '',
award_refs: '', award_refs: '',
}; };
@@ -205,6 +206,24 @@ function rstCategory(mode: string): keyof RSTLists {
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone'; return 'phone';
} }
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's // rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load. // editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] { function rstOptions(mode: string, lists: RSTLists): string[] {
@@ -402,10 +421,12 @@ export default function App() {
// User changed band/mode in the entry strip → push to the rig if CAT is up. // User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state. // Both calls are fire-and-forget; CAT will reflect back via cat:state.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) { function onBandUserChange(v: string) {
setBand(v); setBand(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
const hz = qsyFreqHz(v, mode); const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {}); if (hz > 0) SetCATFrequency(hz).catch(() => {});
} }
@@ -414,7 +435,7 @@ export default function App() {
setMode(v); setMode(v);
applyModePreset(v); applyModePreset(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.mode) {
SetCATMode(v).catch(() => {}); SetCATMode(v).catch(() => {});
} }
} }
@@ -469,11 +490,25 @@ export default function App() {
}; };
// Transient success toast (bottom-right, auto-dismiss). Used for things // Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill. // like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => { const toastQueue = useRef<string[]>([]);
setToast(msg); const toastTimer = useRef<number | undefined>(undefined);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []); }, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately.
useEffect(() => { useEffect(() => {
@@ -507,6 +542,12 @@ export default function App() {
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase(); if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
}; };
// Reset the recording to zero (drop everything so far, pre-roll included) —
// bound to clicking the REC timer. Use when the station was already in a long
// QSO and you only want your own exchange in the file.
const resetRecordingClock = () => {
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState(''); const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns). // Advanced filter builder (replaces the old band/mode dropdowns).
@@ -689,6 +730,10 @@ export default function App() {
} }
const chatShown = chatOpen && chatAvailable; const chatShown = chatOpen && chatAvailable;
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkEnabled, setDvkEnabled] = useState(false);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 }); const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
@@ -729,7 +774,7 @@ export default function App() {
const [clusterLockMode, setClusterLockMode] = useState(false); const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including // Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass. // already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set()); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
@@ -772,11 +817,11 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the // map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below. // so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex'; type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1'); const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2'); const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => { const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex'; const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
const [l, r] = await Promise.all([ const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''),
@@ -1048,6 +1093,12 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the // Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns). // rotator is disabled (the backend just reads settings and returns).
useEffect(() => { useEffect(() => {
@@ -1235,12 +1286,20 @@ export default function App() {
// source behaves identically. // source behaves identically.
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz); const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) { // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
tuneRigCAT(s.freq_hz, m); // then tune the rig if CAT is connected. We must not wait for the CAT status
} else { // echo to update the display: on a remote Flex link that echo lags by seconds,
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
if (s.band) setBand(s.band); // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
} }
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotPOTA((s as any).pota_ref);
@@ -1372,6 +1431,19 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
// QSY, spot click, manual band) — so the right antennas follow the frequency.
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
// backends. The backend no-ops if the band has no configured mapping.
const lastAntBandRef = useRef('');
useEffect(() => {
if (catState.backend !== 'flex' || locks.band) return;
const b = band.trim();
if (!b || b === lastAntBandRef.current) return;
lastAntBandRef.current = b;
FlexApplyBandAntenna(b).catch(() => {});
}, [band, catState.backend, locks.band]);
// Cluster live wiring: hydrate per-server status + saved server list, // Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events. // then subscribe to push events.
async function reloadClusterMeta() { async function reloadClusterMeta() {
@@ -1430,7 +1502,11 @@ export default function App() {
// typing: only update when the field is empty, already shows this call, or // typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not // still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed. // a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => { // force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim(); const call = String(raw ?? '').trim();
if (!call) return false; if (!call) return false;
const upper = call.toUpperCase(); const upper = call.toUpperCase();
@@ -1438,22 +1514,26 @@ export default function App() {
const prev = lastUdpCallRef.current; const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up onCallsignInput(call, { force: true }); // programmatic → always look up
return true; return true;
}; };
const unsubDX = EventsOn('udp:dx_call', (p: any) => { const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target. // External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
}); });
const unsubRC = EventsOn('udp:remote_call', (raw: string) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
}); });
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1550,7 +1630,7 @@ export default function App() {
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
}; };
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his); out = out.replace(/\*/g, myCall).replace(/!/g, his);
@@ -1565,15 +1645,22 @@ export default function App() {
} }
async function wkSend(rawText: string) { async function wkSend(rawText: string) {
setWkSent(''); setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has // <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO // finished sending — so the QSO isn't logged (and the form cleared) while CW
// isn't logged (and the form cleared) while the CW is still going out. // is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return; if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save(); void save();
} }
// stopAutoCall cancels any running auto-call loop. // stopAutoCall cancels any running auto-call loop.
@@ -1588,8 +1675,14 @@ export default function App() {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; if (!m) break;
await wkSend(m.text); await wkSend(m.text);
// Wait for the message to finish before the gap+resend. Cap the wait at the
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
// link the keyer's "busy" status lags badly and stays stuck true for tens of
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
if (gen !== autoCallGenRef.current) break; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -1680,6 +1773,10 @@ export default function App() {
// when you first entered the call (minutes early) and won't match LoTW. // when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart; const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
const payload: any = { const payload: any = {
callsign: callsign.trim().toUpperCase(), callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(), qso_date: start.toISOString(),
@@ -1698,13 +1795,11 @@ export default function App() {
tx_pwr: details.tx_pwr, tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode, sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id, contest_id: details.contest_id,
srx: details.srx, stx: details.stx, srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
await AddQSO(payload); await AddQSO(payload);
// Same green toast as a QSL upload, so the op gets visual confirmation.
showToast(`QSO logged — ${payload.callsign}${band ? ` · ${band}` : ''}${mode ? ` ${mode}` : ''}`);
resetEntry(); // clears the call AND the Worked-before matrix resetEntry(); // clears the call AND the Worked-before matrix
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name) callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
await refresh(); await refresh();
@@ -1983,8 +2078,14 @@ export default function App() {
// applyUdpCall saw current != lastUdpCall and refused every later UDP call. // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) → // A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact. // stop auto-calling so we don't key over the contact. If a CQ was actually
if (v.trim() !== '') stopAutoCall(); // in flight, abort the current transmission too so it stops IMMEDIATELY
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
stopAutoCall();
}
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry, // on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -2098,6 +2199,8 @@ export default function App() {
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' }, { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' }, { type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move // Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land. // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing }, { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
@@ -2106,7 +2209,7 @@ export default function App() {
{ name: 'help', label: 'Help', items: [ { name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about' }, { type: 'item', label: 'About OpsLog', action: 'help.about' },
]}, ]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]); ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]);
function handleMenu(action: string) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -2124,6 +2227,7 @@ export default function App() {
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break; case 'help.about': setShowAbout(true); break;
@@ -2247,10 +2351,16 @@ export default function App() {
</Label> </Label>
<div className="relative"> <div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none"> <button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50"
>
<span className="size-2 rounded-full bg-red-600 animate-pulse" /> <span className="size-2 rounded-full bg-red-600 animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')} {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span> </button>
)} )}
<Input <Input
ref={callsignRef} ref={callsignRef}
@@ -2638,8 +2748,9 @@ export default function App() {
{([ {([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' }, // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
return ( return (
@@ -2736,7 +2847,7 @@ export default function App() {
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden"> <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</div> </div>
); );
case 'flex': case 'flex':
@@ -2745,6 +2856,28 @@ export default function App() {
<FlexPanel /> <FlexPanel />
</div> </div>
); );
case 'recent':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/>
</div>
);
} }
}; };
@@ -2800,7 +2933,7 @@ export default function App() {
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in"> <div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" /> <Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span> <span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button> <button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
</div> </div>
)} )}
</div> </div>
@@ -3448,6 +3581,21 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger> <TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger> <TabsTrigger value="bandmap">Band Map</TabsTrigger>
{netEnabled && (
<TabsTrigger value="net" className="gap-1.5">
Net
<span
role="button"
aria-label="Close Net"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>} {catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's {/* Not a tab — QRZ blocks embedding, so this opens the call's
@@ -3554,6 +3702,7 @@ export default function App() {
onBulkEdit={openBulkEdit} onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF} onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/> />
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30"> <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
@@ -3729,7 +3878,7 @@ export default function App() {
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</TabsContent> </TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the {/* Opened on demand from Tools → QSL Manager; closable via the
@@ -3774,6 +3923,12 @@ export default function App() {
{/* Band Map: several bands shown side-by-side (panadapter-style {/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to strips). Pick bands with the chips; each strip is clickable to
tune the rig. */} tune the rig. */}
{netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</TabsContent>
)}
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap"> <div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">Bands:</span> <span className="text-xs text-muted-foreground mr-1">Bands:</span>
+26 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
@@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string
switch (s?.status) { switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' }; case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' }; case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' }; case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null; default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
} }
@@ -159,6 +160,7 @@ const COL_CATALOG: ColEntry[] = [
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC'; if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND'; if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-mode') return 'NEW MODE';
if (s?.status === 'new-slot') return 'NEW SLOT'; if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : ''; return s?.worked_call ? 'WKD CALL' : '';
}, },
@@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// NEW SLOT (mode not yet worked on this band) → fill the cell. // Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot' // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 } cellStyle: (p: any) => {
: undefined), const st = statusFor(p)?.status;
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
// Status badge text tells them apart.
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
return undefined;
},
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>, cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined), tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
return undefined;
},
}, },
{ {
group: 'Spot', label: 'Pfx', colId: 'pfx', group: 'Spot', label: 'Pfx', colId: 'pfx',
@@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// change below. // change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]); const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
useEffect(() => {
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
+7 -4
View File
@@ -35,8 +35,11 @@ export interface DetailsState {
sat_name: string; sat_name: string;
sat_mode: string; sat_mode: string;
contest_id: string; contest_id: string;
srx?: number; // Contest exchanges as free text — most are serials (001) but some are
stx?: number; // alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string; email: string;
// Award references for the contacted station (set via the Awards tab picker). // Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064". // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} /> <Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field> </Field>
<Field label="SRX"> <Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} /> <Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field> </Field>
<Field label="STX"> <Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} /> <Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field> </Field>
<Field label="Contacted email" span={3}> <Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} /> <Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
+51 -14
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -19,7 +19,8 @@ type FlexState = {
proc_enable: boolean; proc_level: number; proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number; mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -34,7 +35,7 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
@@ -110,8 +111,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30', className={cn(
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}> // min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
{label} {label}
</button> </button>
); );
@@ -238,8 +242,15 @@ export function FlexPanel() {
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500]; const CW_BW = [100, 200, 300, 400, 500];
const SSB_BW = [1800, 2100, 2400, 2800, 3000, 4000, 6000]; const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0)); const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
// 1002790), so an exact/±50 match would leave nothing lit — "doesn't pick up
// the current filter". Snapping to the nearest preset always reflects the rig.
const ssbActiveBW = curBW > 0
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1;
return ( return (
<div className="h-full min-h-0 overflow-auto bg-background"> <div className="h-full min-h-0 overflow-auto bg-background">
@@ -350,20 +361,46 @@ export function FlexPanel() {
{/* RECEIVE */} {/* RECEIVE */}
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2"> <Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span>
<select disabled={rxOff} value={st.rx_ant ?? ''}
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<span className="text-[10px] text-muted-foreground">TX</span>
<select disabled={rxOff} value={st.tx_ant ?? ''}
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} /> onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> <button type="button" disabled={rxOff}
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span> title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} /> <Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
@@ -416,7 +453,7 @@ export function FlexPanel() {
FlexSetFilter(lo, hi).catch(() => {}); FlexSetFilter(lo, hi).catch(() => {});
}} }}
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0', className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
Math.abs(curBW - bw) <= 50 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}> bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{(bw / 1000).toFixed(1)} {(bw / 1000).toFixed(1)}
</button> </button>
))} ))}
+32 -24
View File
@@ -139,18 +139,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const from = gridToLatLon(fromGrid); const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid); const to = gridToLatLon(toGrid);
if (from && to) { // Station marker + antenna beam/boom are drawn whenever the station grid is
// known — independent of any DX. The antenna is always pointed somewhere, so
// the beam heading should show even before a callsign is entered.
if (from) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' }) L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo); .addTo(wo);
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
// line, which makes a smooth arc look angular/bumpy).
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) { if (beamAzimuths && beamAzimuths.length) {
@@ -169,21 +164,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a // Draw the lobe as a DENSE fan of translucent great-circle radials, not
// filled polygon: a polygon breaks badly near the poles on Mercator // a filled polygon: a polygon smears badly near the poles on Mercator
// (its edges run off toward ±90° and the fill smears across the map), // (a poleward beam degenerates into a giant triangle), while each radial
// while each radial LINE stays clean. The overlapping lines read as a // LINE stays clean at any azimuth. A small angular step makes the lines
// lobe — solid near the antenna, fanning out toward the front. Works // overlap into a solid-looking lobe (no separate strokes), darker near
// for any azimuth, north/south included. // the antenna and fanning out toward the front.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) { for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]); const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo); L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
} }
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays // Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray // readable on any basemap. Same dashArray so the casing tracks each dash.
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo); L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 }) L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
@@ -204,17 +198,31 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 }) L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
} }
}
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
let arcPts: [number, number][] | null = null;
if (from && to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
}
if (autoZoom) { if (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} } else if (to) {
} else if (autoZoom && to) {
wm.setView([to.lat, to.lon], 3); wm.setView([to.lat, to.lon], 3);
} else if (autoZoom && from) { } else if (from) {
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
}
setTimeout(() => { wm.invalidateSize(); }, 0); setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
+397
View File
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import type { QSOForm } from '@/types';
import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type Net = netctl.Net;
type Station = netctl.Station;
function fmtTimeOn(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
}
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
const [openId, setOpenId] = useState<string>('');
const [roster, setRoster] = useState<Station[]>([]);
const [active, setActive] = useState<QSOForm[]>([]);
const [error, setError] = useState('');
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null);
const isOpen = openId !== '' && openId === selId;
const refreshNets = useCallback(async () => {
try {
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
const arr = (list ?? []) as Net[];
setNets(arr);
setOpenId(oid ?? '');
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
} catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshRoster = useCallback(async (id: string) => {
if (!id) { setRoster([]); return; }
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshActive = useCallback(async () => {
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
catch { /* ignore */ }
}, []);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
// The roster side hides callsigns currently on the air (they live in the left
// grid until logged), mirroring Log4OM's two-list behaviour.
const activeCalls = useMemo(
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
[active],
);
const rosterShown = useMemo(
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
[roster, activeCalls],
);
async function newNet() {
const name = window.prompt('New NET name:');
if (!name?.trim()) return;
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function renameNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
const name = window.prompt('Rename NET:', cur?.name ?? '');
if (!name?.trim()) return;
try { await NetRename(selId, name.trim()); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function deleteNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function toggleOpen() {
try {
if (isOpen) {
if (active.length > 0 &&
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
await NetClose(); setOpenId('');
} else {
await NetOpen(selId); setOpenId(selId); await refreshActive();
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Roster → active (start QSO).
async function activate(call: string) {
if (!isOpen || !call) return;
try { await NetActivate(call); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Edit-modal handlers (operate on the in-memory draft, not the DB).
async function saveDraft(q: QSOForm) {
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function discardDraft(id: number) {
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Add-contact dialog.
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
async function lookupContact() {
const call = (contact.callsign ?? '').trim();
if (!call) return;
setLooking(true);
try {
const r = await NetLookup(call);
setContact((c) => netctl.Station.createFrom({
...c, callsign: (r.callsign || call).toUpperCase(),
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
}));
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLooking(false); }
}
async function saveContact() {
const call = (contact.callsign ?? '').trim().toUpperCase();
if (!call || !selId) return;
try {
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
setContactOpen(false);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function removeSelectedRoster() {
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
if (sel.length === 0) return;
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
try {
for (const s of sel) await NetRosterRemove(selId, s.callsign);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
], []);
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: 'Country', field: 'country', width: 130 },
], []);
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
return (
<div className="flex flex-col min-h-0 flex-1">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
<Plus className="size-3.5" /> New NET
</Button>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
value={selId}
disabled={isOpen}
onChange={(e) => setSelId(e.target.value)}
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
>
<option value=""> select a NET </option>
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
</select>
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
</Button>
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
<Trash2 className="size-3.5" /> Delete
</Button>
<div className="flex-1" />
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
<span className="text-[11px] text-muted-foreground">
On air: <strong className="text-foreground">{active.length}</strong> ·
Roster: <strong className="text-foreground">{roster.length}</strong>
</span>
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
</div>
<div className="flex min-h-0 flex-1">
{/* ACTIVE USERS (left) */}
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
On air active QSOs
<span className="ml-auto font-normal normal-case opacity-90">double-click edit all fields · "Log &amp; end" to save</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
<MinusCircle className="size-3.5" /> Log &amp; end selected
</Button>
</div>
)}
</div>
{/* NET USERS / roster (right) */}
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
NET users roster
<span className="ml-auto font-normal normal-case opacity-90">double-click put on air</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station>
ref={rosterGrid}
theme={hamlogTheme}
rowData={rosterShown}
columnDefs={rosterCols}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
/>
</div>
</div>
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
<UserPlus className="size-3.5" /> Add contact
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
<MinusCircle className="size-3.5" /> Remove
</Button>
{isOpen && (
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
<PlusCircle className="size-3.5" /> Put selected on air
</Button>
)}
</div>
</div>
</div>
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
{editingDraft && (
<QSOEditModal
qso={editingDraft}
onSave={saveDraft}
onDelete={discardDraft}
onClose={() => setEditingDraft(null)}
countries={countries}
bands={bands}
modes={modes}
/>
)}
{/* Add / edit contact dialog */}
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add contact to NET</DialogTitle>
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
</DialogHeader>
<div className="grid gap-3 px-5 py-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-[11px]">Callsign</Label>
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
</div>
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
<Search className="size-3.5" /> {looking ? '' : 'Search'}
</Button>
</div>
<div>
<Label className="text-[11px]">Name</Label>
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">QTH</Label>
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">Country</Label>
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
<PlusCircle className="size-3.5" /> Save in NET
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+59 -64
View File
@@ -10,6 +10,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -60,6 +61,14 @@ const QSL_STATUSES = [
{ v: 'I', label: 'Ignore' }, { v: 'I', label: 'Ignore' },
]; ];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: '— leave —' },
{ v: 'Bureau', label: 'Bureau' },
{ v: 'Direct', label: 'Direct' },
{ v: 'Electronic', label: 'Electronic' },
];
type LogQSO = { type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState(''); const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]); const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set()); const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false); const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState(''); const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y'); const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_'); const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState(''); const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState(''); const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => { const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase(); const c = paperCall.trim().toUpperCase();
@@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[]; const list = (r ?? []) as LogQSO[];
setPaperRows(list); setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id))); setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
}, [paperCall]); }, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() { async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd, rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate), sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate), rcvd_date: ymd(qslRcvdDate),
via: qslVia, via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any); } as any);
setPaperMsg(`${n} QSO updated.`); setPaperMsg(`${n} QSO updated.`);
await searchPaper(); await searchPaper();
@@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
} }
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly. const [rows, setRows] = useState<any[]>([]);
const gridRef = useRef<AgGridReact<UploadRow>>(null);
const [selectedCount, setSelectedCount] = useState(0); const [selectedCount, setSelectedCount] = useState(0);
const selectAllNext = useRef(false); // selectAll once after the next data load const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
@@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload. // Grid selection → just track the count; ids are read from the grid at upload.
function onUploadSelChanged() { function onUploadRowSelected(ids: number[]) {
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0); setUploadSelIds(ids);
} setSelectedCount(ids.length);
// After "Select required" loads new rows, select them all (the old default).
function onUploadRowsLoaded() {
if (selectAllNext.current) {
selectAllNext.current = false;
gridRef.current?.api?.selectAll();
}
} }
const shownConfs = useMemo(() => confirmations.filter((c) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
@@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as any[];
selectAllNext.current = true; // pre-select everything once the grid renders
setRows(list); setRows(list);
setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length); setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload'); setViewMode('upload');
setShowLog(false); setShowLog(false);
} catch (e: any) { } catch (e: any) {
@@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}, [service, sent]); }, [service, sent]);
async function upload() { async function upload() {
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id); const ids = uploadSelIds;
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); } try { await UploadQSOsManual(service, ids); }
@@ -283,7 +290,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}> <Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent> <SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
{uploadCall && ( {uploadCall && (
@@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
paperRows.length === 0 ? ( paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div> <div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : ( ) : (
<table className="w-full text-xs border-collapse"> // Same grid as Recent QSOs (full columns + column picker). Selection
<thead className="sticky top-0 bg-card"> // drives which QSOs the apply-form below updates; a search selects all.
<tr className="text-left text-muted-foreground border-b border-border"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th> <RecentQSOsGrid
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th> rows={paperRows as any}
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th> total={paperRows.length}
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th> selectAllSignal={paperSelAllSig}
</tr> onRowSelected={(ids) => setPaperSel(new Set(ids))}
</thead> />
<tbody> </div>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
) )
) : service === 'pota' ? ( ) : service === 'pota' ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div> <div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : ( ) : (
<div className="h-full w-full"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<AgGridReact<UploadRow> <RecentQSOsGrid
ref={gridRef} rows={rows as any}
theme={qslTheme} total={rows.length}
rowData={rows} selectAllSignal={uploadSelAllSig}
columnDefs={UPLOAD_COLS} onRowSelected={onUploadRowSelected}
defaultColDef={{ sortable: true, resizable: true, filter: true }}
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
onSelectionChanged={onUploadSelChanged}
onRowDataUpdated={onUploadRowsLoaded}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/> />
</div> </div>
)} )}
@@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> <Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>} {paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
+16 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react'; import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null; export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -15,6 +15,7 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
}; };
const UPLOAD_TARGETS: { service: string; label: string }[] = [ const UPLOAD_TARGETS: { service: string; label: string }[] = [
@@ -31,7 +32,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO // or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh, // list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.) // which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) { export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
@@ -159,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
))} ))}
</> </>
)} )}
{onDelete && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
onClick={() => { onDelete(menu.ids); onClose(); }}
>
<Trash2 className="size-4" />
<span>Delete {n} QSO{n > 1 ? 's' : ''}</span>
</button>
</>
)}
</div> </div>
); );
} }
+11 -5
View File
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]); }, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</thead> </thead>
<tbody> <tbody>
{CONFIRMATIONS.map((c) => ( {CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}> <tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{c.label}</td> <td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td> <td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0"> <TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F> <F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F> <F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F> <F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F> <F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F> <F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F> <F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
+13 -1
View File
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
@@ -56,6 +59,7 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// One column per defined award; the cell shows the reference this QSO counts // One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default. // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -156,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ── // ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -222,7 +228,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc', 'Contest', 'Propagation', 'My station', 'Misc',
]; ];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -307,6 +313,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
@@ -395,6 +406,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onBulkEdit={onBulkEdit} onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected} onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered} onExportFiltered={onExportFiltered}
onDelete={onDelete}
/> />
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}> <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+113 -16
View File
@@ -38,6 +38,7 @@ import {
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -174,13 +175,27 @@ type SectionId =
| 'antenna' | 'antenna'
| 'antgenius' | 'antgenius'
| 'pgxl' | 'pgxl'
| 'flex'
| 'audio'; | 'audio';
type TreeNode = type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean }; | { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
const TREE: TreeNode[] = [ // buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean): TreeNode[] {
const hardware: TreeNode[] = [
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: 'FlexRadio', id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: 'Audio devices', id: 'audio' },
];
return [
{ {
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Station Information', id: 'station' },
@@ -206,17 +221,10 @@ const TREE: TreeNode[] = [
], ],
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
{ kind: 'item', label: 'Audio devices', id: 'audio' },
],
}, },
]; ];
}
// Map section id → friendly name (used in breadcrumb / placeholders). // Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = { const SECTION_LABELS: Partial<Record<SectionId, string>> = {
@@ -240,6 +248,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
antenna: 'UltraBeam', antenna: 'UltraBeam',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'Power Genius', pgxl: 'Power Genius',
flex: 'FlexRadio',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -248,12 +257,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
interface TreeProps { interface TreeProps {
selected: SectionId; selected: SectionId;
onSelect: (id: SectionId) => void; onSelect: (id: SectionId) => void;
flexAvailable?: boolean;
} }
function Tree({ selected, onSelect }: TreeProps) { function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
return ( return (
<nav className="text-sm"> <nav className="text-sm">
{TREE.map((node, i) => ( {buildTree(!!flexAvailable).map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} /> <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))} ))}
</nav> </nav>
@@ -485,14 +495,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map2', label: 'Map — locator (street)' }, { value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' }, { value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' }, { value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
]; ];
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) { function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1'); const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2'); const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex. // FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = flexAvailable const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }] ? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: MAIN_PANE_OPTIONS; : [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => { useEffect(() => {
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v); const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
@@ -583,6 +595,90 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
// when the band changes (frequency change / spot click). Antennas come live from
// the connected FlexRadio; bands come from Lists → Bands.
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
const [rxList, setRxList] = useState<string[]>([]);
const [txList, setTxList] = useState<string[]>([]);
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
const [msg, setMsg] = useState('');
useEffect(() => {
GetFlexState().then((s: any) => {
setRxList((s?.ant_list ?? []) as string[]);
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
}).catch(() => {});
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
}, []);
const set = (band: string, side: 'rx' | 'tx', v: string) => {
const key = band.toUpperCase();
setMap((m) => {
const cur = m[key] ?? { rx: '', tx: '' };
const next = { ...m, [key]: { ...cur, [side]: v } };
SaveFlexBandAntennas(next as any)
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
.catch((e: any) => setMsg(String(e?.message ?? e)));
return next;
});
};
return (
<div className="space-y-3">
<div>
<h3 className="text-base font-semibold text-foreground">FlexRadio per-band antennas</h3>
<p className="text-xs text-muted-foreground">
Choose the RX and TX antenna for each band. They're applied automatically when the band
changes (frequency change or clicking a spot).
</p>
</div>
{rxList.length === 0 && (
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
No antennas reported yet make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div>
)}
{bands.length === 0 ? (
<p className="text-xs text-muted-foreground">No bands configured add them in Lists Bands.</p>
) : (
<div className="rounded-md border border-border overflow-hidden max-w-xl">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground text-xs">
<tr>
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
</tr>
</thead>
<tbody>
{bands.map((b) => {
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
return (
<tr key={b} className="border-t border-border/50">
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
<td className="px-3 py-1.5">
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value=""> none </option>
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
<td className="px-3 py-1.5">
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value=""> none </option>
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
</div>
);
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -3846,6 +3942,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
antenna: UltrabeamPanel, antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings, antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings, pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
audio: AudioPanel, audio: AudioPanel,
}; };
@@ -3863,7 +3960,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden"> <div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */} {/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2"> <aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} /> <Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
</aside> </aside>
{/* Right content pane */} {/* Right content pane */}
+3 -1
View File
@@ -53,6 +53,7 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for). // One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
}; };
@@ -67,7 +68,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) { export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -253,6 +254,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo} onSendTo={onSendTo}
onSendRecording={onSendRecording} onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onDelete={onDelete}
/> />
{count > entries.length && ( {count > entries.length && (
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.13'; export const APP_VERSION = '0.15';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+50 -1
View File
@@ -16,6 +16,7 @@ import {audio} from '../models';
import {operating} from '../models'; import {operating} from '../models';
import {udp} from '../models'; import {udp} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models';
export function ADIFFields():Promise<Array<adif.FieldDef>>; export function ADIFFields():Promise<Array<adif.FieldDef>>;
@@ -129,7 +130,7 @@ export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>; export function FlexATUBypass():Promise<void>;
@@ -137,6 +138,8 @@ export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>; export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>; export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>; export function FlexSetAGCMode(arg1:string):Promise<void>;
@@ -173,6 +176,8 @@ export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>; export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>; export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>; export function FlexSetNBLevel(arg1:number):Promise<void>;
@@ -187,8 +192,12 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>; export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -255,6 +264,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>; export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexState():Promise<cat.FlexTXState>; export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>; export function GetIcomState():Promise<cat.IcomTXState>;
@@ -371,6 +382,40 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>; export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>;
export function NetActiveList():Promise<Array<qso.QSO>>;
export function NetClose():Promise<void>;
export function NetCreate(arg1:string):Promise<netctl.Net>;
export function NetDeactivate(arg1:number):Promise<number>;
export function NetDelete(arg1:string):Promise<void>;
export function NetDiscardActive(arg1:number):Promise<void>;
export function NetList():Promise<Array<netctl.Net>>;
export function NetLookup(arg1:string):Promise<netctl.Station>;
export function NetOpen(arg1:string):Promise<void>;
export function NetOpenID():Promise<string>;
export function NetRename(arg1:string,arg2:string):Promise<void>;
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>; export function OpenDatabase(arg1:string):Promise<void>;
@@ -429,6 +474,8 @@ export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
export function QSOAudioResetClock():Promise<boolean>;
export function QSOAudioRestart():Promise<boolean>; export function QSOAudioRestart():Promise<boolean>;
export function QuitApp():Promise<void>; export function QuitApp():Promise<void>;
@@ -483,6 +530,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>; export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
+96
View File
@@ -242,6 +242,10 @@ export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1); return window['go']['main']['App']['FlexAmpOperate'](arg1);
} }
export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexMox(arg1) { export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1); return window['go']['main']['App']['FlexMox'](arg1);
} }
@@ -314,6 +318,10 @@ export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1); return window['go']['main']['App']['FlexSetMonLevel'](arg1);
} }
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) { export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1); return window['go']['main']['App']['FlexSetNB'](arg1);
} }
@@ -342,10 +350,18 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
export function FlexSetSidetoneLevel(arg1) { export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
} }
export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
@@ -478,6 +494,10 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices'](); return window['go']['main']['App']['GetExternalServices']();
} }
export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexState() { export function GetFlexState() {
return window['go']['main']['App']['GetFlexState'](); return window['go']['main']['App']['GetFlexState']();
} }
@@ -710,6 +730,74 @@ export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1); return window['go']['main']['App']['MoveDatabase'](arg1);
} }
export function NetActivate(arg1) {
return window['go']['main']['App']['NetActivate'](arg1);
}
export function NetActiveList() {
return window['go']['main']['App']['NetActiveList']();
}
export function NetClose() {
return window['go']['main']['App']['NetClose']();
}
export function NetCreate(arg1) {
return window['go']['main']['App']['NetCreate'](arg1);
}
export function NetDeactivate(arg1) {
return window['go']['main']['App']['NetDeactivate'](arg1);
}
export function NetDelete(arg1) {
return window['go']['main']['App']['NetDelete'](arg1);
}
export function NetDiscardActive(arg1) {
return window['go']['main']['App']['NetDiscardActive'](arg1);
}
export function NetList() {
return window['go']['main']['App']['NetList']();
}
export function NetLookup(arg1) {
return window['go']['main']['App']['NetLookup'](arg1);
}
export function NetOpen(arg1) {
return window['go']['main']['App']['NetOpen'](arg1);
}
export function NetOpenID() {
return window['go']['main']['App']['NetOpenID']();
}
export function NetRename(arg1, arg2) {
return window['go']['main']['App']['NetRename'](arg1, arg2);
}
export function NetRoster(arg1) {
return window['go']['main']['App']['NetRoster'](arg1);
}
export function NetRosterRemove(arg1, arg2) {
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
}
export function NetRosterUpsert(arg1, arg2) {
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
}
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
}
export function NetUpdateActive(arg1) {
return window['go']['main']['App']['NetUpdateActive'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
@@ -826,6 +914,10 @@ export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel'](); return window['go']['main']['App']['QSOAudioCancel']();
} }
export function QSOAudioResetClock() {
return window['go']['main']['App']['QSOAudioResetClock']();
}
export function QSOAudioRestart() { export function QSOAudioRestart() {
return window['go']['main']['App']['QSOAudioRestart'](); return window['go']['main']['App']['QSOAudioRestart']();
} }
@@ -934,6 +1026,10 @@ export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1); return window['go']['main']['App']['SaveExternalServices'](arg1);
} }
export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveListsSettings(arg1) { export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1); return window['go']['main']['App']['SaveListsSettings'](arg1);
} }
+89 -24
View File
@@ -515,6 +515,11 @@ export namespace cat {
agc_mode?: string; agc_mode?: string;
agc_threshold: number; agc_threshold: number;
audio_level: number; audio_level: number;
mute: boolean;
rx_ant?: string;
tx_ant?: string;
ant_list?: string[];
tx_ant_list?: string[];
nb: boolean; nb: boolean;
nb_level: number; nb_level: number;
nr: boolean; nr: boolean;
@@ -563,6 +568,11 @@ export namespace cat {
this.agc_mode = source["agc_mode"]; this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"]; this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"]; this.audio_level = source["audio_level"];
this.mute = source["mute"];
this.rx_ant = source["rx_ant"];
this.tx_ant = source["tx_ant"];
this.ant_list = source["ant_list"];
this.tx_ant_list = source["tx_ant_list"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nb_level = source["nb_level"]; this.nb_level = source["nb_level"];
this.nr = source["nr"]; this.nr = source["nr"];
@@ -1562,6 +1572,8 @@ export namespace main {
sent_date: string; sent_date: string;
rcvd_date: string; rcvd_date: string;
via: string; via: string;
notes: string;
comment: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLBulkUpdate(source); return new QSLBulkUpdate(source);
@@ -1574,6 +1586,8 @@ export namespace main {
this.sent_date = source["sent_date"]; this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"]; this.rcvd_date = source["rcvd_date"];
this.via = source["via"]; this.via = source["via"];
this.notes = source["notes"];
this.comment = source["comment"];
} }
} }
export class QSLDefaults { export class QSLDefaults {
@@ -2007,6 +2021,81 @@ export namespace main {
} }
export namespace netctl {
export class Station {
callsign: string;
name?: string;
qth?: string;
country?: string;
dxcc?: number;
itu?: number;
cq?: number;
groups?: string;
sig?: string;
sig_info?: string;
static createFrom(source: any = {}) {
return new Station(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.name = source["name"];
this.qth = source["qth"];
this.country = source["country"];
this.dxcc = source["dxcc"];
this.itu = source["itu"];
this.cq = source["cq"];
this.groups = source["groups"];
this.sig = source["sig"];
this.sig_info = source["sig_info"];
}
}
export class Net {
id: string;
name: string;
default_rst_sent?: string;
default_rst_rcvd?: string;
default_comment?: string;
stations?: Station[];
static createFrom(source: any = {}) {
return new Net(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.default_rst_sent = source["default_rst_sent"];
this.default_rst_rcvd = source["default_rst_rcvd"];
this.default_comment = source["default_comment"];
this.stations = this.convertValues(source["stations"], Station);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace operating { export namespace operating {
export class AntennaBand { export class AntennaBand {
@@ -2820,30 +2909,6 @@ export namespace qso {
return a; return a;
} }
} }
export class UploadRow {
id: number;
qso_date: string;
callsign: string;
band: string;
mode: string;
country: string;
status: string;
static createFrom(source: any = {}) {
return new UploadRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = source["qso_date"];
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
+5 -1
View File
@@ -116,7 +116,11 @@ func (c *Client) Activate(port, antenna int) error {
if antenna < 0 { if antenna < 0 {
return fmt.Errorf("antgenius: invalid antenna %d", antenna) return fmt.Errorf("antgenius: invalid antenna %d", antenna)
} }
if err := c.send(fmt.Sprintf("port set %d rxant=%d txant=%d", port, antenna, antenna)); err != nil { // Set only rxant (like the reference ShackMaster client): the AG mirrors it
// to the TX antenna automatically. Forcing txant too can be rejected on the
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
// selection and deselection.
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
return err return err
} }
// Ask for the new port state so the snapshot reflects it promptly (the // Ask for the new port state so the snapshot reflects it promptly (the
+13
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@@ -19,6 +20,7 @@ var (
mu sync.Mutex mu sync.Mutex
file *os.File file *os.File
path string path string
crashFile *os.File // kept open so the runtime can write a crash traceback to it
) )
// Init opens (creates) the log file in dataDir. On rotation we truncate // Init opens (creates) the log file in dataDir. On rotation we truncate
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
file = f file = f
path = logPath path = logPath
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
// recover()s, or a runtime-fatal error like a concurrent map write, or a
// Windows access violation routed through the Go signal handler) into a
// dedicated file the runtime writes directly — so otherwise-silent process
// deaths leave a stack we can read.
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
crashFile = cf
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
}
// Redirect log.Print* and the standard logger to the file too, so // Redirect log.Print* and the standard logger to the file too, so
// any third-party output stays consistent. // any third-party output stays consistent.
log.SetOutput(io.MultiWriter(file, os.Stderr)) log.SetOutput(io.MultiWriter(file, os.Stderr))
+14
View File
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
for i, v := range mono32 { for i, v := range mono32 {
stereo[2*i], stereo[2*i+1] = v, v stereo[2*i], stereo[2*i+1] = v, v
} }
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
// regardless of how short the trailing chunk is. If the buffer isn't an exact
// multiple of a frame, the final pass reads past the slice and the process
// dies with an access violation (0xc0000005) inside windowFilterSubband.
// Pad with trailing silence to a whole number of frames so no partial pass
// exists. (~36 ms of silence at most — inaudible.)
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
if rem := len(stereo) % frameInterleaved; rem != 0 {
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
}
if len(stereo) == 0 {
return nil // nothing to encode (empty recording)
}
enc := mp3.NewEncoder(mp3Rate, 2) enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo) return enc.Write(f, stereo)
} }
+16
View File
@@ -239,6 +239,22 @@ func (r *Recorder) RestartQSO() {
r.active = true r.active = true
} }
// ResetQSOClock restarts the active accumulation from ZERO — discarding
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
// contain only audio from this moment onward. Used when the contact you entered
// was already in a long QSO and you want to record just your own exchange.
// No-op if not running; if no take is active it begins one (empty).
func (r *Recorder) ResetQSOClock() {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running {
return
}
r.acc = nil
r.active = true
}
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and // TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a // stops accumulating — fast, no encoding. The next BeginQSO can safely start a
// new take immediately. Pair with WritePCM to encode/write off the hot path so // new take immediately. Pair with WritePCM to encode/write off the hot path so
+8
View File
@@ -251,6 +251,11 @@ type FlexTXState struct {
AGCMode string `json:"agc_mode,omitempty"` AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"` AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"` AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
NB bool `json:"nb"` NB bool `json:"nb"`
NBLevel int `json:"nb_level"` NBLevel int `json:"nb_level"`
NR bool `json:"nr"` NR bool `json:"nr"`
@@ -312,6 +317,9 @@ type FlexController interface {
SetAGCMode(string) error SetAGCMode(string) error
SetAGCThreshold(int) error SetAGCThreshold(int) error
SetAudioLevel(int) error SetAudioLevel(int) error
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
SetNR(bool) error SetNR(bool) error
+44
View File
@@ -73,6 +73,7 @@ type flexSlice struct {
agcMode string // off | slow | med | fast agcMode string // off | slow | med | fast
agcThreshold int // 0-100 agcThreshold int // 0-100
audioLevel int // 0-100 (RX volume) audioLevel int // 0-100 (RX volume)
mute bool // RX audio muted
nb bool // noise blanker nb bool // noise blanker
nbLevel int nbLevel int
nr bool // noise reduction nr bool // noise reduction
@@ -83,6 +84,10 @@ type flexSlice struct {
apfLevel int apfLevel int
filterLo int // slice filter low cut (Hz) filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz) filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
txAnt string // selected TX antenna
antList []string // antennas valid for this slice (RX side)
txAntList []string // antennas valid for TX
} }
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
@@ -670,6 +675,16 @@ func (f *Flex) handleStatus(payload string) {
s.agcThreshold = atoiDefault(val, s.agcThreshold) s.agcThreshold = atoiDefault(val, s.agcThreshold)
case "audio_level": case "audio_level":
s.audioLevel = atoiDefault(val, s.audioLevel) s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute":
s.mute = val == "1"
case "rxant":
s.rxAnt = val
case "txant":
s.txAnt = val
case "ant_list":
s.antList = splitCSV(val)
case "tx_ant_list":
s.txAntList = splitCSV(val)
case "nb": case "nb":
s.nb = val == "1" s.nb = val == "1"
case "nb_level": case "nb_level":
@@ -957,6 +972,18 @@ func splitKV(kv string) (key, val string, ok bool) {
} }
// atoiDefault parses an int (or a float like "20.0", truncated), else def. // atoiDefault parses an int (or a float like "20.0", truncated), else def.
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
// trimmed slice, dropping empties.
func splitCSV(s string) []string {
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func atoiDefault(s string, def int) int { func atoiDefault(s string, def int) int {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil { if n, err := strconv.Atoi(s); err == nil {
@@ -1029,6 +1056,7 @@ func (f *Flex) FlexState() FlexTXState {
st.AGCMode = rx.agcMode st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel st.AudioLevel = rx.audioLevel
st.Mute = rx.mute
st.NB = rx.nb st.NB = rx.nb
st.NBLevel = rx.nbLevel st.NBLevel = rx.nbLevel
st.NR = rx.nr st.NR = rx.nr
@@ -1039,6 +1067,13 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
st.TXAnt = rx.txAnt
st.AntList = rx.antList
st.TXAntList = rx.txAntList
if len(st.TXAntList) == 0 {
st.TXAntList = rx.antList // many configs share one antenna list
}
} }
if f.amp.handle != "" { if f.amp.handle != "" {
st.AmpAvailable = true st.AmpAvailable = true
@@ -1076,6 +1111,8 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.agcThreshold = toInt(val) rx.agcThreshold = toInt(val)
case "audio_level": case "audio_level":
rx.audioLevel = toInt(val) rx.audioLevel = toInt(val)
case "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
case "nb": case "nb":
rx.nb = val == "1" rx.nb = val == "1"
case "nb_level": case "nb_level":
@@ -1092,6 +1129,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1" rx.apf = val == "1"
case "apf_level": case "apf_level":
rx.apfLevel = toInt(val) rx.apfLevel = toInt(val)
case "rxant":
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -1126,6 +1167,9 @@ func (f *Flex) SetAGCMode(m string) error {
} }
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
+234
View File
@@ -0,0 +1,234 @@
// Package netctl persists "NET" definitions and their station rosters for the
// NET Control feature (managing a directed net / round-table on a frequency).
//
// A NET is a named net (e.g. "French QSO", "QSO des Brasses") with a roster of
// stations that habitually check in. The roster grows over time as you add new
// callsigns. Storage is a single JSON file in the data dir — global/shared
// across all logbooks (a net like "French QSO" is reused whatever logbook is
// open). The QSOs themselves are logged into the active logbook by the caller;
// this package only owns the net definitions + rosters, not the live session.
package netctl
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Station is one roster entry: a station registered in a net.
type Station struct {
Callsign string `json:"callsign"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Country string `json:"country,omitempty"`
DXCC int `json:"dxcc,omitempty"`
ITU int `json:"itu,omitempty"`
CQ int `json:"cq,omitempty"`
Groups string `json:"groups,omitempty"`
SIG string `json:"sig,omitempty"`
SIGInfo string `json:"sig_info,omitempty"`
}
// Net is a named net with default report values and a station roster.
type Net struct {
ID string `json:"id"`
Name string `json:"name"`
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
DefaultComment string `json:"default_comment,omitempty"`
Stations []Station `json:"stations,omitempty"`
}
// Store is the persistent collection of nets, backed by a JSON file.
type Store struct {
mu sync.Mutex
path string
nets []Net
}
// Open loads the store from path (creating an empty one if the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
if err := json.Unmarshal(b, &s.nets); err != nil {
// Corrupt file: start empty rather than failing the whole app.
s.nets = nil
}
}
return s, nil
}
// save writes the current state to disk. Caller must hold s.mu.
func (s *Store) save() error {
b, err := json.MarshalIndent(s.nets, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// List returns a copy of all nets (with rosters), ordered by name.
func (s *Store) List() []Net {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Net, len(s.nets))
copy(out, s.nets)
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
// find returns the index of the net with id, or -1. Caller must hold s.mu.
func (s *Store) find(id string) int {
for i := range s.nets {
if s.nets[i].ID == id {
return i
}
}
return -1
}
// Create adds a new net with default reports of 59/59 and returns it.
func (s *Store) Create(name string) (Net, error) {
name = strings.TrimSpace(name)
if name == "" {
return Net{}, fmt.Errorf("net name required")
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.nets {
if strings.EqualFold(s.nets[i].Name, name) {
return Net{}, fmt.Errorf("a net named %q already exists", name)
}
}
n := Net{ID: newID(), Name: name, DefaultRSTSent: "59", DefaultRSTRcvd: "59"}
s.nets = append(s.nets, n)
return n, s.save()
}
// Rename changes a net's name.
func (s *Store) Rename(id, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("net name required")
}
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets[i].Name = name
return s.save()
}
// SetDefaults updates the per-net default report/comment values.
func (s *Store) SetDefaults(id, rstSent, rstRcvd, comment string) error {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets[i].DefaultRSTSent = rstSent
s.nets[i].DefaultRSTRcvd = rstRcvd
s.nets[i].DefaultComment = comment
return s.save()
}
// Delete removes a net and its roster.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets = append(s.nets[:i], s.nets[i+1:]...)
return s.save()
}
// Get returns a copy of one net by id.
func (s *Store) Get(id string) (Net, bool) {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return Net{}, false
}
return s.nets[i], true
}
// Roster returns a net's stations, sorted by callsign.
func (s *Store) Roster(id string) ([]Station, error) {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return nil, fmt.Errorf("net not found")
}
out := make([]Station, len(s.nets[i].Stations))
copy(out, s.nets[i].Stations)
sort.Slice(out, func(a, b int) bool { return out[a].Callsign < out[b].Callsign })
return out, nil
}
// RosterUpsert adds st to the net's roster, or updates it if the callsign is
// already present (matched case-insensitively; the callsign is stored upper).
func (s *Store) RosterUpsert(id string, st Station) error {
st.Callsign = strings.ToUpper(strings.TrimSpace(st.Callsign))
if st.Callsign == "" {
return fmt.Errorf("callsign required")
}
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
for j := range s.nets[i].Stations {
if strings.EqualFold(s.nets[i].Stations[j].Callsign, st.Callsign) {
s.nets[i].Stations[j] = st
return s.save()
}
}
s.nets[i].Stations = append(s.nets[i].Stations, st)
return s.save()
}
// RosterRemove deletes a callsign from a net's roster.
func (s *Store) RosterRemove(id, callsign string) error {
callsign = strings.ToUpper(strings.TrimSpace(callsign))
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
for j := range s.nets[i].Stations {
if strings.EqualFold(s.nets[i].Stations[j].Callsign, callsign) {
s.nets[i].Stations = append(s.nets[i].Stations[:j], s.nets[i].Stations[j+1:]...)
return s.save()
}
}
return nil // not present → nothing to do
}
+15 -2
View File
@@ -45,6 +45,10 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
// Optimistic fan mode kept until the amp's status poll confirms it (or it
// ages out) — otherwise a stale poll right after a change reverts the UI.
fanPending string
fanPendingAt time.Time
cmdID atomic.Int64 cmdID atomic.Int64
stop chan struct{} stop chan struct{}
@@ -102,7 +106,10 @@ func (c *Client) SetFanMode(mode string) error {
if _, err := c.command("setup fanmode=" + m); err != nil { if _, err := c.command("setup fanmode=" + m); err != nil {
return err return err
} }
c.setStatus(func(s *Status) { s.FanMode = m }) // optimistic c.statusMu.Lock()
c.status.FanMode = m // optimistic
c.fanPending, c.fanPendingAt = m, time.Now()
c.statusMu.Unlock()
return nil return nil
} }
@@ -217,7 +224,13 @@ func (c *Client) parse(resp string) {
case "state": case "state":
c.status.State = kv[1] c.status.State = kv[1]
case "fanmode": case "fanmode":
c.status.FanMode = strings.ToUpper(kv[1]) dev := strings.ToUpper(kv[1])
// Honour a recent optimistic change until the amp confirms it.
if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending {
break
}
c.fanPending = ""
c.status.FanMode = dev
case "temp": case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64) c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
} }
+28
View File
@@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{
// ListForUpload returns QSOs whose per-service sent-status column equals // ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required". // value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] { if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column) return nil, fmt.Errorf("invalid upload column %q", column)
@@ -1622,6 +1647,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
type EntitySlot struct { type EntitySlot struct {
Country string Country string
Bands map[string]struct{} // bands worked, any mode Bands map[string]struct{} // bands worked, any mode
Modes map[string]struct{} // modes worked, any band
Slots map[string]map[string]struct{} // band → modes worked Slots map[string]map[string]struct{} // band → modes worked
} }
@@ -1667,11 +1693,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
e = &EntitySlot{ e = &EntitySlot{
Country: country, Country: country,
Bands: make(map[string]struct{}), Bands: make(map[string]struct{}),
Modes: make(map[string]struct{}),
Slots: make(map[string]map[string]struct{}), Slots: make(map[string]map[string]struct{}),
} }
out[key] = e out[key] = e
} }
e.Bands[band] = struct{}{} e.Bands[band] = struct{}{}
e.Modes[mode] = struct{}{}
bandSlots, ok := e.Slots[band] bandSlots, ok := e.Slots[band]
if !ok { if !ok {
bandSlots = make(map[string]struct{}) bandSlots = make(map[string]struct{})
+23
View File
@@ -68,6 +68,13 @@ type Client struct {
running bool running bool
seqNum byte seqNum byte
seqMu sync.Mutex seqMu sync.Mutex
// Optimistic pattern direction kept until the antenna's status poll reports
// it (or it ages out) — the motors take a second or two, and a stale poll in
// between would otherwise snap the UI back to the old direction.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
} }
type Status struct { type Status struct {
@@ -199,6 +206,14 @@ func (c *Client) pollLoop() {
} }
c.statusMu.Lock() c.statusMu.Lock()
// Keep a just-commanded direction until the antenna reports it.
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
status.Direction = c.pendingDir
}
}
c.lastStatus = status c.lastStatus = status
c.statusMu.Unlock() c.statusMu.Unlock()
@@ -449,6 +464,14 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
} }
_, err := c.sendCommand(CMD_FREQ, data) _, err := c.sendCommand(CMD_FREQ, data)
if err == nil {
c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately
}
c.statusMu.Unlock()
}
return err return err
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.13" appVersion = "0.15"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.