Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f93e1c5898 | ||
|
|
2d67e3d57f | ||
|
|
1a32e4a228 | ||
|
|
b318aa66cc | ||
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 | ||
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b | ||
|
|
ae8e5b1bc6 | ||
|
|
ad69371f6a | ||
|
|
3f97084246 | ||
|
|
74dfc3a725 | ||
|
|
6442325926 | ||
|
|
4c4b3b6c2d | ||
|
|
4d8cb58550 | ||
|
|
a6172c4323 | ||
|
|
b70a679c64 | ||
|
|
f39bda110a | ||
|
|
ab8ecd65fe | ||
|
|
de0771d797 | ||
|
|
22e4266d38 | ||
|
|
e3332d1e27 | ||
|
|
5cebf163c5 | ||
|
|
cfd85ff9c3 | ||
|
|
c6f479750f | ||
|
|
386a8ad531 | ||
|
|
bcd7e409ba | ||
|
|
88f35e2c20 | ||
|
|
d2194da28e | ||
|
|
2f1d592497 | ||
|
|
2b6f1ba9d7 | ||
|
|
4a017f6290 | ||
|
|
7bab30aa71 | ||
|
|
77b95289e7 | ||
|
|
5b894b9dbc | ||
|
|
187ac9aa84 | ||
|
|
629bd8d84f | ||
|
|
7152d11007 | ||
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 |
@@ -38,6 +38,7 @@ import (
|
|||||||
"hamlog/internal/cwdecode"
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
"hamlog/internal/geo"
|
"hamlog/internal/geo"
|
||||||
@@ -116,6 +117,9 @@ const (
|
|||||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||||
|
// Put the RADIO in USB for a digital mode instead of asking for the mode by
|
||||||
|
// name. Every backend, because the reason is the radio, not the link.
|
||||||
|
keyCATDigiUSB = "cat.digi_usb"
|
||||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||||
@@ -191,6 +195,7 @@ const (
|
|||||||
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
|
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
|
||||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||||
|
keyAudioPTTData = "audio.ptt_data" // keyer audio arrives on the rig DATA/USB input
|
||||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||||
@@ -361,6 +366,7 @@ const (
|
|||||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||||
|
keyQSLDefaultHamqthStatus = "qsl.hamqth_status"
|
||||||
keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
|
keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
|
||||||
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
|
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
|
||||||
|
|
||||||
@@ -401,6 +407,12 @@ const (
|
|||||||
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
||||||
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
||||||
|
|
||||||
|
keyExtHamqthUsername = "extsvc.hamqth.username"
|
||||||
|
keyExtHamqthPassword = "extsvc.hamqth.password"
|
||||||
|
keyExtHamqthCallsign = "extsvc.hamqth.callsign"
|
||||||
|
keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
|
||||||
|
keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"
|
||||||
|
|
||||||
keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
|
keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
|
||||||
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
|
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
|
||||||
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
|
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
|
||||||
@@ -450,6 +462,9 @@ type QSLDefaults struct {
|
|||||||
// at "N" here exactly as they do for Club Log.
|
// at "N" here exactly as they do for Club Log.
|
||||||
HamlogStatus string `json:"hamlog_status"`
|
HamlogStatus string `json:"hamlog_status"`
|
||||||
HamlogCfm string `json:"hamlog_confirmed"`
|
HamlogCfm string `json:"hamlog_confirmed"`
|
||||||
|
// HamQTH, same extras story — and SENT only: the site publishes no
|
||||||
|
// confirmation feed, so there is no received side to default.
|
||||||
|
HamqthStatus string `json:"hamqth_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||||
@@ -463,6 +478,9 @@ type CATSettings struct {
|
|||||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||||
|
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
|
||||||
|
// than naming the mode. What the QSO is LOGGED as never changes.
|
||||||
|
DigiAsUSB bool `json:"digi_as_usb"`
|
||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||||
@@ -795,6 +813,9 @@ type App struct {
|
|||||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||||
|
// dxped reads the ADXO announcements and the DX-World feed for the
|
||||||
|
// DXpeditions tab. Built lazily: nothing fetches until the tab is opened.
|
||||||
|
dxped *dxped.Manager
|
||||||
|
|
||||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||||
// session (in-memory only — active stations currently in QSO).
|
// session (in-memory only — active stations currently in QSO).
|
||||||
@@ -2851,7 +2872,9 @@ func (a *App) RestartApp() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("locate executable: %w", err)
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(exe)
|
// --relaunch: the child waits for OUR mutex instead of declaring us a
|
||||||
|
// duplicate — this instance is quitting, just not always fast enough.
|
||||||
|
cmd := exec.Command(exe, "--relaunch")
|
||||||
cmd.Dir = filepath.Dir(exe)
|
cmd.Dir = filepath.Dir(exe)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||||
@@ -2932,6 +2955,28 @@ func (a *App) reloadLookupProviders() {
|
|||||||
|
|
||||||
// --- QSO bindings ---
|
// --- QSO bindings ---
|
||||||
|
|
||||||
|
// fillRXDefaults stamps the receive side when the contact was not split.
|
||||||
|
//
|
||||||
|
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
|
||||||
|
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
|
||||||
|
// which door it came through. It shows up outside OpsLog: the record forwarded
|
||||||
|
// to another logger over UDP is written from the QSO as logged, and a receiver
|
||||||
|
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
|
||||||
|
// path that left it blank.
|
||||||
|
//
|
||||||
|
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
|
||||||
|
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
|
||||||
|
// the export and the forwarded copy all say the same thing.
|
||||||
|
func fillRXDefaults(q *qso.QSO) {
|
||||||
|
if strings.TrimSpace(q.BandRX) == "" {
|
||||||
|
q.BandRX = q.Band
|
||||||
|
}
|
||||||
|
if q.FreqRXHz == nil && q.FreqHz != nil {
|
||||||
|
v := *q.FreqHz
|
||||||
|
q.FreqRXHz = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
@@ -2947,6 +2992,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q, true)
|
a.applyStationDefaults(&q, true)
|
||||||
|
fillRXDefaults(&q)
|
||||||
fillDistance(&q)
|
fillDistance(&q)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
||||||
@@ -8196,7 +8242,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -8285,9 +8331,16 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
||||||
out.IcomBaud = n
|
out.IcomBaud = n
|
||||||
}
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF {
|
// 0x00 is a real CI-V address an operator may need (a bare interface, a rig
|
||||||
|
// left at its factory broadcast address), and "> 0" silently sent them back
|
||||||
|
// to the IC-7610 default with no way to say what they meant. An EMPTY
|
||||||
|
// setting is what means "never configured" — so the error is what decides,
|
||||||
|
// not the value.
|
||||||
|
if v := strings.TrimSpace(m[keyCATIcomAddr]); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 0xFF {
|
||||||
out.IcomAddr = n
|
out.IcomAddr = n
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if out.Backend == "" {
|
if out.Backend == "" {
|
||||||
out.Backend = "omnirig"
|
out.Backend = "omnirig"
|
||||||
}
|
}
|
||||||
@@ -8297,6 +8350,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||||
out.OmniRigVFO = v
|
out.OmniRigVFO = v
|
||||||
}
|
}
|
||||||
|
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
|
||||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||||
out.OmniRigNum = n
|
out.OmniRigNum = n
|
||||||
}
|
}
|
||||||
@@ -8405,6 +8459,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATBackend: s.Backend,
|
keyCATBackend: s.Backend,
|
||||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||||
|
keyCATDigiUSB: boolStr(s.DigiAsUSB),
|
||||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||||
keyCATFlexSpots: flexSpots,
|
keyCATFlexSpots: flexSpots,
|
||||||
@@ -8475,6 +8530,10 @@ type AudioSettings struct {
|
|||||||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||||
|
// PTTData: the keyer's audio reaches the radio on its DATA/USB input, not
|
||||||
|
// the microphone socket. CAT keying only — it changes which transmit
|
||||||
|
// command is sent (a Kenwood TS-590 takes TX1 instead of TX).
|
||||||
|
PTTData bool `json:"ptt_data"`
|
||||||
Format string `json:"format"` // "wav" | "mp3"
|
Format string `json:"format"` // "wav" | "mp3"
|
||||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||||
@@ -8536,7 +8595,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
|||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
|
||||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -8548,6 +8607,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
|||||||
out.PTTMethod = v
|
out.PTTMethod = v
|
||||||
}
|
}
|
||||||
out.PTTPort = m[keyAudioPTTPort]
|
out.PTTPort = m[keyAudioPTTPort]
|
||||||
|
out.PTTData = m[keyAudioPTTData] == "1"
|
||||||
out.FromRadio = m[keyAudioFromRadio]
|
out.FromRadio = m[keyAudioFromRadio]
|
||||||
out.ToRadio = m[keyAudioToRadio]
|
out.ToRadio = m[keyAudioToRadio]
|
||||||
out.RecordingDevice = m[keyAudioRecDevice]
|
out.RecordingDevice = m[keyAudioRecDevice]
|
||||||
@@ -8618,6 +8678,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
|||||||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||||||
keyAudioPTTMethod: pttMethod,
|
keyAudioPTTMethod: pttMethod,
|
||||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||||
|
keyAudioPTTData: boolStr(s.PTTData),
|
||||||
keyAudioFormat: format,
|
keyAudioFormat: format,
|
||||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||||
@@ -9106,6 +9167,7 @@ func (a *App) clusterEventWorker() {
|
|||||||
FreqHz: s.FreqHz,
|
FreqHz: s.FreqHz,
|
||||||
Callsign: s.DXCall,
|
Callsign: s.DXCall,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
|
Priority: spotPriority(status),
|
||||||
Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
|
Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
|
||||||
Color: col.Text,
|
Color: col.Text,
|
||||||
BackgroundColor: col.Bg,
|
BackgroundColor: col.Bg,
|
||||||
@@ -10187,14 +10249,26 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
|||||||
}
|
}
|
||||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||||
if !ok {
|
if !ok {
|
||||||
// No exception COVERS this QSO's date. If the call nonetheless HAS a
|
// No exception covers this QSO's date, so the question becomes which
|
||||||
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
|
// country file knows the PREFIX better.
|
||||||
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older
|
//
|
||||||
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 →
|
// ClubLog's prefix table is date-ranged and cty.dat's is not, and that
|
||||||
// England for a 2012 contact). Ordinary calls (no exception) are left to
|
// is not a detail: cty.dat describes the world as it is TODAY. Niue
|
||||||
// cty.dat.
|
// moved to E6, so ZK2 went back to New Zealand there — and every ZK2
|
||||||
if a.clublog.HasException(q.Callsign) {
|
// contact ever made was silently relabelled New Zealand, taking a
|
||||||
|
// confirmed entity out of an operator's DXCC with it. ZK1 loses the
|
||||||
|
// Cook Islands the same way. ClubLog still knows both.
|
||||||
|
//
|
||||||
|
// It only speaks when it HAS an answer (E6, TO5A and their like are not
|
||||||
|
// in its prefix table at all), and never over an exact "=CALLSIGN" entry
|
||||||
|
// in cty.dat: that is somebody having looked at this very callsign, and
|
||||||
|
// a prefix rule does not overrule it.
|
||||||
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
||||||
|
ctyExact := false
|
||||||
|
if m, mok := a.dxcc.Lookup(q.Callsign); mok {
|
||||||
|
ctyExact = m.Exact
|
||||||
|
}
|
||||||
|
if !ctyExact {
|
||||||
e, ok = pe, true
|
e, ok = pe, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10544,7 +10618,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
|||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("CAT not initialized")
|
return fmt.Errorf("CAT not initialized")
|
||||||
}
|
}
|
||||||
if err := a.cat.SetPTT(true); err != nil {
|
if err := a.cat.SetPTTSource(true, cfg.PTTData); err != nil {
|
||||||
applog.Printf("ptt: CAT SetPTT failed: %v", err)
|
applog.Printf("ptt: CAT SetPTT failed: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -10941,6 +11015,7 @@ func defaultQSLDefaults() QSLDefaults {
|
|||||||
EQSLSent: "R", EQSLRcvd: "N",
|
EQSLSent: "R", EQSLRcvd: "N",
|
||||||
LOTWSent: "R", LOTWRcvd: "N",
|
LOTWSent: "R", LOTWRcvd: "N",
|
||||||
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||||
|
HamqthStatus: "R",
|
||||||
QRZComStatus: "R", QRZComCfm: "N",
|
QRZComStatus: "R", QRZComCfm: "N",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10962,6 +11037,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
|
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
|
||||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||||
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
||||||
|
keyQSLDefaultHamqthStatus,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -10978,6 +11054,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||||
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
|
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
|
||||||
|
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
|
||||||
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
|
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -11002,6 +11079,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
|||||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||||
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
|
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
|
||||||
|
keyQSLDefaultHamqthStatus: strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
|
||||||
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
|
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
@@ -11058,6 +11136,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
|||||||
// string field, and these two are map entries. Only set when the QSO does not
|
// string field, and these two are map entries. Only set when the QSO does not
|
||||||
// already carry them, which is the same rule fill() applies.
|
// already carry them, which is the same rule fill() applies.
|
||||||
setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
|
setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
|
||||||
|
setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
|
||||||
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
|
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11175,7 +11254,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
|
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
|
||||||
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode)
|
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
|
||||||
|
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
|
||||||
|
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -11259,6 +11340,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
AutoUpload: m[keyExtHamlogAutoUpload] == "1",
|
AutoUpload: m[keyExtHamlogAutoUpload] == "1",
|
||||||
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
|
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
|
||||||
}
|
}
|
||||||
|
out.HamQTH = extsvc.ServiceConfig{
|
||||||
|
Username: m[keyExtHamqthUsername],
|
||||||
|
Password: m[keyExtHamqthPassword],
|
||||||
|
Callsign: m[keyExtHamqthCallsign],
|
||||||
|
AutoUpload: m[keyExtHamqthAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
|
||||||
|
}
|
||||||
|
// The callbook lookup already knows these credentials; blanks fall back to
|
||||||
|
// them so an operator who set up the lookup years ago is one checkbox away.
|
||||||
|
if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
|
||||||
|
u, _ := a.settings.Get(a.ctx, keyHQUser)
|
||||||
|
p, _ := a.settings.Get(a.ctx, keyHQPassword)
|
||||||
|
out.HamQTH.Username = strings.TrimSpace(u)
|
||||||
|
out.HamQTH.Password = p
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11333,6 +11429,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if hamMode == string(extsvc.ModeOnClose) {
|
if hamMode == string(extsvc.ModeOnClose) {
|
||||||
hamMode = string(extsvc.ModeImmediate)
|
hamMode = string(extsvc.ModeImmediate)
|
||||||
}
|
}
|
||||||
|
hqMode := modeOf(cfg.HamQTH.UploadMode)
|
||||||
hamAuto := "0"
|
hamAuto := "0"
|
||||||
if cfg.Hamlog.AutoUpload {
|
if cfg.Hamlog.AutoUpload {
|
||||||
hamAuto = "1"
|
hamAuto = "1"
|
||||||
@@ -11388,6 +11485,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
|
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
|
||||||
keyExtHamlogAutoUpload: hamAuto,
|
keyExtHamlogAutoUpload: hamAuto,
|
||||||
keyExtHamlogUploadMode: hamMode,
|
keyExtHamlogUploadMode: hamMode,
|
||||||
|
keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username),
|
||||||
|
keyExtHamqthPassword: cfg.HamQTH.Password,
|
||||||
|
keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
|
||||||
|
keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
|
||||||
|
keyExtHamqthUploadMode: hqMode,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -11416,6 +11518,142 @@ func (a *App) TestClublogUpload() (string, error) {
|
|||||||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
|
||||||
|
//
|
||||||
|
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
|
||||||
|
// it has to be driven a full backlog costs the better part of an hour. This is
|
||||||
|
// the other endpoint HamQTH offers: a whole log as one file, which is why it
|
||||||
|
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
|
||||||
|
// nothing that is not in the file.
|
||||||
|
//
|
||||||
|
// Scoped to the callsign this profile uploads as: a database holding two
|
||||||
|
// operators' contacts must not push one operator's QSOs into the other's log.
|
||||||
|
func (a *App) UploadFullLogHamQTH() error {
|
||||||
|
if a.qso == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
cfg := a.loadExternalServices().HamQTH
|
||||||
|
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
|
||||||
|
return fmt.Errorf("set the HamQTH username and password first")
|
||||||
|
}
|
||||||
|
go a.runFullLogHamQTH(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
|
||||||
|
emit := func(line string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := func(n int) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := a.ctx
|
||||||
|
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
|
||||||
|
|
||||||
|
// Written to a temp file rather than a buffer so the ordinary, tested
|
||||||
|
// exporter does the work — the same one the Export menu uses.
|
||||||
|
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
|
||||||
|
if err != nil {
|
||||||
|
emit("Export failed: " + err.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := tmp.Name()
|
||||||
|
_ = tmp.Close()
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if owner != "" {
|
||||||
|
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
|
||||||
|
}
|
||||||
|
emit("Exporting the log…")
|
||||||
|
var res adif.ExportResult
|
||||||
|
if owner != "" {
|
||||||
|
// station_callsign empty OR the owner call — an old QSO logged before
|
||||||
|
// the field existed belongs to whoever is uploading now.
|
||||||
|
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
|
||||||
|
{Field: "station_callsign", Op: "eq", Value: ""},
|
||||||
|
{Field: "station_callsign", Op: "eq", Value: owner},
|
||||||
|
}}
|
||||||
|
res, err = a.ExportADIFFiltered(path, false, f, nil)
|
||||||
|
} else {
|
||||||
|
res, err = a.ExportADIF(path, false, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
emit("Export failed: " + err.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, rerr := os.ReadFile(path)
|
||||||
|
if rerr != nil {
|
||||||
|
emit("Export failed: " + rerr.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := a.qso.Count(ctx)
|
||||||
|
if total > 0 && int64(res.Count) != total {
|
||||||
|
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
|
||||||
|
}
|
||||||
|
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
|
||||||
|
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
|
||||||
|
|
||||||
|
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
|
||||||
|
if uerr != nil || !up.OK {
|
||||||
|
msg := up.Message
|
||||||
|
if uerr != nil {
|
||||||
|
msg = uerr.Error()
|
||||||
|
}
|
||||||
|
emit("Upload failed: " + msg)
|
||||||
|
applog.Printf("hamqth: full-log upload failed: %s", msg)
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit("HamQTH replied: " + up.Message)
|
||||||
|
// Said plainly, because the numbers will not agree for a while and an
|
||||||
|
// operator comparing them straight away has every reason to think the
|
||||||
|
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
|
||||||
|
// own pace. What it makes of each record is reported by E-MAIL, never in
|
||||||
|
// this reply — so a count that stops short means the site rejected records,
|
||||||
|
// and the mail says which.
|
||||||
|
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
|
||||||
|
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
|
||||||
|
|
||||||
|
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
|
||||||
|
// the rows that are not already stamped need writing.
|
||||||
|
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
|
||||||
|
if lerr != nil {
|
||||||
|
applog.Printf("hamqth: marking sent: %v", lerr)
|
||||||
|
} else if len(pending) > 0 {
|
||||||
|
ids := make([]int64, 0, len(pending))
|
||||||
|
for _, q := range pending {
|
||||||
|
ids = append(ids, q.ID)
|
||||||
|
}
|
||||||
|
date := time.Now().UTC().Format("20060102")
|
||||||
|
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
|
||||||
|
applog.Printf("hamqth: marking sent: %v", e)
|
||||||
|
}
|
||||||
|
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
|
||||||
|
applog.Printf("hamqth: marking sent date: %v", e)
|
||||||
|
}
|
||||||
|
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
|
||||||
|
}
|
||||||
|
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
|
||||||
|
}
|
||||||
|
done(res.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
|
||||||
|
// authenticated, and unable to touch the log.
|
||||||
|
func (a *App) TestHamQTHUpload() (string, error) {
|
||||||
|
cfg := a.loadExternalServices().HamQTH
|
||||||
|
return extsvc.TestHamQTH(a.ctx, nil, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHRDLogUpload validates that the HRDLog credentials are complete.
|
// TestHRDLogUpload validates that the HRDLog credentials are complete.
|
||||||
func (a *App) TestHRDLogUpload() (string, error) {
|
func (a *App) TestHRDLogUpload() (string, error) {
|
||||||
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
|
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
|
||||||
@@ -11475,6 +11713,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
|
|||||||
if extsvc.Service(service) == extsvc.ServiceHamlog {
|
if extsvc.Service(service) == extsvc.ServiceHamlog {
|
||||||
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
|
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
|
||||||
}
|
}
|
||||||
|
if extsvc.Service(service) == extsvc.ServiceHamQTH {
|
||||||
|
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
|
||||||
|
}
|
||||||
col := uploadColumnFor(service)
|
col := uploadColumnFor(service)
|
||||||
if col == "" {
|
if col == "" {
|
||||||
return nil, fmt.Errorf("unknown service %q", service)
|
return nil, fmt.Errorf("unknown service %q", service)
|
||||||
@@ -11490,7 +11731,14 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
|||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
svc := extsvc.Service(service)
|
svc := extsvc.Service(service)
|
||||||
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog {
|
// Cloudlog/Wavelog has no sent-status column ON PURPOSE — it dedupes
|
||||||
|
// server-side, which is exactly why an explicit selection needs no column to
|
||||||
|
// be safe. HAMLOG.online and HamQTH keep theirs in ADIF extras.
|
||||||
|
if svc == extsvc.ServiceHamlog {
|
||||||
|
return extsvc.ErrHamlogClosed
|
||||||
|
}
|
||||||
|
if uploadColumnFor(service) == "" &&
|
||||||
|
svc != extsvc.ServiceHamQTH && svc != extsvc.ServiceCloudlog {
|
||||||
return fmt.Errorf("unknown service %q", service)
|
return fmt.Errorf("unknown service %q", service)
|
||||||
}
|
}
|
||||||
cfg := a.loadExternalServices()
|
cfg := a.loadExternalServices()
|
||||||
@@ -11714,6 +11962,46 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
|
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH || svc == extsvc.ServiceCloudlog {
|
||||||
|
// One record per request, extras-stamped services. Hamlog used to fall
|
||||||
|
// through to the QRZ branch below and got uploaded to the WRONG service
|
||||||
|
// with the QRZ key — the context menu offered what the loop never handled.
|
||||||
|
for i, id := range ids {
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(manualUploadPace)
|
||||||
|
}
|
||||||
|
q, gerr := a.qso.GetByID(ctx, id)
|
||||||
|
call := ""
|
||||||
|
if gerr == nil {
|
||||||
|
call = q.Callsign
|
||||||
|
}
|
||||||
|
rec, ok := a.buildUploadADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
emit(call + " — skipped (no record)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var res extsvc.UploadResult
|
||||||
|
var err error
|
||||||
|
switch svc {
|
||||||
|
case extsvc.ServiceHamlog:
|
||||||
|
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
|
||||||
|
case extsvc.ServiceHamQTH:
|
||||||
|
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
|
||||||
|
default:
|
||||||
|
res, err = extsvc.UploadCloudlog(ctx, nil, cfg.Cloudlog, rec)
|
||||||
|
}
|
||||||
|
if err == nil && res.OK {
|
||||||
|
a.markExtUploaded(svc, id, res.LogID)
|
||||||
|
uploaded++
|
||||||
|
emit(call + " — OK")
|
||||||
|
} else {
|
||||||
|
msg := res.Message
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
emit(call + " — FAILED: " + msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// QRZ.com: one record per request (its logbook API has no batch upload),
|
// QRZ.com: one record per request (its logbook API has no batch upload),
|
||||||
// paced for the same reason as HRDLog above.
|
// paced for the same reason as HRDLog above.
|
||||||
@@ -11755,6 +12043,8 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
label := map[extsvc.Service]string{
|
label := map[extsvc.Service]string{
|
||||||
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||||
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||||
|
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
|
||||||
|
extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
|
||||||
}[svc]
|
}[svc]
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = string(svc)
|
label = string(svc)
|
||||||
@@ -13490,14 +13780,21 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
// is not remembered — hence no on-close mode and no manual backlog.
|
// is not remembered — hence no on-close mode and no manual backlog.
|
||||||
return true
|
return true
|
||||||
case extsvc.ServiceHamlog:
|
case extsvc.ServiceHamlog:
|
||||||
// The stamp is an extra, not a column — see markExtUploaded. Present means
|
// The stamp is an extra, not a column — see markExtUploaded. A stamp that
|
||||||
// it has gone, which is what stops an on-demand re-upload of a whole log
|
// MEANS SENT is what stops an on-demand re-upload of a whole log from
|
||||||
// from sending every contact twice.
|
// sending every contact twice.
|
||||||
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" {
|
if q.Extras != nil && extrasSaysSent(q.Extras[hamlogSentKey]) {
|
||||||
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
|
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case extsvc.ServiceHamQTH:
|
||||||
|
// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
|
||||||
|
if q.Extras != nil && extrasSaysSent(q.Extras[hamqthSentKey]) {
|
||||||
|
applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||||
if strings.EqualFold(q.LOTWSent, f) {
|
if strings.EqualFold(q.LOTWSent, f) {
|
||||||
@@ -13518,8 +13815,26 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
const (
|
const (
|
||||||
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
|
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
|
||||||
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
|
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
|
||||||
|
hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT"
|
||||||
|
hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// extrasSaysSent reads an extras upload stamp as "this QSO has GONE".
|
||||||
|
//
|
||||||
|
// Mere presence will not do. markExtUploaded writes "Y" (older builds wrote the
|
||||||
|
// date), but the Confirmations page pre-fills the same key with a TO-DO status
|
||||||
|
// — "R", requested, is the natural default for a service you intend to upload
|
||||||
|
// to — and reading that as "already gone" would silently disable the very
|
||||||
|
// auto-upload the default was set to arm. So the pending statuses mean pending,
|
||||||
|
// and anything else means sent.
|
||||||
|
func extrasSaysSent(v string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(v)) {
|
||||||
|
case "", "R", "N", "Q", "I":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||||
date := time.Now().UTC().Format("20060102")
|
date := time.Now().UTC().Format("20060102")
|
||||||
// Use a fresh background context, NOT a.ctx: this stamp often runs during
|
// Use a fresh background context, NOT a.ctx: this stamp often runs during
|
||||||
@@ -13565,6 +13880,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
|||||||
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
|
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
|
||||||
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
|
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
|
||||||
}
|
}
|
||||||
|
case extsvc.ServiceHamQTH:
|
||||||
|
if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
|
||||||
|
err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||||
@@ -14117,6 +14436,7 @@ func (a *App) consumeUDPEvents() {
|
|||||||
BackgroundColor: bgCol,
|
BackgroundColor: bgCol,
|
||||||
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
|
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
|
||||||
LifetimeSec: secs,
|
LifetimeSec: secs,
|
||||||
|
Priority: spotPriority(status),
|
||||||
}:
|
}:
|
||||||
default:
|
default:
|
||||||
// Radio not keeping up: skip the spot rather than stall the
|
// Radio not keeping up: skip the spot rather than stall the
|
||||||
@@ -14555,6 +14875,7 @@ func (a *App) SetCATMode(mode string) error {
|
|||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
}
|
}
|
||||||
|
mode = a.catModeForRadio(mode)
|
||||||
err := a.cat.SetMode(mode)
|
err := a.cat.SetMode(mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
||||||
@@ -14562,6 +14883,31 @@ func (a *App) SetCATMode(mode string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
|
||||||
|
// in, for operators who have asked for USB on digital.
|
||||||
|
//
|
||||||
|
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
|
||||||
|
// the microphone path, and that is what most rigs need. Asking for the mode by
|
||||||
|
// name is the better answer on a modern transceiver with a DATA/PKT position,
|
||||||
|
// and the wrong one on an older set where the CAT layer resolves "digital" to
|
||||||
|
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
|
||||||
|
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
|
||||||
|
// file, and there is no arguing with it from here — so the option sends what
|
||||||
|
// the radio can actually do.
|
||||||
|
//
|
||||||
|
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
|
||||||
|
// what the contact WAS, not what the front panel says.
|
||||||
|
func (a *App) catModeForRadio(mode string) string {
|
||||||
|
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if qso.ModeClass(mode) != "DIG" {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
|
||||||
|
return "USB"
|
||||||
|
}
|
||||||
|
|
||||||
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
||||||
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
||||||
|
|
||||||
@@ -20736,6 +21082,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if !worked {
|
if !worked {
|
||||||
out[i].Status = "new"
|
out[i].Status = "new"
|
||||||
if _, ever := entities[dxccNum]; ever {
|
if _, ever := entities[dxccNum]; ever {
|
||||||
|
// Worked, never confirmed — and if it was THIS station on THIS
|
||||||
|
// slot, the need is not its to answer (see the rule below).
|
||||||
|
if out[i].WorkedSlot {
|
||||||
|
out[i].Status = "worked"
|
||||||
|
continue
|
||||||
|
}
|
||||||
out[i].UnconfStatus = true
|
out[i].UnconfStatus = true
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -20772,6 +21124,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// THIS station, already in the log on THIS slot, has nothing left to
|
||||||
|
// give. Working it a second time on the same band and mode cannot make
|
||||||
|
// the entity count: the QSO is already there and only the QSL is
|
||||||
|
// missing, and a duplicate does not produce one. So the need is real —
|
||||||
|
// it is simply not this station's to answer, and shouting NEW DXCC over
|
||||||
|
// a callsign worked an hour ago is how an operator learns to distrust
|
||||||
|
// the colour. Another station in the entity still carries the badge.
|
||||||
|
//
|
||||||
|
// Only for a need that IS a missing confirmation: a genuinely new
|
||||||
|
// entity cannot co-exist with a worked callsign, so nothing real is
|
||||||
|
// suppressed here.
|
||||||
|
if out[i].UnconfStatus && out[i].WorkedSlot {
|
||||||
|
out[i].Status = "worked"
|
||||||
|
out[i].UnconfStatus = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||||
|
//
|
||||||
|
// What a logger can say that a news reader cannot: whether the announced
|
||||||
|
// operation is worth chasing. Every activation is judged against THIS log —
|
||||||
|
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||||
|
// still need", not "what is on the air".
|
||||||
|
|
||||||
|
// DXpedition is one announced operation plus what it is worth here.
|
||||||
|
type DXpedition struct {
|
||||||
|
dxped.Activation
|
||||||
|
// Status is the strongest verdict across the announced callsigns, bands and
|
||||||
|
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||||
|
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||||
|
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||||
|
Status string `json:"status_chase"`
|
||||||
|
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||||
|
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||||
|
Unconfirmed bool `json:"unconfirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||||
|
// list shows ONE badge, so the ranking is the whole decision.
|
||||||
|
var chaseRank = map[string]int{
|
||||||
|
"new": 6,
|
||||||
|
"new-band-mode": 5,
|
||||||
|
"new-band": 4,
|
||||||
|
"new-mode": 3,
|
||||||
|
"new-slot": 2,
|
||||||
|
"worked": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||||
|
// each carrying its chase verdict.
|
||||||
|
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
acts, err := a.dxped.Activations(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: adxo fetch: %v", err)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Stale data with a logged error beats an empty tab.
|
||||||
|
}
|
||||||
|
out := make([]DXpedition, 0, len(acts))
|
||||||
|
for _, act := range acts {
|
||||||
|
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||||
|
}
|
||||||
|
a.judgeDXpeditions(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeDXpeditions fills in the chase verdict, in place.
|
||||||
|
//
|
||||||
|
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||||
|
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||||
|
// asking it forty times to answer forty rows was the difference between a tab
|
||||||
|
// that opens and a tab that stalls a remote MySQL for a second.
|
||||||
|
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||||
|
if a.qso == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct{ row int }
|
||||||
|
var queries []SpotQuery
|
||||||
|
var owners []slot
|
||||||
|
for i, d := range list {
|
||||||
|
calls := d.Calls
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||||
|
calls = []string{c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
// No announced band/mode: ask the entity-level question alone.
|
||||||
|
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||||
|
queries = append(queries, SpotQuery{Call: call})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bands := d.Bands
|
||||||
|
if len(bands) == 0 {
|
||||||
|
bands = []string{""}
|
||||||
|
}
|
||||||
|
modes := d.Modes
|
||||||
|
if len(modes) == 0 {
|
||||||
|
modes = []string{""}
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
for _, m := range modes {
|
||||||
|
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := a.ClusterSpotStatuses(queries)
|
||||||
|
for i, r := range res {
|
||||||
|
if i >= len(owners) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
row := owners[i].row
|
||||||
|
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||||
|
list[row].Status = r.Status
|
||||||
|
list[row].Unconfirmed = r.UnconfStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||||
|
// of each one so the reader can watch them.
|
||||||
|
//
|
||||||
|
// Deliberately NOT judged against the log. A headline names no band, so the
|
||||||
|
// only verdict available is entity-level, and a pane of "NEW DXCC" and
|
||||||
|
// "worked" badges turned out to say nothing an operator could act on — the
|
||||||
|
// same two words on every row is noise wearing the clothes of information.
|
||||||
|
// The announcements pane, which knows the bands and modes, is where a chase
|
||||||
|
// verdict is worth drawing.
|
||||||
|
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
news, err := a.dxped.News(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||||
|
if len(news) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||||
|
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||||
|
// on a cluster should not wait out the cache to see it here.
|
||||||
|
func (a *App) RefreshDXpeditions() {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
a.dxped.Invalidate()
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtHamqthPassword: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
keyExtCloudlogAPIKey: true,
|
keyExtCloudlogAPIKey: true,
|
||||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||||
|
|||||||
+26
-2
@@ -55,7 +55,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Add(callsign, contest)
|
if err := a.watchlist.Add(callsign, contest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, true)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistRemove deletes an entry.
|
// WatchlistRemove deletes an entry.
|
||||||
@@ -63,7 +67,27 @@ func (a *App) WatchlistRemove(callsign string) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Remove(callsign)
|
if err := a.watchlist.Remove(callsign); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWatchlist announces a membership change to the UI.
|
||||||
|
//
|
||||||
|
// Emitted from the BINDINGS rather than from the watchlist panel, because a
|
||||||
|
// call can now be added from three places (the panel, the cluster's menu, the
|
||||||
|
// DXpeditions tab) and an operator who added one from the cluster saw nothing
|
||||||
|
// at all — the confirmation lived inside a panel they were not looking at.
|
||||||
|
func (a *App) notifyWatchlist(callsign string, added bool) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(callsign)),
|
||||||
|
"added": added,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
||||||
|
|||||||
+138
@@ -1,4 +1,142 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.11",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operator’s DXCC with it. ZK1 lost the Cook Islands the same way. ClubLog’s prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
|
||||||
|
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.com’s per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operator’s real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
|
||||||
|
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
|
||||||
|
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
|
||||||
|
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
|
||||||
|
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
|
||||||
|
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
|
||||||
|
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending program’s own status names the mode in full and now settles it; the markers both forks agree on are untouched."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel qu’il est AUJOURD’HUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de l’opérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès qu’aucune exception par indicatif ne s’applique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle n’a pas de réponse c’est cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
|
||||||
|
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QU’UNE paire représentative par entité, et OpsLog l’imposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). C’est un crédit WAZ pour une zone jamais travaillée. L’entité vient toujours de cty.dat, qui fait autorité sur ce qu’un indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
|
||||||
|
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook l’a rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec l’apparence de ce qu’a dit la page — c’est ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
|
||||||
|
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu d’envoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut d’envoi par QSO — volontairement, puisqu’il dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite n’a besoin d’aucun statut pour être sûre : c’est précisément pourquoi cette absence cesse de compter ici.",
|
||||||
|
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface d’envoi n’accepte rien d’autre : la case d’envoi automatique et l’entrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations s’importent toujours depuis un fichier (ce qui n’a jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code d’envoi est conservé intact pour le jour où les clés reviendraient.",
|
||||||
|
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà qu’un FTDX101MP peut sortir 200 — c’est à cela qu’elle dimensionnait son curseur — mais la commande d’envoi était bornée à 100 : le poste recevait donc poliment la moitié de ce qu’on lui demandait, et le sondage suivant l’affichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
|
||||||
|
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 l’y amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre n’en faisait qu’une moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
|
||||||
|
"FT decodes : JTDX en FT4 ne s’affiche plus en Q65. Un décodage porte un marqueur de mode d’un seul caractère, et les deux logiciels ne s’accordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux s’accordent ne changent pas."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.10",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
|
||||||
|
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NG’s firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez.",
|
||||||
|
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base d’Arduino — le firmware K3NG, la famille ERC — REDÉMARRE à l’ouverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels d’un échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
|
||||||
|
"Voice keyer with CAT keying: an option saying the keyer’s audio arrives on the radio’s DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rig’s own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — la signature d’une liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait s’en apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte qu’une ligne à ignorer.",
|
||||||
|
"Voice keyer avec PTT CAT : une option indiquant que l’audio du keyer arrive sur l’entrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 l’ACC2/USB — si bien qu’un keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The band matrix’s DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says. The label column is sized once for the longest mode it can show, so the matrix never shifts as the rotation comes round to RTTY.",
|
||||||
|
"Icom console: LSB and USB are separate buttons and can finally be commanded by name — the single SSB button resolved the sideband from the band, so there was no way to ask an IC-7300 for USB on 40 m. A rig reporting the folded “SSB” still lights the side its frequency implies.",
|
||||||
|
"Icom console: a 60 m band button, and the antenna and PSK controls only appear on radios that have them. An IC-7300 has one antenna socket and no native PSK mode, so ANT1/ANT2 could only ever disagree with its front panel and the PSK button was dead furniture.",
|
||||||
|
"Icom console: the mic gain is no longer hidden outside phone modes — on USB-D it still sets what the radio transmits at, and an operator who lives in FT8 had none at all.",
|
||||||
|
"Icom CI-V: address 0x00 can be chosen. It was silently refused and replaced by the IC-7610 default, with no way to say what was meant."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce qu’elle dit. La colonne des libellés est dimensionnée une fois pour le plus long mode qu’elle peut afficher : la matrice ne bouge donc plus quand la rotation arrive sur RTTY.",
|
||||||
|
"Console Icom : LSB et USB sont deux boutons distincts et peuvent enfin être demandés par leur nom — le bouton SSB unique déduisait la bande latérale de la fréquence, impossible donc de demander l’USB à un IC-7300 sur 40 m. Une radio qui annonce le « SSB » générique allume malgré tout le côté que sa fréquence implique.",
|
||||||
|
"Console Icom : un bouton de bande 60 m, et les commandes antenne et PSK n’apparaissent que sur les radios qui en disposent. Un IC-7300 n’a qu’une prise d’antenne et pas de mode PSK natif : ANT1/ANT2 ne pouvait que contredire sa face avant, et le bouton PSK était un meuble mort.",
|
||||||
|
"Console Icom : le gain micro n’est plus masqué hors des modes phonie — en USB-D il règle toujours le niveau d’émission, et un opérateur qui vit en FT8 n’en avait aucun.",
|
||||||
|
"CI-V Icom : l’adresse 0x00 peut être choisie. Elle était refusée en silence et remplacée par le défaut IC-7610, sans moyen de dire ce que l’on voulait."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
|
||||||
|
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
|
||||||
|
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
|
||||||
|
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
|
||||||
|
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbook’s QSOs that leaves, the file size, and HamQTH’s own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
|
||||||
|
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
|
||||||
|
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
|
||||||
|
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
|
||||||
|
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
|
||||||
|
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunter’s ideas will land beside them.",
|
||||||
|
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
|
||||||
|
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
|
||||||
|
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
|
||||||
|
"Panadapter spots now carry SmartSDR’s priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
|
||||||
|
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen d’y revenir.",
|
||||||
|
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu d’une requête par QSO, une première synchro passe de près d’une heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site n’a pas d’envoi partiel) : il demande donc confirmation, se limite à l’indicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
|
||||||
|
"ADIF sortant (transfert d’un QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. L’import le faisait déjà, pas les chemins de log — ce qu’un QSO transportait dépendait donc de la porte par laquelle il était entré.",
|
||||||
|
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date d’envoi), le filtre de QSO et l’édition groupée — cette dernière pour qu’un log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à l’envoi.",
|
||||||
|
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — l’indicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
|
||||||
|
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande n’est touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne l’efface jamais.",
|
||||||
|
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
|
||||||
|
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien qu’une entité contactée sur une bande affichait NEW BAND comme si elle ne l’avait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il n’y en a plus qu’une.",
|
||||||
|
"Les cases Chasse (POTA, comtés US, préfixes, locators) s’appliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts d’entité : NOUVEAU DXCC, bande, mode, slot.",
|
||||||
|
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom d’un seul ; d’autres idées de DXHunter viendront s’y ajouter.",
|
||||||
|
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
|
||||||
|
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici l’instant où l’onglet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
|
||||||
|
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même d’avoir entendu quoi que ce soit.",
|
||||||
|
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée d’abord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et n’en dessine qu’un — elle choisit par priorité, si bien qu’un nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog d’avoir dit laquelle méritait la place.",
|
||||||
|
"Une station déjà contactée sur la MÊME bande et le même mode n’annonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de l’entité, là où le besoin peut réellement être comblé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
|
||||||
|
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
|
||||||
|
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
|
||||||
|
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
|
||||||
|
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
|
||||||
|
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).",
|
||||||
|
"New DXpeditions tab (Tools): the announced operations from NG3K’s ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
|
||||||
|
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
|
||||||
|
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
|
||||||
|
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
|
||||||
|
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
|
||||||
|
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and − buttons keep the zoom.",
|
||||||
|
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
|
||||||
|
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
|
||||||
|
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
|
||||||
|
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté.",
|
||||||
|
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
|
||||||
|
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online).",
|
||||||
|
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par l’ADXO de NG3K à côté du fil d’actualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce qu’il me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
|
||||||
|
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu d’un message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou l’onglet DXpéditions, où ce message n’était jamais vu.",
|
||||||
|
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
|
||||||
|
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
|
||||||
|
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé d’une session à l’autre.",
|
||||||
|
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et − gardent le zoom.",
|
||||||
|
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur l’une connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.5",
|
"version": "0.27.5",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||||
|
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||||
|
//
|
||||||
|
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||||
|
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||||
|
// reason for enabling its country file.
|
||||||
|
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||||
|
dir := filepath.Join("build", "bin", "data")
|
||||||
|
dm := dxcc.NewManager(dir)
|
||||||
|
if err := dm.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
cm := clublog.NewManager("", dir)
|
||||||
|
if err := cm.EnsureLoaded(); err != nil {
|
||||||
|
t.Skipf("ClubLog country file not available here: %v", err)
|
||||||
|
}
|
||||||
|
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||||
|
|
||||||
|
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
want int // ADIF entity
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"ZK2KK", 188, "Niue — the reported case"},
|
||||||
|
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||||
|
if !a.applyClublogException(&q, true) {
|
||||||
|
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.DXCC == nil || *q.DXCC != c.want {
|
||||||
|
got := 0
|
||||||
|
if q.DXCC != nil {
|
||||||
|
got = *q.DXCC
|
||||||
|
}
|
||||||
|
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||||
|
// rather than blanked: silence is not an answer.
|
||||||
|
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||||
|
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||||
|
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Confirmations default of "R" means "I intend to upload this", not "it has
|
||||||
|
// gone" — reading it as sent would silently disable the auto-upload the default
|
||||||
|
// was set to arm. Only a real stamp blocks.
|
||||||
|
func TestExtrasSaysSent(t *testing.T) {
|
||||||
|
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
|
||||||
|
for _, v := range pending {
|
||||||
|
if extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
|
||||||
|
for _, v := range sent {
|
||||||
|
if !extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HamQTH default lands on the extras key the uploader reads, and must leave
|
||||||
|
// the QSO eligible.
|
||||||
|
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
|
||||||
|
q := &qso.QSO{Callsign: "F4BPO"}
|
||||||
|
applyQSLDefaultsTo(q, defaultQSLDefaults())
|
||||||
|
if got := q.Extras[hamqthSentKey]; got != "R" {
|
||||||
|
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
|
||||||
|
}
|
||||||
|
if extrasSaysSent(q.Extras[hamqthSentKey]) {
|
||||||
|
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
-94
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||||
GetScpStatus, ScpLookup,
|
GetScpStatus, ScpLookup,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
@@ -59,6 +59,8 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||||
import { formatDistance } from '@/lib/units';
|
import { formatDistance } from '@/lib/units';
|
||||||
|
import { WIDGET_KEYS } from '@/components/AppearancePanel';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -76,6 +78,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
|||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
|
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -100,7 +103,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
|||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
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 { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, chaseState, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
|
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||||
import { applyMatrixColors } from '@/lib/matrixColors';
|
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
@@ -431,21 +434,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bandForMHz(mhz: number): string {
|
|
||||||
if (!mhz || isNaN(mhz)) return '';
|
|
||||||
const plan: [number, number, string][] = [
|
|
||||||
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
|
||||||
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
|
||||||
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
|
||||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
|
||||||
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
|
|
||||||
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
|
||||||
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
|
||||||
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
|
||||||
];
|
|
||||||
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
||||||
// CW gold, phone green, digital blue, unknown muted.
|
// CW gold, phone green, digital blue, unknown muted.
|
||||||
@@ -774,6 +762,32 @@ export default function App() {
|
|||||||
// Several amps side by side make a wide widget, so an operator running two
|
// Several amps side by side make a wide widget, so an operator running two
|
||||||
// picks the one he watches while transmitting. Declared here because the poll
|
// picks the one he watches while transmitting. Declared here because the poll
|
||||||
// below runs faster while the widget is open.
|
// below runs faster while the widget is open.
|
||||||
|
// Widget order (Settings → Appearance). The row is a flex container, so the
|
||||||
|
// ORDER property moves a widget without touching the tree: every condition,
|
||||||
|
// every ref and every hook stays where it was, and a widget that is switched
|
||||||
|
// off simply is not there to be ordered.
|
||||||
|
//
|
||||||
|
// QSO entry and the F1-F5 panel are not in this list on purpose — they are
|
||||||
|
// not in this row at all, and an operator cannot be allowed to push the thing
|
||||||
|
// they type into behind a rotator dial.
|
||||||
|
const [widgetOrder, setWidgetOrder] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
|
||||||
|
} catch { /* corrupt pref → the default order */ }
|
||||||
|
return [...WIDGET_KEYS];
|
||||||
|
});
|
||||||
|
useEffect(() => EventsOn('widgets:order', (keys: any) => {
|
||||||
|
if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string'));
|
||||||
|
}), []);
|
||||||
|
// A key the saved order has never heard of (a widget added since) goes to the
|
||||||
|
// end rather than to the front, where it would jump the queue on every
|
||||||
|
// upgrade.
|
||||||
|
const wOrder = (k: string) => {
|
||||||
|
const i = widgetOrder.indexOf(k);
|
||||||
|
return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i;
|
||||||
|
};
|
||||||
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||||
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||||
// Poll fast only while the amplifier widget is open: its meters must track TX
|
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||||
@@ -1315,6 +1329,17 @@ export default function App() {
|
|||||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||||
|
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
||||||
|
function openDxpedTab() {
|
||||||
|
setDxpedTabOpen(true);
|
||||||
|
writeUiPref('opslog.dxpedTab', '1');
|
||||||
|
setActiveTab('dxped');
|
||||||
|
}
|
||||||
|
function closeDxpedTab() {
|
||||||
|
setDxpedTabOpen(false);
|
||||||
|
writeUiPref('opslog.dxpedTab', '0');
|
||||||
|
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
|
||||||
|
}
|
||||||
function openFtmapTab() {
|
function openFtmapTab() {
|
||||||
setFtmapTabOpen(true);
|
setFtmapTabOpen(true);
|
||||||
writeUiPref('opslog.ftmapTab', '1');
|
writeUiPref('opslog.ftmapTab', '1');
|
||||||
@@ -2100,7 +2125,41 @@ export default function App() {
|
|||||||
// worked_slot must be carried explicitly like every other field: this map is
|
// worked_slot must be carried explicitly like every other field: this map is
|
||||||
// assembled field by field, so a backend flag that nobody copies here simply
|
// assembled field by field, so a backend flag that nobody copies here simply
|
||||||
// never reaches the panels — silently, since the extra key is just dropped.
|
// never reaches the panels — silently, since the extra key is just dropped.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
// spotStatusEntry maps ONE backend verdict onto the entry the panels read.
|
||||||
|
//
|
||||||
|
// There were four of these, written by hand at each call site, and they had
|
||||||
|
// drifted apart: the two that RE-fetch — after a QSO is logged, and when a
|
||||||
|
// pane becomes visible — rebuilt every entry without the unconfirmed flags.
|
||||||
|
// So a badge drawn correctly as "worked, QSL missing" turned solid at the
|
||||||
|
// next refresh and stayed that way, which is how South Africa came to read
|
||||||
|
// NEW BAND on a band with four contacts in the log. grid_state was never
|
||||||
|
// copied by any of them, so a known-but-unconfirmed square never dimmed at
|
||||||
|
// all. One function now, so a field added to the backend cannot reach three
|
||||||
|
// callers and miss the fourth.
|
||||||
|
const spotStatusEntry = (r: any) => ({
|
||||||
|
status: r.status ?? '',
|
||||||
|
country: r.country,
|
||||||
|
continent: r.continent,
|
||||||
|
worked_call: !!r.worked_call,
|
||||||
|
worked_slot: !!r.worked_slot,
|
||||||
|
new_county: !!r.new_county,
|
||||||
|
lotw: !!r.lotw,
|
||||||
|
spotter_continent: r.spotter_continent,
|
||||||
|
grid: r.grid,
|
||||||
|
grid_state: r.grid_state,
|
||||||
|
new_grid: !!r.new_grid,
|
||||||
|
county: r.county,
|
||||||
|
state: r.state,
|
||||||
|
new_state: !!r.new_state,
|
||||||
|
new_pota: !!r.new_pota,
|
||||||
|
new_pfx: !!r.new_pfx,
|
||||||
|
pfx: r.pfx,
|
||||||
|
unconf_status: !!r.unconf_status,
|
||||||
|
unconf_pfx: !!r.unconf_pfx,
|
||||||
|
unconf_cty: !!r.unconf_cty,
|
||||||
|
unconf_state: !!r.unconf_state,
|
||||||
|
});
|
||||||
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; grid_state?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
// still need resolving without re-subscribing the cluster:spot listener.
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
const spotStatusRef = useRef(spotStatus);
|
const spotStatusRef = useRef(spotStatus);
|
||||||
@@ -2173,11 +2232,7 @@ export default function App() {
|
|||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const r of res) {
|
for (const r of res) {
|
||||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
next[k] = {
|
next[k] = spotStatusEntry(r);
|
||||||
status: r.status ?? '', country: r.country, continent: (r as any).continent,
|
|
||||||
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
|
|
||||||
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -2346,6 +2401,18 @@ export default function App() {
|
|||||||
}, [showSettings]);
|
}, [showSettings]);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
|
// Watchlist membership changes announce themselves here rather than inside
|
||||||
|
// the watchlist panel: a call can be added from the cluster or the
|
||||||
|
// DXpeditions tab, and the old inline message lived on a page nobody was
|
||||||
|
// looking at.
|
||||||
|
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
||||||
|
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
||||||
|
if (!e?.call) return;
|
||||||
|
setWlNotice({ call: String(e.call), added: !!e.added });
|
||||||
|
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||||
|
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||||
|
}), []);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
// Fresh update check on demand (opening About), so it never shows a stale
|
// Fresh update check on demand (opening About), so it never shows a stale
|
||||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||||
@@ -3644,22 +3711,7 @@ export default function App() {
|
|||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const r of res) {
|
for (const r of res) {
|
||||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
next[k] = {
|
next[k] = spotStatusEntry(r);
|
||||||
status: r.status ?? '',
|
|
||||||
country: r.country,
|
|
||||||
continent: (r as any).continent,
|
|
||||||
worked_call: !!(r as any).worked_call,
|
|
||||||
worked_slot: !!(r as any).worked_slot,
|
|
||||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
|
|
||||||
new_state: !!(r as any).new_state,
|
|
||||||
unconf_status: !!(r as any).unconf_status,
|
|
||||||
unconf_pfx: !!(r as any).unconf_pfx,
|
|
||||||
unconf_cty: !!(r as any).unconf_cty,
|
|
||||||
unconf_state: !!(r as any).unconf_state,
|
|
||||||
new_pota: !!(r as any).new_pota,
|
|
||||||
new_pfx: !!(r as any).new_pfx,
|
|
||||||
pfx: (r as any).pfx,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -3753,23 +3805,7 @@ export default function App() {
|
|||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const r of res) {
|
for (const r of res) {
|
||||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
next[k] = {
|
next[k] = spotStatusEntry(r);
|
||||||
status: r.status ?? '',
|
|
||||||
country: r.country,
|
|
||||||
continent: (r as any).continent,
|
|
||||||
worked_call: !!(r as any).worked_call,
|
|
||||||
worked_slot: !!(r as any).worked_slot,
|
|
||||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
|
|
||||||
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
|
|
||||||
county: (r as any).county, state: (r as any).state,
|
|
||||||
new_state: !!(r as any).new_state,
|
|
||||||
unconf_status: !!(r as any).unconf_status,
|
|
||||||
unconf_pfx: !!(r as any).unconf_pfx,
|
|
||||||
unconf_cty: !!(r as any).unconf_cty,
|
|
||||||
unconf_state: !!(r as any).unconf_state,
|
|
||||||
new_pota: !!(r as any).new_pota,
|
|
||||||
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -4267,17 +4303,7 @@ export default function App() {
|
|||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const r of res) {
|
for (const r of res) {
|
||||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
next[k] = {
|
next[k] = spotStatusEntry(r);
|
||||||
status: r.status ?? '',
|
|
||||||
country: r.country,
|
|
||||||
continent: (r as any).continent,
|
|
||||||
worked_call: !!(r as any).worked_call,
|
|
||||||
worked_slot: !!(r as any).worked_slot,
|
|
||||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
|
|
||||||
new_pota: !!(r as any).new_pota,
|
|
||||||
new_pfx: !!(r as any).new_pfx,
|
|
||||||
pfx: (r as any).pfx,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -5126,6 +5152,7 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
|
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
@@ -5180,6 +5207,7 @@ export default function App() {
|
|||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
|
case 'tools.dxped': openDxpedTab(); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
case 'tools.ftmap': openFtmapTab(); break;
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
@@ -6261,7 +6289,11 @@ export default function App() {
|
|||||||
// worked spots; the separate "Hide worked" checkbox drops them — they
|
// worked spots; the separate "Hide worked" checkbox drops them — they
|
||||||
// are opposite controls, so don't use both at once.
|
// are opposite controls, so don't use both at once.
|
||||||
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||||
]).filter((s: any) => s.k !== 'new-pota' || chasePota())
|
]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
|
||||||
|
&& (s.k !== 'new-county' || chaseCounty())
|
||||||
|
&& (s.k !== 'new-pfx' || chasePfx())
|
||||||
|
&& (s.k !== 'new-grid' || chaseGrid())
|
||||||
|
&& (s.k !== 'new-state' || chaseState()))
|
||||||
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
||||||
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
|
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
|
||||||
</div>,
|
</div>,
|
||||||
@@ -6323,6 +6355,9 @@ export default function App() {
|
|||||||
txState={txState}
|
txState={txState}
|
||||||
txStates={txStates}
|
txStates={txStates}
|
||||||
spotStatus={spotStatus as any}
|
spotStatus={spotStatus as any}
|
||||||
|
// Only while CAT is actually connected: an empty band means "nothing to
|
||||||
|
// compare with", never "the rig is on no band".
|
||||||
|
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||||
myCall={station.callsign}
|
myCall={station.callsign}
|
||||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||||
// a Reply, which is the same thing as double-clicking the line in their
|
// a Reply, which is the same thing as double-clicking the line in their
|
||||||
@@ -7073,6 +7108,26 @@ export default function App() {
|
|||||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{wlNotice && (
|
||||||
|
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
|
||||||
|
wlNotice.added ? 'border-warning/40' : 'border-border',
|
||||||
|
// Stacked above the update card when both are up.
|
||||||
|
updateInfo ? 'bottom-44' : 'bottom-4')}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setWlNotice(null)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -7386,6 +7441,7 @@ export default function App() {
|
|||||||
band={band}
|
band={band}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
satellites={satellites}
|
satellites={satellites}
|
||||||
onEditQso={openEdit}
|
onEditQso={openEdit}
|
||||||
{...(!callsign.trim() && selQso ? {
|
{...(!callsign.trim() && selQso ? {
|
||||||
@@ -7411,7 +7467,7 @@ export default function App() {
|
|||||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||||
their freq/mode (colour-coded) and OpsLog version. */}
|
their freq/mode (colour-coded) and OpsLog version. */}
|
||||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||||
<div className="w-[248px] shrink-0 min-h-0 relative">
|
<div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
|
||||||
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
||||||
<Radio className="size-3.5 text-primary" />
|
<Radio className="size-3.5 text-primary" />
|
||||||
@@ -7455,7 +7511,7 @@ export default function App() {
|
|||||||
// relative + absolute inner: the chat takes the row height (set by the
|
// relative + absolute inner: the chat takes the row height (set by the
|
||||||
// entry strip) WITHOUT its message list growing the row, like the
|
// entry strip) WITHOUT its message list growing the row, like the
|
||||||
// Stats panel. The list scrolls inside this fixed height.
|
// Stats panel. The list scrolls inside this fixed height.
|
||||||
<div className="w-[280px] shrink-0 min-h-0 relative">
|
<div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
|
||||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||||
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
||||||
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
||||||
@@ -7467,7 +7523,7 @@ export default function App() {
|
|||||||
controls column, so the widget is just the dial and needs only its
|
controls column, so the widget is just the dial and needs only its
|
||||||
width. */}
|
width. */}
|
||||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
|
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
||||||
<RotorCompass
|
<RotorCompass
|
||||||
presets={rotorCompact ? undefined : rotorPresets}
|
presets={rotorCompact ? undefined : rotorPresets}
|
||||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
@@ -7487,7 +7543,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMotorAnt && ubStatus.enabled && (
|
{showMotorAnt && ubStatus.enabled && (
|
||||||
<div className="w-[230px] shrink-0 min-h-0">
|
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
|
||||||
<MotorAntennaWidget
|
<MotorAntennaWidget
|
||||||
ant={ubStatus}
|
ant={ubStatus}
|
||||||
refetch={pokeUbStatus}
|
refetch={pokeUbStatus}
|
||||||
@@ -7498,7 +7554,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAntGenius && agEnabled && (
|
{showAntGenius && agEnabled && (
|
||||||
<div className="w-[230px] shrink-0 min-h-0">
|
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
|
||||||
<AntGeniusPanel
|
<AntGeniusPanel
|
||||||
status={agStatus}
|
status={agStatus}
|
||||||
onActivate={agActivate}
|
onActivate={agActivate}
|
||||||
@@ -7511,7 +7567,7 @@ export default function App() {
|
|||||||
// One column per amplifier shown, so two amps stand side by side
|
// One column per amplifier shown, so two amps stand side by side
|
||||||
// rather than making the widget twice as tall as the dock row.
|
// rather than making the widget twice as tall as the dock row.
|
||||||
<div className="shrink-0 min-h-0"
|
<div className="shrink-0 min-h-0"
|
||||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
|
||||||
<AmpWidget
|
<AmpWidget
|
||||||
amps={ampSts}
|
amps={ampSts}
|
||||||
flex={flexAmp}
|
flex={flexAmp}
|
||||||
@@ -7521,7 +7577,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showTuner && tgEnabled && (
|
{showTuner && tgEnabled && (
|
||||||
<div className="w-[230px] shrink-0 min-h-0">
|
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
|
||||||
<TunerGeniusPanel
|
<TunerGeniusPanel
|
||||||
status={tgStatus}
|
status={tgStatus}
|
||||||
onTune={tgTune}
|
onTune={tgTune}
|
||||||
@@ -7533,7 +7589,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showScp && scpEnabled && (
|
{showScp && scpEnabled && (
|
||||||
<div className="w-[240px] shrink-0 min-h-0">
|
<div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
|
||||||
<ScpPanel
|
<ScpPanel
|
||||||
result={scpResult}
|
result={scpResult}
|
||||||
currentCall={callsign}
|
currentCall={callsign}
|
||||||
@@ -7544,20 +7600,35 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chaseNewOn && showChaseNew && (
|
{chaseNewOn && showChaseNew && (
|
||||||
<div className="w-[420px] shrink-0 min-h-0">
|
<div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
|
||||||
{/* Same reflex as clicking a cluster spot: the callsign into the
|
{/* Same reflex as clicking a cluster spot: the callsign into the
|
||||||
entry, and the rig onto the frequency it was decoded on. */}
|
entry, and the rig onto the frequency it was decoded on. */}
|
||||||
<ChaseNewPanel onPick={(sp) => {
|
<ChaseNewPanel onPick={(sp) => {
|
||||||
onCallsignInput(sp.call, { force: true });
|
// A row here is a spot like any other, so it goes through the
|
||||||
// And the panadapter width too — after the tune settles, for the
|
// SAME handler as one clicked in the cluster: the rig tunes, the
|
||||||
// same SmartSDR pan-follow reason as handleSpotClick.
|
// panadapter follows, the entry takes the mode and its RST
|
||||||
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode).then(() =>
|
// preset, the park and summit references come across — and the
|
||||||
window.setTimeout(() => FlexZoomForSpot(sp.mode ?? '', sp.freq_hz ?? 0).catch(() => {}), 300));
|
// decoding application is told to change mode, which is what an
|
||||||
|
// operator clicking an FT4 row from an FT8 slot is asking for.
|
||||||
|
//
|
||||||
|
// It used to do a hand-picked half of that, so the one thing
|
||||||
|
// Chase new exists for — jumping onto a station — left WSJT-X
|
||||||
|
// decoding the wrong mode.
|
||||||
|
handleSpotClick({
|
||||||
|
dx_call: sp.call,
|
||||||
|
freq_hz: sp.freq_hz ?? 0,
|
||||||
|
band: sp.band,
|
||||||
|
// handleSpotClick infers the mode from the comment the way a
|
||||||
|
// cluster line carries it; the mode is what we have, so it is
|
||||||
|
// what we hand over.
|
||||||
|
comment: sp.mode ?? '',
|
||||||
|
spotter: '',
|
||||||
|
});
|
||||||
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
|
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
<div className="w-[320px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
|
||||||
<DvkPanel
|
<DvkPanel
|
||||||
messages={dvkMsgs}
|
messages={dvkMsgs}
|
||||||
status={dvkStat}
|
status={dvkStat}
|
||||||
@@ -7573,7 +7644,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{wkEnabled && (
|
{wkEnabled && (
|
||||||
<div className="w-[380px] shrink-0 min-h-0">
|
<div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
|
||||||
<WinkeyerPanel
|
<WinkeyerPanel
|
||||||
// A rig keyer has no serial status of its own: it is connected
|
// A rig keyer has no serial status of its own: it is connected
|
||||||
// exactly when its CAT backend is. Yaesu was missing from this
|
// exactly when its CAT backend is. Yaesu was missing from this
|
||||||
@@ -7617,7 +7688,7 @@ export default function App() {
|
|||||||
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
||||||
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
||||||
{lookupResult?.image_url && (
|
{lookupResult?.image_url && (
|
||||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
|
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||||
@@ -7813,6 +7884,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{dxpedTabOpen && (
|
||||||
|
<TabsTrigger value="dxped" className="gap-1.5">
|
||||||
|
{t('dxp.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close DXpeditions"
|
||||||
|
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(); closeDxpedTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||||
{t('ftmap.tab')}
|
{t('ftmap.tab')}
|
||||||
@@ -8060,31 +8146,57 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Disconnect all
|
Disconnect all
|
||||||
</Button>
|
</Button>
|
||||||
{clusterServerStatuses.length === 0 && (
|
{clusterServers.filter((x) => x.enabled).length === 0 && (
|
||||||
<span className="text-xs text-muted-foreground italic">
|
<span className="text-xs text-muted-foreground italic">
|
||||||
No active sessions — configure clusters in Settings → DX Cluster.
|
No active sessions — configure clusters in Settings → DX Cluster.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{clusterServerStatuses.map((s) => {
|
{clusterServers
|
||||||
|
.filter((x) => x.enabled)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((srv) => {
|
||||||
|
// A disconnected server has no session to report, so it has no
|
||||||
|
// entry in the status list at all — the pill stands in for it
|
||||||
|
// as "disconnected", which is exactly the state you click to
|
||||||
|
// undo.
|
||||||
|
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
|
||||||
|
const s: ServerStatus = live ?? {
|
||||||
|
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
|
||||||
|
};
|
||||||
const isMaster = clusterServers
|
const isMaster = clusterServers
|
||||||
.filter((x) => x.enabled)
|
.filter((x) => x.enabled)
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
||||||
|
// The colour already says the state, so the word beside it was
|
||||||
|
// saying it twice — and the pills are the only place a single
|
||||||
|
// cluster can be reached without opening Settings. Clicking one
|
||||||
|
// now drops or reopens that session; everything the word used
|
||||||
|
// to carry (state, retries, last error, address) moves into the
|
||||||
|
// tooltip, where it costs no width.
|
||||||
|
const up = s.state === 'connected';
|
||||||
|
const busy = s.state === 'connecting' || s.state === 'reconnecting';
|
||||||
return (
|
return (
|
||||||
<span
|
<button
|
||||||
key={s.server_id}
|
key={s.server_id}
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (up || busy) await DisconnectClusterServer(s.server_id);
|
||||||
|
else await ConnectClusterServer(s.server_id);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
await reloadClusterMeta();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-opacity hover:opacity-80',
|
||||||
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
||||||
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
busy ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
||||||
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||||||
'bg-muted text-muted-foreground border-border',
|
'bg-muted text-muted-foreground border-border',
|
||||||
)}
|
)}
|
||||||
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
|
title={`${s.name} — ${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
|
||||||
>
|
>
|
||||||
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
||||||
{s.name}
|
{s.name}
|
||||||
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
|
</button>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -8426,6 +8538,15 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dxpedTabOpen && (
|
||||||
|
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{activeTab === 'dxped' && (
|
||||||
|
<div className="h-full w-full min-h-0 p-1">
|
||||||
|
<DXpeditionsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
{activeTab === 'ftmap' && (
|
{activeTab === 'ftmap' && (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { GripVertical, Lock } from 'lucide-react';
|
||||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -290,6 +292,116 @@ export function AppearancePanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MatrixColorsSection />
|
<MatrixColorsSection />
|
||||||
|
<WidgetOrderSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row of widgets to the right of the entry, in the order they appear.
|
||||||
|
//
|
||||||
|
// Flexbox does the moving in the main view — this list only decides the order
|
||||||
|
// property each one gets. That is why a widget switched OFF still holds its
|
||||||
|
// place here: it comes back where the operator left it rather than at the end.
|
||||||
|
export const WIDGET_KEYS = [
|
||||||
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||||
|
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WIDGET_LABELS: Record<string, string> = {
|
||||||
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||||
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||||
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
|
||||||
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readWidgetOrder(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
// A key from an older build that no longer exists is dropped; a widget
|
||||||
|
// added since joins the end. An old preference can never hide a new one.
|
||||||
|
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||||
|
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||||
|
}
|
||||||
|
} catch { /* corrupt pref → the default order */ }
|
||||||
|
return [...WIDGET_KEYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetOrderSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||||
|
const dragKey = useRef<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<string | null>(null);
|
||||||
|
// Where the row would land. Drawn as a line above the target rather than by
|
||||||
|
// colouring it: the question a dragging hand asks is "between which two", and
|
||||||
|
// a highlighted row answers a different one.
|
||||||
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commit = (keys: string[]) => {
|
||||||
|
setOrder(keys);
|
||||||
|
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||||
|
// The main view listens: an order is meant to be watched as it is dragged,
|
||||||
|
// not discovered after closing Preferences.
|
||||||
|
EventsEmit('widgets:order', keys);
|
||||||
|
};
|
||||||
|
const moveTo = (from: string, to: string) => {
|
||||||
|
if (from === to) return;
|
||||||
|
const next = order.filter((k) => k !== from);
|
||||||
|
const at = next.indexOf(to);
|
||||||
|
next.splice(at < 0 ? next.length : at, 0, from);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
{/* The two that cannot move, shown so the order reads as the whole row
|
||||||
|
rather than as a list that mysteriously starts at the third item. */}
|
||||||
|
{['wo.entry', 'wo.details'].map((k) => (
|
||||||
|
<div key={k}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.map((k) => (
|
||||||
|
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||||
|
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||||
|
// cannot be moved. The grip stays as the sign that it can.
|
||||||
|
<div key={k} draggable
|
||||||
|
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (over !== k) setOver(k);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveTo(dragKey.current, k);
|
||||||
|
setOver(null);
|
||||||
|
}}
|
||||||
|
title={t('wo.drag')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||||
|
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||||
|
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||||
|
// The landing line, on the edge the row would take.
|
||||||
|
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||||
|
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||||
|
{t('wo.reset')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
@@ -283,6 +283,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
const range = bandRange(band);
|
const range = bandRange(band);
|
||||||
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The legend is a reference, not a running display: once its colours are
|
||||||
|
// learnt it is four lines of a short screen spent saying nothing new. Folded
|
||||||
|
// away by a toggle, and the choice is remembered.
|
||||||
|
const [legendOpen, setLegendOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
|
||||||
|
});
|
||||||
|
const toggleLegend = () => setLegendOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
@@ -460,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||||
const el = scrollerRef.current;
|
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||||
if (!el) return;
|
// app over one panel and one band map over another is a gesture nobody can
|
||||||
const onWheel = (e: WheelEvent) => {
|
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||||
if (!range) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
changeZoom(e.deltaY > 0 ? -1 : 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
@@ -753,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
|
{legendOpen && (
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
@@ -769,9 +772,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
)}
|
||||||
|
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
|
||||||
|
<span className="flex-1 text-center">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={toggleLegend}
|
||||||
|
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
|
||||||
|
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[];
|
||||||
|
// The operator's configured mode list, in THEIR order: the digital row
|
||||||
|
// rotates through it.
|
||||||
|
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
// to an entity with no position at all, and the block simply does not appear.
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
@@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status:
|
|||||||
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Cell drill-down: which band+class the operator clicked, or null.
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
|
|
||||||
|
// The DIGITAL row is a rotation, not a fixed row.
|
||||||
|
//
|
||||||
|
// One row for every digital mode would be the honest layout and there is no
|
||||||
|
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
|
||||||
|
// So the row keeps its place and changes what it answers: DIG (all of them),
|
||||||
|
// then each digital mode the operator actually uses, in the order their mode
|
||||||
|
// list gives, then back to DIG. The backend publishes the same cells under
|
||||||
|
// both the class name and the raw mode, so a rotation costs no round trip.
|
||||||
|
const digModes = useMemo(
|
||||||
|
() => (modes ?? [])
|
||||||
|
.map((m) => (m || '').toUpperCase().trim())
|
||||||
|
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
||||||
|
[modes],
|
||||||
|
);
|
||||||
|
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
|
||||||
|
// A shorter mode list (the operator edited it) must not strand the rotation
|
||||||
|
// on a row that no longer exists.
|
||||||
|
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
||||||
|
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
|
||||||
|
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -310,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[26px]" />
|
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
|
||||||
{cols.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
@@ -325,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CLASSES.map((cls) => {
|
{CLASSES.map((clsBase) => {
|
||||||
const classCurrent = classMatchesMode(cls, currentMode);
|
const cls = clsBase === 'DIG' ? digRow : clsBase;
|
||||||
|
// On a specific digital mode the "you are here" mark has to be that
|
||||||
|
// mode, not any digital one — otherwise every FT4 entry lights the
|
||||||
|
// FT8 row it happens to be cycled to.
|
||||||
|
const classCurrent = cls === clsBase
|
||||||
|
? classMatchesMode(cls, currentMode)
|
||||||
|
: (currentMode || '').toUpperCase() === cls;
|
||||||
return (
|
return (
|
||||||
<tr key={cls}>
|
<tr key={cls}>
|
||||||
<th
|
<th
|
||||||
|
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
|
||||||
|
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
// Sized once for the LONGEST label the rotation can show,
|
||||||
|
// and pinned there: a column that grows when RTTY comes
|
||||||
|
// round shifts every band beneath it, and the eye reads
|
||||||
|
// that as the matrix moving rather than the row changing.
|
||||||
|
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
|
||||||
|
// Beyond four characters (PSK31, MSK144) the type gives way
|
||||||
|
// instead of the column.
|
||||||
|
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
|
||||||
|
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
|
||||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Radar, Loader2, X } from 'lucide-react';
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
import { formatDistance } from '@/lib/units';
|
import { formatDistance } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||||
@@ -60,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// categoryOf is the single thing a row says about a station.
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
//
|
||||||
|
// A category the operator does not chase is not a category here either: with
|
||||||
|
// prefixes and squares switched off this panel showed rows whose only reason
|
||||||
|
// for being listed had been withdrawn everywhere else.
|
||||||
function categoryOf(s: ChaseNewSpot): Category | null {
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
switch (s.status) {
|
switch (s.status) {
|
||||||
case 'new': return 'dxcc';
|
case 'new': return 'dxcc';
|
||||||
@@ -68,8 +73,8 @@ function categoryOf(s: ChaseNewSpot): Category | null {
|
|||||||
case 'new-mode': return 'mode';
|
case 'new-mode': return 'mode';
|
||||||
case 'new-slot': return 'slot';
|
case 'new-slot': return 'slot';
|
||||||
}
|
}
|
||||||
if (s.new_pfx) return 'pfx';
|
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
|
||||||
if (s.new_grid) return 'grid';
|
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ function loadFilters(): Set<Category> {
|
|||||||
if (Array.isArray(list)) return new Set(list);
|
if (Array.isArray(list)) return new Set(list);
|
||||||
}
|
}
|
||||||
} catch { /* a corrupt preference is not worth a broken panel */ }
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
return new Set(CATEGORIES.map((c) => c.key));
|
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
@@ -132,7 +137,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
|
|
||||||
{/* Filters, in the same order and colours as the badges they hide. */}
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-1">
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.key}
|
key={c.key}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||||
|
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||||
|
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||||
|
//
|
||||||
|
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||||
|
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||||
|
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||||
|
// be watched with the same one click.
|
||||||
|
|
||||||
|
type DXped = {
|
||||||
|
dxcc: string; callsign: string; calls?: string[];
|
||||||
|
start_date: string; end_date: string;
|
||||||
|
bands?: string[]; modes?: string[];
|
||||||
|
qsl: string; operators: string; source: string; link: string;
|
||||||
|
status: string; // active | upcoming
|
||||||
|
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||||
|
unconfirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type News = {
|
||||||
|
title: string; link: string; pub_date: string; excerpt: string;
|
||||||
|
creator: string; image_url: string; tag: string; calls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge per expedition, the strongest verdict winning — the same palette
|
||||||
|
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||||
|
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||||
|
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DXpeditionsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [peds, setPeds] = useState<DXped[]>([]);
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||||
|
|
||||||
|
const loadWatchlist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const e: any[] = await WatchlistEntries();
|
||||||
|
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||||
|
} catch { /* the list simply shows every call as unwatched */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErr('');
|
||||||
|
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||||
|
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||||
|
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||||
|
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||||
|
setBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||||
|
|
||||||
|
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||||
|
|
||||||
|
const addAll = async (calls: string[]) => {
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!watched.has(c.toUpperCase())) {
|
||||||
|
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadWatchlist();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||||
|
[peds, neededOnly]);
|
||||||
|
|
||||||
|
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||||
|
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 gap-3">
|
||||||
|
{/* ── Announcements (ADXO) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
<input type="checkbox" checked={neededOnly}
|
||||||
|
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||||
|
{t('dxp.neededOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{shown.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((p, i) => {
|
||||||
|
const calls = callsOf(p);
|
||||||
|
const badge = CHASE_BADGE[p.status_chase];
|
||||||
|
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||||
|
className={cn('rounded-md border p-2 text-xs',
|
||||||
|
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||||
|
<span className="font-medium">{p.dxcc}</span>
|
||||||
|
{p.status === 'active' && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||||
|
{t('dxp.onAir')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||||
|
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||||
|
style={p.unconfirmed
|
||||||
|
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||||
|
: { color: badge.colour, borderColor: badge.colour }}>
|
||||||
|
{t(badge.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{p.start_date} → {p.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||||
|
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||||
|
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||||
|
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||||
|
{p.source && <span>· {p.source}</span>}
|
||||||
|
</div>
|
||||||
|
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={calls.length === 0 || allWatched}
|
||||||
|
onClick={() => addAll(calls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||||
|
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{p.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── News (DX-World) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{news.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||||
|
)}
|
||||||
|
{news.map((n, i) => {
|
||||||
|
const newsCalls = n.calls ?? [];
|
||||||
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{n.image_url && (
|
||||||
|
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.tag && (
|
||||||
|
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||||
|
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||||
|
{/* The callsigns are shown, not clicked: watching is the same
|
||||||
|
one button as an announcement, so the gesture is learned
|
||||||
|
once for the whole tab. */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.pub_date && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(n.pub_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(n.calls ?? []).map((c) => (
|
||||||
|
<span key={c} className={cn('font-mono text-[10px]',
|
||||||
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
||||||
|
onClick={() => addAll(newsCalls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
||||||
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{n.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(n.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
// both panels rather than being judged twice by two rules.
|
// both panels rather than being judged twice by two rules.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
@@ -94,6 +95,9 @@ interface Props {
|
|||||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||||
txStates?: Record<string, TxMsg>;
|
txStates?: Record<string, TxMsg>;
|
||||||
spotStatus: Record<string, StatusEntry>;
|
spotStatus: Record<string, StatusEntry>;
|
||||||
|
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||||
|
// the decoder announces — see the drift warning.
|
||||||
|
rigBand?: string;
|
||||||
onCall: (d: Decode) => void;
|
onCall: (d: Decode) => void;
|
||||||
myCall?: string;
|
myCall?: string;
|
||||||
// Drop every decode and transmit message held for this panel. The list is a
|
// Drop every decode and transmit message held for this panel. The list is a
|
||||||
@@ -120,6 +124,9 @@ interface Props {
|
|||||||
// that starts by hiding most of the band would be lying about what is on it.
|
// that starts by hiding most of the band would be lying about what is on it.
|
||||||
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
|
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
|
||||||
|
|
||||||
|
// The seven, in the order an operator reads them.
|
||||||
|
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||||
|
|
||||||
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||||
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
||||||
{ key: 'band', label: 'dec.stBand', colour: 'var(--warning)' },
|
{ key: 'band', label: 'dec.stBand', colour: 'var(--warning)' },
|
||||||
@@ -535,7 +542,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Column widths, dragged in the header and shared by every row. Persisted
|
// Column widths, dragged in the header and shared by every row. Persisted
|
||||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||||
@@ -593,6 +600,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
|
|
||||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||||
// best; between overs the transmit state still does.
|
// best; between overs the transmit state still does.
|
||||||
|
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||||
|
// frequency it knew, and every decode after that carries a stale band. Nothing
|
||||||
|
// downstream can tell: the entity verdicts, the band filter and the FT map all
|
||||||
|
// believe what the decoder said, and an operator ends up reading NEW BAND for a
|
||||||
|
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
|
||||||
|
// Korea showed as a new band because on 80 m it would have been.)
|
||||||
|
//
|
||||||
|
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||||
|
// decoding a second receiver on another band, and a warning costs that setup
|
||||||
|
// nothing but a line it can read past.
|
||||||
|
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||||
|
const bandDrift = !!rigBand && !!decoderBand
|
||||||
|
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||||
|
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||||
|
|
||||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||||
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||||
|
|
||||||
@@ -629,27 +651,25 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// dropdowns were pure furniture for most operators; they appear the day a
|
// dropdowns were pure furniture for most operators; they appear the day a
|
||||||
// second instance puts a second band on the link, which is the only day they
|
// second instance puts a second band on the link, which is the only day they
|
||||||
// mean anything.
|
// mean anything.
|
||||||
const { bands, modes, conts, instances } = useMemo(() => {
|
const { bands, modes, instances } = useMemo(() => {
|
||||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
|
||||||
for (const d of decodes) {
|
for (const d of decodes) {
|
||||||
if (d.band) b.add(d.band);
|
if (d.band) b.add(d.band);
|
||||||
if (d.mode) m.add(d.mode);
|
if (d.mode) m.add(d.mode);
|
||||||
if (d.instance) i.add(d.instance);
|
if (d.instance) i.add(d.instance);
|
||||||
const ct = statusOf(d)?.continent;
|
|
||||||
if (ct) c.add(ct);
|
|
||||||
}
|
}
|
||||||
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
return { bands: [...b].sort(), modes: [...m].sort(), instances: [...i].sort() };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus]);
|
}, [decodes, spotStatus]);
|
||||||
|
|
||||||
// The chips are the continents on the feed — a selector with nothing to choose
|
// All seven, always, in their usual order.
|
||||||
// is furniture — PLUS anything currently selected. Without that second half a
|
//
|
||||||
// filter can strand itself: pick AF, the last African station stops decoding,
|
// The chips used to be built from the continents ON the feed, which read as a
|
||||||
// and the list empties with no chip left to switch it back off.
|
// list that reshuffled itself every period: a chip appeared when the first
|
||||||
const contChips = useMemo(
|
// Asian station decoded and moved everything sideways under the pointer. A
|
||||||
() => [...new Set([...conts, ...contList])].sort(),
|
// fixed row can be aimed at — you learn where OC is and it stays there — and
|
||||||
[conts, contList],
|
// it also says what the filter can do before anything has been heard.
|
||||||
);
|
const contChips = CONTINENTS;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toUpperCase();
|
const q = search.trim().toUpperCase();
|
||||||
@@ -731,6 +751,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
what the transmit state reports, so it is right the moment anything
|
what the transmit state reports, so it is right the moment anything
|
||||||
is heard and keeps running when the band goes quiet. */}
|
is heard and keeps running when the band goes quiet. */}
|
||||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||||
|
{bandDrift && (
|
||||||
|
<span
|
||||||
|
title={t('dec.bandDriftTip')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||||
|
<AlertTriangle className="size-3.5" />
|
||||||
|
{t('dec.bandDrift', {
|
||||||
|
app: driftInstance || t('dec.bandDriftApp'),
|
||||||
|
dec: decoderBand.toUpperCase(),
|
||||||
|
rig: (rigBand ?? '').toUpperCase(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||||
|
|
||||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||||
@@ -743,7 +775,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* Per-category badges, in the colours of the flags they select — the
|
{/* Per-category badges, in the colours of the flags they select — the
|
||||||
same vocabulary as the Chase New panel. */}
|
same vocabulary as the Chase New panel. */}
|
||||||
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
||||||
{NEW_CATS.map((c) => {
|
{NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
|
||||||
const on = cats.has(c.key);
|
const on = cats.has(c.key);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1051,7 +1083,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
const e = statusOf(d);
|
const e = statusOf(d);
|
||||||
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
||||||
const entities = st ? entityBadgesFor(st) : [];
|
const entities = st ? entityBadgesFor(st) : [];
|
||||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
|
||||||
const mine = !!me && d.call === me;
|
const mine = !!me && d.call === me;
|
||||||
const hot = entities.length > 0 || extras.length > 0;
|
const hot = entities.length > 0 || extras.length > 0;
|
||||||
// Someone answering us outranks everything else on the screen.
|
// Someone answering us outranks everything else on the screen.
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
|
||||||
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
||||||
// read here: the list lives in Preferences, and App already reloads it when
|
// read here: the list lives in Preferences, and App already reloads it when
|
||||||
// Preferences close — a panel reading it once at mount would need a restart.
|
// Preferences close — a panel reading it once at mount would need a restart.
|
||||||
@@ -155,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -294,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
forCall={slotCall}
|
forCall={slotCall}
|
||||||
onEditQso={onEditQso}
|
onEditQso={onEditQso}
|
||||||
|
|||||||
@@ -62,7 +62,25 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
|||||||
});
|
});
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
|
// be resized under it, so the stale measurement showed as a strip of dead
|
||||||
|
// space along the bottom where tiles were never asked for. The observer
|
||||||
|
// hands it the real size whenever the box changes.
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
// Once more after the first paint: the first observation can arrive while
|
||||||
|
// the panel is still zero-height, and no further resize follows a layout
|
||||||
|
// that settles by itself.
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Basemap follows the picker.
|
// Basemap follows the picker.
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
|
||||||
|
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ const ZERO: IcomState = {
|
|||||||
type Band = { l: string; hz: number };
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
const HF_BANDS: Band[] = [
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
|
||||||
|
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
|
||||||
|
// every 60 m-capable rig can display. Where the band is channelised (the US)
|
||||||
|
// the operator moves to their channel from here — the button is a way onto
|
||||||
|
// the band, not a claim about what may be transmitted on it.
|
||||||
|
{ l: '60', hz: 5_354_000 },
|
||||||
|
{ l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
];
|
];
|
||||||
@@ -78,9 +84,36 @@ function bandsFor(model?: string): Band[] {
|
|||||||
return [...HF_BANDS, B6];
|
return [...HF_BANDS, B6];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row).
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
//
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA'];
|
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
|
||||||
|
// convention is right for a logged mode and useless when the operator means
|
||||||
|
// "put this radio in USB on 40 m", which the console could not express at all.
|
||||||
|
//
|
||||||
|
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
|
||||||
|
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
|
||||||
|
// DATA, which every rig can do.
|
||||||
|
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
|
||||||
|
|
||||||
|
function hasNativePSK(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700');
|
||||||
|
}
|
||||||
|
|
||||||
|
function modesFor(model?: string): string[] {
|
||||||
|
if (!hasNativePSK(model)) return MODES_BASE;
|
||||||
|
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which radios actually have an antenna selector on the CI-V command (0x12).
|
||||||
|
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
|
||||||
|
// only ever disagree with the front panel.
|
||||||
|
function hasAntennaSelector(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700') || m.includes('9700');
|
||||||
|
}
|
||||||
|
|
||||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
@@ -155,9 +188,21 @@ function icomWatts(pct: number): { w: number; defl: number } {
|
|||||||
return { w: Math.round(w), defl };
|
return { w: Math.round(w), defl };
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeMatches(btn: string, cur?: string): boolean {
|
// Which sideband a bare "SSB" means at this frequency — the same convention the
|
||||||
|
// backend applies when it resolves the mode for the radio.
|
||||||
|
function sideForHz(hz?: number): string | null {
|
||||||
|
if (!hz || hz <= 0) return null;
|
||||||
|
return hz < 10_000_000 ? 'LSB' : 'USB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
|
||||||
if (!cur) return false;
|
if (!cur) return false;
|
||||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
// A rig that reports the folded ADIF "SSB" still lights the side its
|
||||||
|
// frequency implies, so the row is never blank on a phone contact.
|
||||||
|
if (btn === 'USB' || btn === 'LSB') {
|
||||||
|
if (cur === btn) return true;
|
||||||
|
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
|
||||||
|
}
|
||||||
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
||||||
// plain DATA — either way it is the DATA button that should light.
|
// plain DATA — either way it is the DATA button that should light.
|
||||||
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
||||||
@@ -507,9 +552,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60">
|
<div className="grid border-t border-border/60 divide-x divide-border/60"
|
||||||
{MODES.map((m) => {
|
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
|
||||||
const on = modeMatches(m, curMode);
|
{modesFor(st.model).map((m) => {
|
||||||
|
const on = modeMatches(m, curMode, mainHz);
|
||||||
return (
|
return (
|
||||||
<button key={m} type="button" onClick={() => setMode(m)}
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
@@ -561,10 +607,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{hasAntennaSelector(st.model) && (
|
||||||
<Row label={t('icmp.antenna')}>
|
<Row label={t('icmp.antenna')}>
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
@@ -588,7 +636,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
{isPhone && (
|
{/* Not phone-only: on USB-D the same control still sets what the radio
|
||||||
|
transmits at, and hiding it left an operator who lives in FT8 with
|
||||||
|
no mic gain at all. */}
|
||||||
|
{(
|
||||||
<Row label={t('icmp.mic')}>
|
<Row label={t('icmp.mic')}>
|
||||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -42,6 +42,7 @@ const SERVICES = [
|
|||||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ v: 'hamqth', label: 'HamQTH' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
@@ -722,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{service === 'hamlog' ? (
|
{service === 'hamqth' ? (
|
||||||
|
// HamQTH's file endpoint REPLACES the remote log — its own
|
||||||
|
// documentation is explicit that partial uploads do not exist. So
|
||||||
|
// it is offered as its own deliberate act, never as the batch path
|
||||||
|
// behind "send these": that would delete everything not selected.
|
||||||
|
<Button variant="outline" size="sm" disabled={busy}
|
||||||
|
title={t('qslm.hqFullTitle')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
|
||||||
|
// Same three lines every other action here runs: without
|
||||||
|
// setShowLog the whole upload reported itself into a panel
|
||||||
|
// nobody was showing, and the tab sat on "Pick a service".
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
|
try { await UploadFullLogHamQTH(); }
|
||||||
|
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
|
||||||
|
}}>
|
||||||
|
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
|
||||||
|
</Button>
|
||||||
|
) : service === 'hamlog' ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
title={t('qslm.hamlogImportTitle')}>
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
{ service: 'hamqth', name: 'HamQTH' },
|
||||||
|
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reading order for the channel list: the two that carry an ARRL award first —
|
||||||
|
// paper QSL and LoTW — then everything else alphabetically, so a channel is
|
||||||
|
// found by its name rather than by remembering the order it was added in.
|
||||||
|
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
|
||||||
|
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const ra = CONF_FIRST[a.key] ?? 2;
|
||||||
|
const rb = CONF_FIRST[b.key] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
// the channel picker and the status table alongside the rest.
|
// the channel picker and the status table alongside the rest.
|
||||||
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
|
|||||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
|
||||||
|
// publishes no confirmation feed, so a received column here would be a promise
|
||||||
|
// nothing can keep.
|
||||||
|
const HAMQTH_CONF = 'HAMQTH';
|
||||||
|
const HAMQTH_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMQTH_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
: { khz: '', hz: '' };
|
: { khz: '', hz: '' };
|
||||||
|
// Correcting a frequency corrects its band. The pair has to agree — the log,
|
||||||
|
// every award and every upload are read on the BAND — and an operator fixing
|
||||||
|
// a wrong frequency is not also expecting to fix the band by hand, which is
|
||||||
|
// exactly how a QSO ends up filed on 20m at 7 MHz.
|
||||||
|
//
|
||||||
|
// Only when the number lands in a known allocation: half a frequency is typed
|
||||||
|
// on the way to all of it, and a band must never be blanked by that.
|
||||||
|
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
|
||||||
|
if (!khz.trim()) return;
|
||||||
|
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
|
||||||
|
if (b) set(field, b as any);
|
||||||
|
};
|
||||||
|
|
||||||
const f0 = splitHz(draft.freq_hz);
|
const f0 = splitHz(draft.freq_hz);
|
||||||
const fr0 = splitHz(draft.freq_rx_hz);
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
@@ -612,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,19 +783,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
{confOrder([
|
||||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH' },
|
||||||
|
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
|
||||||
|
{/* The three above that are NOT in CONFIRMATIONS — the
|
||||||
QSO columns, and this channel lives in the ADIF
|
QSO columns, and this channel lives in the ADIF
|
||||||
extras. It gets its own editor below rather than the
|
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||||
generic sent/received/date grid, which has no field
|
ADIF extras rather than QSO columns, and each gets its
|
||||||
to bind to. */}
|
own editor below instead of the generic
|
||||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
sent/received/date grid, which has no field to bind
|
||||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
to. */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confSel === HAMLOG_CONF ? (
|
{confSel === HAMQTH_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === HAMLOG_CONF ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
@@ -845,30 +896,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{/* The extras-backed channels (OpsLog card,
|
||||||
|
HAMLOG.online, HamQTH) sit in the same ordered list
|
||||||
|
as the column-backed ones: the reader is looking for
|
||||||
|
a name, not for a storage detail. A dash in RECEIVED
|
||||||
|
means the channel has nothing to receive — Club Log
|
||||||
|
and HamQTH publish no confirmations — which is not
|
||||||
|
the same statement as "N". */}
|
||||||
|
{confOrder([
|
||||||
|
...CONFIRMATIONS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
|
||||||
|
sent: val(c.sent),
|
||||||
|
rcvd: c.rcvd ? val(c.rcvd) : null,
|
||||||
|
})),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
|
||||||
|
]).map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={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 === null
|
||||||
|
? <span className="block text-center text-[11px] text-muted-foreground">—</span>
|
||||||
|
: <StatusCell value={c.rcvd} />}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{/* OpsLog's own card, read from the ADIF extras rather
|
|
||||||
than a QSO column — hence a hand-written row instead
|
|
||||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
|
||||||
when the card actually goes out, so it stays
|
|
||||||
read-only here: an operator ticking it by hand would
|
|
||||||
be recording something that never happened. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
|
||||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
|
||||||
</tr>
|
|
||||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
|
// HamQTH, the extras again — sent only, the site having no confirmations.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
SetCIVTrace,
|
SetCIVTrace,
|
||||||
CIVTraceEnabled,
|
CIVTraceEnabled,
|
||||||
WinkeyerTraceEnabled,
|
WinkeyerTraceEnabled,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
@@ -213,6 +213,7 @@ type SectionId =
|
|||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
| 'lists-satellites'
|
| 'lists-satellites'
|
||||||
| 'cluster'
|
| 'cluster'
|
||||||
|
| 'dxhunter'
|
||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'autostart'
|
| 'autostart'
|
||||||
@@ -325,6 +326,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
|
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
|
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||||
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||||
@@ -1612,7 +1614,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
|
||||||
});
|
});
|
||||||
// Brand + connection, derived from the stored backend rather than held
|
// Brand + connection, derived from the stored backend rather than held
|
||||||
// separately: two sources for one fact drift apart the first time something
|
// separately: two sources for one fact drift apart the first time something
|
||||||
@@ -1700,13 +1702,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
type AudioSettings = {
|
type AudioSettings = {
|
||||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
|
||||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||||
};
|
};
|
||||||
type AudioDev = { id: string; name: string; default: boolean };
|
type AudioDev = { id: string; name: string; default: boolean };
|
||||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
|
||||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||||
});
|
});
|
||||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||||
@@ -1825,6 +1827,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
|
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
|
||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string; hamlog_confirmed: string;
|
hamlog_status: string; hamlog_confirmed: string;
|
||||||
|
hamqth_status: string;
|
||||||
};
|
};
|
||||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||||
qsl_sent: '', qsl_rcvd: '',
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
@@ -1832,7 +1835,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
eqsl_sent: '', eqsl_rcvd: '',
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
||||||
qrzcom_confirmed: '',
|
qrzcom_confirmed: '',
|
||||||
hamlog_status: '', hamlog_confirmed: '',
|
hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// External services (logbook upload). One block per service; only QRZ is
|
// External services (logbook upload). One block per service; only QRZ is
|
||||||
@@ -1848,7 +1851,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||||
on_air?: boolean;
|
on_air?: boolean;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
@@ -1856,7 +1859,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -2025,6 +2028,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||||
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hamlogTesting, setHamlogTesting] = useState(false);
|
const [hamlogTesting, setHamlogTesting] = useState(false);
|
||||||
|
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [hamqthTesting, setHamqthTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [eqslTesting, setEqslTesting] = useState(false);
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
@@ -2032,7 +2037,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||||||
// stays put because moving it down would reset the tab on every reopen —
|
// stays put because moving it down would reset the tab on every reopen —
|
||||||
// a choice now, not a workaround.
|
// a choice now, not a workaround.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -2076,6 +2081,9 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
};
|
};
|
||||||
const [chaseGrids, setChaseGrids] = useState(false);
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
|
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
|
||||||
|
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
|
||||||
|
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
|
||||||
|
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
|
||||||
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
|
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
|
||||||
const [chaseNew, setChaseNew] = useState(false);
|
const [chaseNew, setChaseNew] = useState(false);
|
||||||
const [spotTTL, setSpotTTL] = useState(0);
|
const [spotTTL, setSpotTTL] = useState(0);
|
||||||
@@ -2097,7 +2105,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
try {
|
||||||
|
const g = await GetChaseNewGrids();
|
||||||
|
setChaseGrids(g);
|
||||||
|
// Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
|
||||||
|
// gates NEW GRID synchronously from localStorage.
|
||||||
|
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
|
||||||
|
} catch { /* defaults stand */ }
|
||||||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||||
@@ -3829,6 +3843,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label> </>
|
</label> </>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
|
||||||
|
<Checkbox
|
||||||
|
checked={!!catCfg.digi_as_usb}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
|
||||||
|
/>
|
||||||
|
{t('cat.digiUsb')}
|
||||||
|
</label>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -5218,6 +5239,131 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
setEditingServer(next);
|
setEditingServer(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chasing is not a cluster feature.
|
||||||
|
//
|
||||||
|
// These switches were born on the DX Cluster page because the cluster was the
|
||||||
|
// only thing that drew their badges. They now decide what the FT decode list
|
||||||
|
// and Chase new say as well, and settings that govern three screens do not
|
||||||
|
// belong under the name of one of them. The page is called DXHunter because
|
||||||
|
// that is what it is about — hunting DX — and because more of DXHunter's
|
||||||
|
// ideas are meant to land beside them.
|
||||||
|
function DXHunterPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
||||||
|
once — badge, colour, filter chip, reference column. A "new band
|
||||||
|
+ new POTA" spot then reads NEW BAND alone. */}
|
||||||
|
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
|
||||||
|
county, state, grid) judged as new-only or new-plus-unconfirmed,
|
||||||
|
against the confirmation sources the operator trusts. */}
|
||||||
|
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
|
||||||
|
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||||||
|
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{chaseCfg.mode === 'new_unconfirmed' && (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap pl-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
|
||||||
|
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
|
||||||
|
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={chaseCfg.sources.includes(k)}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
|
||||||
|
const next = { ...chaseCfg, sources };
|
||||||
|
setChaseCfg(next); void SaveChaseSettings(next as any);
|
||||||
|
}} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
||||||
|
<Checkbox checked={chasePotaOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chasePota')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
|
||||||
|
<Checkbox checked={chaseSotaOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseSota')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
|
||||||
|
<Checkbox checked={chaseCountyOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseCounty')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
|
||||||
|
<Checkbox checked={chasePfxOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chasePfx')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
|
||||||
|
<Checkbox checked={chaseStateOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseState')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{chaseGrids && (
|
||||||
|
<p className="pl-6 text-xs text-muted-foreground">
|
||||||
|
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
||||||
|
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What counts as "I already have this square". There is no single
|
||||||
|
right answer — a VUCC chaser counts a square per band, someone
|
||||||
|
filling a wall map counts it once — so it is a choice, and the
|
||||||
|
same six GridTracker offers, because a square wanted in one and
|
||||||
|
not the other is a bug report every time. */}
|
||||||
|
<div className="pl-6 space-y-1.5 pt-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
||||||
|
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(gridScope.scopes ?? []).map((s: any) => (
|
||||||
|
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||||
|
squares and chasing entities are different wants; they only share
|
||||||
|
the PSK Reporter feed, which either one brings up. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseNew} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClusterPanel() {
|
function ClusterPanel() {
|
||||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
// Written on every keystroke. This panel has no Save button, and a pair of
|
// Written on every keystroke. This panel has no Save button, and a pair of
|
||||||
@@ -5420,97 +5566,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
|
||||||
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
|
||||||
once — badge, colour, filter chip, reference column. A "new band
|
|
||||||
+ new POTA" spot then reads NEW BAND alone. */}
|
|
||||||
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
|
|
||||||
county, state, grid) judged as new-only or new-plus-unconfirmed,
|
|
||||||
against the confirmation sources the operator trusts. */}
|
|
||||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
|
|
||||||
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
|
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
|
||||||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{chaseCfg.mode === 'new_unconfirmed' && (
|
|
||||||
<div className="flex items-center gap-4 flex-wrap pl-2">
|
|
||||||
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
|
|
||||||
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
|
|
||||||
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
|
||||||
<Checkbox checked={chaseCfg.sources.includes(k)}
|
|
||||||
onCheckedChange={(c) => {
|
|
||||||
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
|
|
||||||
const next = { ...chaseCfg, sources };
|
|
||||||
setChaseCfg(next); void SaveChaseSettings(next as any);
|
|
||||||
}} />
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
|
||||||
<Checkbox checked={chasePotaOn}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
|
||||||
{t('clu.chasePota')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
|
|
||||||
<Checkbox checked={chaseSotaOn}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
|
|
||||||
{t('clu.chaseSota')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
|
||||||
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
|
||||||
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
|
||||||
</label>
|
|
||||||
{chaseGrids && (
|
|
||||||
<p className="pl-6 text-xs text-muted-foreground">
|
|
||||||
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
|
||||||
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* What counts as "I already have this square". There is no single
|
|
||||||
right answer — a VUCC chaser counts a square per band, someone
|
|
||||||
filling a wall map counts it once — so it is a choice, and the
|
|
||||||
same six GridTracker offers, because a square wanted in one and
|
|
||||||
not the other is a bug report every time. */}
|
|
||||||
<div className="pl-6 space-y-1.5 pt-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
|
||||||
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(gridScope.scopes ?? []).map((s: any) => (
|
|
||||||
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
|
||||||
squares and chasing entities are different wants; they only share
|
|
||||||
the PSK Reporter feed, which either one brings up. */}
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={chaseNew} className="mt-0.5"
|
|
||||||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
|
||||||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||||
switching it on adds two of them — the operator should see that
|
switching it on adds two of them — the operator should see that
|
||||||
happen where it happens rather than find nodes they did not add. */}
|
happen where it happens rather than find nodes they did not add. */}
|
||||||
@@ -5735,6 +5790,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* HamQTH — no received side: the site has no confirmation feed. */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">HamQTH</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||||
|
{renderSelect('hamqth_status', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
{/* HAMLOG.online */}
|
{/* HAMLOG.online */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
||||||
@@ -5928,6 +5992,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||||
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
||||||
|
{ k: 'hamqth', label: 'HAMQTH', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -5997,6 +6062,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hamqth = extSvc.hamqth ?? emptyExtCfg();
|
||||||
|
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
|
||||||
|
async function testHamqth() {
|
||||||
|
setHamqthTesting(true);
|
||||||
|
setHamqthTest(null);
|
||||||
|
try {
|
||||||
|
const msg = await TestHamQTHUpload();
|
||||||
|
setHamqthTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setHamqthTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
||||||
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||||||
@@ -6342,11 +6422,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</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">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{/* The site stopped issuing API keys and its upload API takes
|
||||||
<Checkbox
|
nothing else, so an auto-upload switch here would arm something
|
||||||
checked={hamlog.auto_upload}
|
that can only fail. Their confirmations still arrive as a file,
|
||||||
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })}
|
which never needed a key. */}
|
||||||
/>
|
<p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
|
||||||
|
{t('es.hamlogClosed')}
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
|
||||||
|
<Checkbox checked={false} disabled />
|
||||||
{t('es.autoUpload')}
|
{t('es.autoUpload')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -6376,6 +6460,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'hamqth' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.username')}</Label>
|
||||||
|
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
|
||||||
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
|
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
|
||||||
|
<Label className="text-sm">{t('es.hamqthCall')}</Label>
|
||||||
|
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
|
||||||
|
{t('es.autoUpload')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||||
|
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||||
|
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
|
||||||
|
</Button>
|
||||||
|
{hamqthTest && (
|
||||||
|
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'cloudlog' ? (
|
) : extSvcTab === 'cloudlog' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
@@ -7134,6 +7255,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Kenwood only, because only a Kenwood acts on it: TX1 is that
|
||||||
|
family's second transmit command. Every other backend keys the
|
||||||
|
one way it knows, so showing the box there would be a switch
|
||||||
|
that changes nothing — the same dead furniture as ANT2 on a
|
||||||
|
radio with one socket. */}
|
||||||
|
{audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
|
||||||
|
<>
|
||||||
|
<span />
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
|
||||||
|
<Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
|
||||||
|
onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
|
||||||
|
<span>{t('aud.pttData')}</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||||
<>
|
<>
|
||||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||||
@@ -7953,6 +8089,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
'lists-satellites': SatellitesPanel,
|
'lists-satellites': SatellitesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
|
dxhunter: DXHunterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
// Module-scope components, wrapped so their props can be passed. The nested
|
// Module-scope components, wrapped so their props can be passed. The nested
|
||||||
// panels below go through PanelHost instead — which is what now lets either
|
// panels below go through PanelHost instead — which is what now lets either
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
try {
|
try {
|
||||||
await WatchlistAdd(c, addContest);
|
await WatchlistAdd(c, addContest);
|
||||||
setAddCall('');
|
setAddCall('');
|
||||||
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
|
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
|
||||||
|
// — saying it twice, once per place a call can be added from, was noise.
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
||||||
// the other two buttons do not have.
|
// the other two buttons do not have.
|
||||||
const remove = async (call: string) => {
|
const remove = async (call: string) => {
|
||||||
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); }
|
try { await WatchlistRemove(call); await refresh(); }
|
||||||
catch (e: any) { flash(String(e?.message ?? e), true); }
|
catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
|
|||||||
export function bandSegments(band: string): Seg[] {
|
export function bandSegments(band: string): Seg[] {
|
||||||
return plan().segments[band] ?? [];
|
return plan().segments[band] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
|
||||||
|
// outside every known allocation.
|
||||||
|
//
|
||||||
|
// Lives here rather than in a panel because more than one place has to answer
|
||||||
|
// the same question the same way: the entry form retunes on a typed frequency,
|
||||||
|
// and the QSO editor has to keep band and frequency agreeing when one of them
|
||||||
|
// is corrected. Kept in step with BandFromHz on the Go side.
|
||||||
|
export function bandForMHz(mhz: number): string {
|
||||||
|
if (!mhz || isNaN(mhz)) return '';
|
||||||
|
const plan: [number, number, string][] = [
|
||||||
|
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||||
|
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||||
|
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||||
|
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
||||||
|
// Microwave, ADIF 3.1.7 ranges.
|
||||||
|
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
||||||
|
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
||||||
|
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
+22
-22
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
|
|||||||
// keep being computed; only the telling stops, so ticking the box back on
|
// keep being computed; only the telling stops, so ticking the box back on
|
||||||
// needs no rescan.
|
// needs no rescan.
|
||||||
chasePota: boolean; chaseSota: boolean;
|
chasePota: boolean; chaseSota: boolean;
|
||||||
|
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// chasePota/chaseSota read the switches directly — for the places that show a
|
// chasePota/chaseSota read the switches directly — for the places that show a
|
||||||
@@ -30,6 +31,43 @@ export function chasePota(): boolean {
|
|||||||
export function chaseSota(): boolean {
|
export function chaseSota(): boolean {
|
||||||
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
||||||
}
|
}
|
||||||
|
export function chaseCounty(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chasePfx(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseState(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseState') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
|
||||||
|
// mirror on load and on toggle) so the display layer can gate NEW GRID without
|
||||||
|
// an async round-trip per row.
|
||||||
|
export function chaseGrid(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseAllows answers "does this operator chase this kind of thing at all?"
|
||||||
|
// for the ORTHOGONAL markers — park, square, prefix, county.
|
||||||
|
//
|
||||||
|
// The switches were written for the cluster and stayed there, so an operator
|
||||||
|
// who does not chase parks still met NEW POTA in the decode list and in Chase
|
||||||
|
// new: the same badge, withdrawn on one screen and shouting on the next. The
|
||||||
|
// setting is about what the operator hunts, not about which panel is open.
|
||||||
|
//
|
||||||
|
// The keys are both the status-entry field names (new_pota…) and the shorter
|
||||||
|
// category names the panels filter with (pota…), so one call serves both.
|
||||||
|
// Anything without a switch of its own — a new US state — is always allowed.
|
||||||
|
export function chaseAllows(key: string): boolean {
|
||||||
|
switch (key) {
|
||||||
|
case 'new_pota': case 'pota': return chasePota();
|
||||||
|
case 'new_grid': case 'grid': return chaseGrid();
|
||||||
|
case 'new_pfx': case 'pfx': return chasePfx();
|
||||||
|
case 'new_county': case 'cty': return chaseCounty();
|
||||||
|
case 'new_state': case 'state': return chaseState();
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Both options are withdrawn from the filter panel for now. The machinery below
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
@@ -44,16 +82,18 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
|
|||||||
// The EXPOSED flag only withdraws the two original switches; the chase
|
// The EXPOSED flag only withdraws the two original switches; the chase
|
||||||
// switches are live regardless.
|
// switches are live regardless.
|
||||||
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
||||||
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() };
|
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
chasePota: chasePota(), chaseSota: chaseSota(),
|
chasePota: chasePota(), chaseSota: chaseSota(),
|
||||||
|
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
|
||||||
|
chaseState: chaseState(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true };
|
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +105,7 @@ type Entry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
new_state?: boolean;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
||||||
@@ -98,6 +139,20 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
|||||||
if (!o.chasePota && e.new_pota) {
|
if (!o.chasePota && e.new_pota) {
|
||||||
e = { ...e, new_pota: false } as NonNullable<T>;
|
e = { ...e, new_pota: false } as NonNullable<T>;
|
||||||
}
|
}
|
||||||
|
// Same withdrawal for the other extra markers: the facts keep being
|
||||||
|
// computed, only the telling stops — re-ticking a box needs no rescan.
|
||||||
|
if (!o.chaseCounty && e.new_county) {
|
||||||
|
e = { ...e, new_county: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chasePfx && e.new_pfx) {
|
||||||
|
e = { ...e, new_pfx: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseGrid && e.new_grid) {
|
||||||
|
e = { ...e, new_grid: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseState && e.new_state) {
|
||||||
|
e = { ...e, new_state: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.27.5';
|
export const APP_VERSION = '0.27.11';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+11
@@ -12,6 +12,7 @@ import {award} from '../models';
|
|||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {bandopen} from '../models';
|
import {bandopen} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
@@ -476,6 +477,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
|||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
|
|
||||||
|
export function GetDXWorldNews():Promise<Array<dxped.News>>;
|
||||||
|
|
||||||
|
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
|
||||||
|
|
||||||
export function GetDataDir():Promise<string>;
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
@@ -944,6 +949,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshDXpeditions():Promise<void>;
|
||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -1334,6 +1341,8 @@ export function TestEmail(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function TestHRDLogUpload():Promise<string>;
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestHamQTHUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
@@ -1388,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UploadCallsign(arg1:string):Promise<string>;
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH():Promise<void>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -890,6 +890,14 @@ export function GetDVKStatus() {
|
|||||||
return window['go']['main']['App']['GetDVKStatus']();
|
return window['go']['main']['App']['GetDVKStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDXWorldNews() {
|
||||||
|
return window['go']['main']['App']['GetDXWorldNews']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDXpeditions() {
|
||||||
|
return window['go']['main']['App']['GetDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDataDir() {
|
export function GetDataDir() {
|
||||||
return window['go']['main']['App']['GetDataDir']();
|
return window['go']['main']['App']['GetDataDir']();
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1834,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshDXpeditions() {
|
||||||
|
return window['go']['main']['App']['RefreshDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshKenwood() {
|
export function RefreshKenwood() {
|
||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
@@ -2606,6 +2618,10 @@ export function TestHRDLogUpload() {
|
|||||||
return window['go']['main']['App']['TestHRDLogUpload']();
|
return window['go']['main']['App']['TestHRDLogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestHamQTHUpload() {
|
||||||
|
return window['go']['main']['App']['TestHamQTHUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -2714,6 +2730,10 @@ export function UploadCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['UploadCallsign'](arg1);
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH() {
|
||||||
|
return window['go']['main']['App']['UploadFullLogHamQTH']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1466,6 +1466,37 @@ export namespace contest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace dxped {
|
||||||
|
|
||||||
|
export class News {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pub_date: string;
|
||||||
|
excerpt: string;
|
||||||
|
creator: string;
|
||||||
|
image_url: string;
|
||||||
|
tag: string;
|
||||||
|
calls: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new News(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.pub_date = source["pub_date"];
|
||||||
|
this.excerpt = source["excerpt"];
|
||||||
|
this.creator = source["creator"];
|
||||||
|
this.image_url = source["image_url"];
|
||||||
|
this.tag = source["tag"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace extsvc {
|
export namespace extsvc {
|
||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
@@ -1522,6 +1553,7 @@ export namespace extsvc {
|
|||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
cloudlog: ServiceConfig;
|
cloudlog: ServiceConfig;
|
||||||
hamlog: ServiceConfig;
|
hamlog: ServiceConfig;
|
||||||
|
hamqth: ServiceConfig;
|
||||||
delete_remote: boolean;
|
delete_remote: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -1537,6 +1569,7 @@ export namespace extsvc {
|
|||||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||||
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
||||||
|
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
|
||||||
this.delete_remote = source["delete_remote"];
|
this.delete_remote = source["delete_remote"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1891,6 +1924,7 @@ export namespace main {
|
|||||||
preroll_seconds: number;
|
preroll_seconds: number;
|
||||||
ptt_method: string;
|
ptt_method: string;
|
||||||
ptt_port: string;
|
ptt_port: string;
|
||||||
|
ptt_data: boolean;
|
||||||
format: string;
|
format: string;
|
||||||
from_gain: number;
|
from_gain: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
@@ -1912,6 +1946,7 @@ export namespace main {
|
|||||||
this.preroll_seconds = source["preroll_seconds"];
|
this.preroll_seconds = source["preroll_seconds"];
|
||||||
this.ptt_method = source["ptt_method"];
|
this.ptt_method = source["ptt_method"];
|
||||||
this.ptt_port = source["ptt_port"];
|
this.ptt_port = source["ptt_port"];
|
||||||
|
this.ptt_data = source["ptt_data"];
|
||||||
this.format = source["format"];
|
this.format = source["format"];
|
||||||
this.from_gain = source["from_gain"];
|
this.from_gain = source["from_gain"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
@@ -2265,6 +2300,7 @@ export namespace main {
|
|||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
omnirig_vfo: string;
|
omnirig_vfo: string;
|
||||||
|
digi_as_usb: boolean;
|
||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
@@ -2317,6 +2353,7 @@ export namespace main {
|
|||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
this.omnirig_vfo = source["omnirig_vfo"];
|
this.omnirig_vfo = source["omnirig_vfo"];
|
||||||
|
this.digi_as_usb = source["digi_as_usb"];
|
||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
@@ -2672,6 +2709,44 @@ export namespace main {
|
|||||||
this.rec_slot = source["rec_slot"];
|
this.rec_slot = source["rec_slot"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DXpedition {
|
||||||
|
dxcc: string;
|
||||||
|
callsign: string;
|
||||||
|
calls: string[];
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
bands: string[];
|
||||||
|
modes: string[];
|
||||||
|
qsl: string;
|
||||||
|
operators: string;
|
||||||
|
source: string;
|
||||||
|
link: string;
|
||||||
|
status: string;
|
||||||
|
status_chase: string;
|
||||||
|
unconfirmed: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DXpedition(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.start_date = source["start_date"];
|
||||||
|
this.end_date = source["end_date"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.qsl = source["qsl"];
|
||||||
|
this.operators = source["operators"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.status_chase = source["status_chase"];
|
||||||
|
this.unconfirmed = source["unconfirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DatabaseSettings {
|
export class DatabaseSettings {
|
||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
@@ -3300,6 +3375,7 @@ export namespace main {
|
|||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string;
|
hamlog_status: string;
|
||||||
hamlog_confirmed: string;
|
hamlog_confirmed: string;
|
||||||
|
hamqth_status: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLDefaults(source);
|
return new QSLDefaults(source);
|
||||||
@@ -3320,6 +3396,7 @@ export namespace main {
|
|||||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
this.hamlog_status = source["hamlog_status"];
|
this.hamlog_status = source["hamlog_status"];
|
||||||
this.hamlog_confirmed = source["hamlog_confirmed"];
|
this.hamlog_confirmed = source["hamlog_confirmed"];
|
||||||
|
this.hamqth_status = source["hamqth_status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSLEmailTemplates {
|
export class QSLEmailTemplates {
|
||||||
|
|||||||
@@ -443,5 +443,11 @@ func adifCounty(state, county string) string {
|
|||||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
|
||||||
|
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
|
||||||
|
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
|
||||||
|
if len(s) != 2 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
return strings.ToUpper(s) + "," + c
|
return strings.ToUpper(s) + "," + c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,33 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataPTTSetter is implemented by a backend that can key the DATA input rather
|
||||||
|
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
|
||||||
|
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
|
||||||
|
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
|
||||||
|
// because the audio arriving on USB is simply not the input it is listening to.
|
||||||
|
type dataPTTSetter interface {
|
||||||
|
SetPTTData(on bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
|
||||||
|
//
|
||||||
|
// data=true means "the audio reaches the radio on its data/USB input" — what a
|
||||||
|
// voice keyer playing through the rig's own sound card needs. A backend that
|
||||||
|
// draws no distinction (every rig where one PTT is all there is) falls back to
|
||||||
|
// the ordinary key, so nothing changes for it.
|
||||||
|
func (m *Manager) SetPTTSource(on, data bool) error {
|
||||||
|
if !data {
|
||||||
|
return m.SetPTT(on)
|
||||||
|
}
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
if d, ok := b.(dataPTTSetter); ok {
|
||||||
|
return d.SetPTTData(on)
|
||||||
|
}
|
||||||
|
return b.SetPTT(on)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// splitSetter is implemented by the backends that can arm split AND place the
|
// splitSetter is implemented by the backends that can arm split AND place the
|
||||||
// transmit frequency. Both together: arming without setting the dial transmits
|
// transmit frequency. Both together: arming without setting the dial transmits
|
||||||
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
||||||
@@ -304,6 +331,14 @@ type SpotInfo struct {
|
|||||||
BackgroundColor string
|
BackgroundColor string
|
||||||
Comment string
|
Comment string
|
||||||
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||||
|
// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
|
||||||
|
//
|
||||||
|
// It matters because the panadapter has finite room: spots close in
|
||||||
|
// frequency are stacked behind a "+" and only one of them is drawn. The
|
||||||
|
// radio picks that one by priority — so an entity never worked can sit
|
||||||
|
// invisible behind three stations already in the log unless we say which
|
||||||
|
// is worth the space. 0 leaves the field off the command entirely.
|
||||||
|
Priority int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
|
|||||||
@@ -1557,6 +1557,10 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if hadOld {
|
if hadOld {
|
||||||
f.send(fmt.Sprintf("spot remove %d", old))
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
}
|
}
|
||||||
|
prio := ""
|
||||||
|
if s.Priority >= 1 && s.Priority <= 5 {
|
||||||
|
prio = fmt.Sprintf(" priority=%d", s.Priority)
|
||||||
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
||||||
@@ -1575,6 +1579,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if c := flexEncode(s.Comment); c != "" {
|
if c := flexEncode(s.Comment); c != "" {
|
||||||
cmd += " comment=" + c
|
cmd += " comment=" + c
|
||||||
}
|
}
|
||||||
|
cmd += prio
|
||||||
seq := f.send(cmd)
|
seq := f.send(cmd)
|
||||||
if seq > 0 {
|
if seq > 0 {
|
||||||
// Remember which call this add was for; the R<seq> response carries the
|
// Remember which call this add was for; the R<seq> response carries the
|
||||||
|
|||||||
@@ -1543,6 +1543,14 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
|||||||
return civ.ModeCW, false, nil
|
return civ.ModeCW, false, nil
|
||||||
case "SSB":
|
case "SSB":
|
||||||
return usb, false, nil
|
return usb, false, nil
|
||||||
|
case "USB":
|
||||||
|
// The SIDEBAND, asked for by name. "SSB" resolves to whichever side the
|
||||||
|
// band convention wants, which is right for a logged mode and useless
|
||||||
|
// when the operator means "put this radio in USB" — on 40 m there was no
|
||||||
|
// way to say it at all, and the console's own button could not either.
|
||||||
|
return civ.ModeUSB, false, nil
|
||||||
|
case "LSB":
|
||||||
|
return civ.ModeLSB, false, nil
|
||||||
case "AM":
|
case "AM":
|
||||||
return civ.ModeAM, false, nil
|
return civ.ModeAM, false, nil
|
||||||
case "FM":
|
case "FM":
|
||||||
|
|||||||
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
|
|||||||
return k.write("RX;")
|
return k.write("RX;")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
|
||||||
|
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
|
||||||
|
// that the parameter chooses the input — "0: SEND (normal transmission using
|
||||||
|
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
|
||||||
|
// into the rig's USB codec has to say TX1 or it transmits dead air while the
|
||||||
|
// radio listens to a microphone nobody is speaking into.
|
||||||
|
//
|
||||||
|
// Unkeying is the same RX either way; there is no data-flavoured stop.
|
||||||
|
func (k *Kenwood) SetPTTData(on bool) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
k.tx = on
|
||||||
|
if on {
|
||||||
|
k.txAt = time.Now()
|
||||||
|
return k.write("TX1;")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
func (k *Kenwood) write(cmd string) error {
|
func (k *Kenwood) write(cmd string) error {
|
||||||
if k.port == nil {
|
if k.port == nil {
|
||||||
return fmt.Errorf("kenwood: not connected")
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
|||||||
@@ -388,7 +388,14 @@ func (y *Yaesu) RefreshYaesu() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetYaesuPower(w int) error {
|
func (y *Yaesu) SetYaesuPower(w int) error {
|
||||||
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
|
// The SAME ceiling the console draws its slider to. It was hard-coded at 100
|
||||||
|
// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
|
||||||
|
// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
|
||||||
|
// next poll — which is how the operator discovered it.
|
||||||
|
y.mu.Lock()
|
||||||
|
max := yaesuMaxPower(y.model, y.panel.RFPower)
|
||||||
|
y.mu.Unlock()
|
||||||
|
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Reported on an FTDX101MP: the power slider sprang back to 100 W. The console
|
||||||
|
// already knew the rig could do 200 — yaesuMaxPower says so — but the SET path
|
||||||
|
// clamped to 100, so the radio was politely given half what was asked for.
|
||||||
|
func TestYaesuPowerCeilingFollowsTheModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"FTDX101MP", 200},
|
||||||
|
{"FT-DX5000", 200},
|
||||||
|
{"FTDX9000", 200},
|
||||||
|
{"FTDX101D", 100},
|
||||||
|
{"FTDX10", 100},
|
||||||
|
{"Yaesu (0999)", 100}, // unknown: ask for too little, never too much
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := yaesuMaxPower(c.model, 0); got != c.want {
|
||||||
|
t.Errorf("%s ceiling = %d W, want %d", c.model, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A rig REPORTING more than the table expects has just proved what it can do.
|
||||||
|
if got := yaesuMaxPower("FTDX10", 150); got != 150 {
|
||||||
|
t.Errorf("a rig reporting 150 W was capped at %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -40,6 +40,14 @@ type Match struct {
|
|||||||
Continent string `json:"continent"`
|
Continent string `json:"continent"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
|
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
|
||||||
|
//
|
||||||
|
// The two carry very different authority. A prefix match is a rule of thumb
|
||||||
|
// about a block of callsigns; an exact entry is somebody having looked at
|
||||||
|
// THIS callsign and written down where it was. A second country file may
|
||||||
|
// improve on the first kind and should not be allowed to overrule the
|
||||||
|
// second.
|
||||||
|
Exact bool `json:"exact,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type prefixEntry struct {
|
type prefixEntry struct {
|
||||||
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
|
|||||||
return Match{}, false
|
return Match{}, false
|
||||||
}
|
}
|
||||||
if e, ok := db.exact[call]; ok {
|
if e, ok := db.exact[call]; ok {
|
||||||
return materialize(e), true
|
m := materialize(e)
|
||||||
|
m.Exact = true
|
||||||
|
return m, true
|
||||||
}
|
}
|
||||||
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
||||||
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// Package dxped reads the two feeds the DX world announces itself on.
|
||||||
|
//
|
||||||
|
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
|
||||||
|
// description is a fixed, dash-separated sentence — dates, entity, callsign,
|
||||||
|
// QSL route, source, then the operators/bands/modes prose. It is what a
|
||||||
|
// DXpedition list is actually made of.
|
||||||
|
//
|
||||||
|
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
|
||||||
|
// carries no structure to act on, but the headline nearly always names the
|
||||||
|
// callsign, so the calls are mined from the title and the reader can act on
|
||||||
|
// them the same way (watchlist, chase status).
|
||||||
|
//
|
||||||
|
// Both are cached: the announcements change a few times a day, the news a few
|
||||||
|
// times an hour, and neither is worth a request per screen repaint.
|
||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adxoURL = "https://www.ng3k.com/adxo.xml"
|
||||||
|
dxworldURL = "https://dx-world.net/feed/"
|
||||||
|
|
||||||
|
adxoTTL = 1 * time.Hour
|
||||||
|
dxworldTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Activation is one announced operation, as ADXO describes it.
|
||||||
|
type Activation struct {
|
||||||
|
DXCC string `json:"dxcc"`
|
||||||
|
Callsign string `json:"callsign"` // display form; "A, B" when several
|
||||||
|
Calls []string `json:"calls"` // the individual callsigns, normalised
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
Modes []string `json:"modes"`
|
||||||
|
QSL string `json:"qsl"`
|
||||||
|
Operators string `json:"operators"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Status string `json:"status"` // "active" | "upcoming"
|
||||||
|
}
|
||||||
|
|
||||||
|
// News is one DX-World post.
|
||||||
|
type News struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
|
||||||
|
Calls []string `json:"calls"` // callsigns mined from the headline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager holds both caches and fetches on demand.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
acts []Activation
|
||||||
|
actsAt time.Time
|
||||||
|
news []News
|
||||||
|
newsAt time.Time
|
||||||
|
client *http.Client
|
||||||
|
fetching sync.Mutex // one refresh at a time, whichever pane asked
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager {
|
||||||
|
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activations returns the cached announcements, refreshing when stale.
|
||||||
|
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out := m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
// Someone else may have refreshed while we waited for the lock.
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out = m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
acts, err := m.fetchADXO(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Stale beats empty: a feed that is down should not blank a list the
|
||||||
|
// operator was reading a minute ago.
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.acts, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.acts, m.actsAt = acts, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// News returns the cached DX-World posts, refreshing when stale.
|
||||||
|
func (m *Manager) News(ctx context.Context) ([]News, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out := m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out = m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
news, err := m.fetchDXWorld(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.news, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.news, m.newsAt = news, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate drops both caches so the next read refetches.
|
||||||
|
func (m *Manager) Invalidate() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.actsAt, m.newsAt = time.Time{}, time.Time{}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ADXO ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type rssItem struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
PubDate string `xml:"pubDate"`
|
||||||
|
Creator string `xml:"creator"`
|
||||||
|
Enclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"enclosure"`
|
||||||
|
MediaContent struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rssFeed struct {
|
||||||
|
Items []rssItem `xml:"channel>item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
|
||||||
|
body, err := m.get(ctx, adxoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: parse: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]Activation, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
a := parseActivation(it.Description, it.Link)
|
||||||
|
if a == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.Status = activationStatus(a.StartDate, a.EndDate, now)
|
||||||
|
if a.Status == "ended" {
|
||||||
|
continue // the list is about what is on or coming
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaceRe = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// parseActivation reads one ADXO description. Its shape is fixed and has been
|
||||||
|
// for twenty years:
|
||||||
|
//
|
||||||
|
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
|
||||||
|
// -- By ops; bands; modes; notes"
|
||||||
|
func parseActivation(desc, link string) *Activation {
|
||||||
|
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
|
||||||
|
desc = strings.TrimSpace(html.UnescapeString(desc))
|
||||||
|
if desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(desc, " -- ")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := &Activation{Link: link}
|
||||||
|
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
|
||||||
|
a.DXCC = strings.TrimSpace(parts[1])
|
||||||
|
a.Callsign = strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
for _, p := range parts[3:] {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "QSL:"):
|
||||||
|
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
|
||||||
|
case strings.HasPrefix(p, "Source:"):
|
||||||
|
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
|
||||||
|
tail := parts[len(parts)-1]
|
||||||
|
if i := strings.Index(tail, "By "); i >= 0 {
|
||||||
|
sub := strings.Split(tail[i+3:], ";")
|
||||||
|
if len(sub) > 0 {
|
||||||
|
a.Operators = strings.TrimSpace(sub[0])
|
||||||
|
}
|
||||||
|
if len(sub) > 1 {
|
||||||
|
a.Bands = parseBands(sub[1])
|
||||||
|
}
|
||||||
|
if len(sub) > 2 {
|
||||||
|
a.Modes = parseModes(sub[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign field often holds only the PREFIX; the real calls hide in the
|
||||||
|
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
|
||||||
|
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
|
||||||
|
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
|
||||||
|
for i := range calls {
|
||||||
|
calls[i] = normalizeCall(calls[i], prefix)
|
||||||
|
}
|
||||||
|
a.Calls = calls
|
||||||
|
a.Callsign = strings.Join(calls, ", ")
|
||||||
|
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
|
||||||
|
a.Calls = []string{c}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
|
||||||
|
// "Mar 3-20, 2026" (the month carried over).
|
||||||
|
var (
|
||||||
|
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
|
||||||
|
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
|
||||||
|
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDateRange(s string) (start, end string) {
|
||||||
|
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[3], m[2] + ", " + m[3]
|
||||||
|
}
|
||||||
|
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
|
||||||
|
}
|
||||||
|
if m := singleDayRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[2], m[1] + ", " + m[2]
|
||||||
|
}
|
||||||
|
return s, s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseADXODate(s string) (time.Time, bool) {
|
||||||
|
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
|
||||||
|
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func activationStatus(start, end string, now time.Time) string {
|
||||||
|
s, okS := parseADXODate(start)
|
||||||
|
e, okE := parseADXODate(end)
|
||||||
|
if !okS || !okE {
|
||||||
|
return "upcoming" // unreadable dates: keep it, an operator can read them
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case now.Before(s):
|
||||||
|
return "upcoming"
|
||||||
|
// The end date is a DAY, so an operation is on until that day is over.
|
||||||
|
case now.After(e.Add(24 * time.Hour)):
|
||||||
|
return "ended"
|
||||||
|
default:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsAfterAs mines "…as CALL…" out of the operators prose:
|
||||||
|
//
|
||||||
|
// "W2APF as PJ2/W2APF" → PJ2/W2APF
|
||||||
|
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
|
||||||
|
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
|
||||||
|
|
||||||
|
func callsAfterAs(operators string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(m[1]))
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
|
||||||
|
// matching the log against JG8NQJ/JD1 would find nothing.
|
||||||
|
func normalizeCall(call, dxccPrefix string) string {
|
||||||
|
if dxccPrefix == "" || !strings.Contains(call, "/") {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
left, right, _ := strings.Cut(call, "/")
|
||||||
|
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
|
||||||
|
return right + "/" + left
|
||||||
|
}
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// A span carries its unit only once — "160-6m" is the commonest way ADXO
|
||||||
|
// states coverage, and reading it as "6m" alone loses the whole low end.
|
||||||
|
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
|
||||||
|
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
|
||||||
|
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
|
||||||
|
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseBands(s string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(num, unit string) {
|
||||||
|
if num == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := strings.ToLower(num + unit)
|
||||||
|
if seen[b] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[b] = true
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
// A span keeps only its endpoints: ADXO states coverage in prose, and the
|
||||||
|
// two ends are the part it gives reliably.
|
||||||
|
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
|
||||||
|
add(m[1], m[3])
|
||||||
|
add(m[2], m[3])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModes(s string) []string {
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, mode := range knownModes {
|
||||||
|
if seen[mode] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
|
||||||
|
seen[mode] = true
|
||||||
|
out = append(out, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DX-World ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func stripHTML(s string) string {
|
||||||
|
s = htmlTagRe.ReplaceAllString(s, " ")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
|
||||||
|
body, err := m.get(ctx, dxworldURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: parse: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]News, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
title := stripHTML(it.Title)
|
||||||
|
tag, title := splitTag(title)
|
||||||
|
n := News{
|
||||||
|
Title: title,
|
||||||
|
Link: strings.TrimSpace(it.Link),
|
||||||
|
Creator: strings.TrimSpace(it.Creator),
|
||||||
|
Tag: tag,
|
||||||
|
Calls: callsInHeadline(title),
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
ex := stripHTML(it.Description)
|
||||||
|
if t2, rest := splitTag(ex); t2 != "" {
|
||||||
|
if n.Tag == "" {
|
||||||
|
n.Tag = t2
|
||||||
|
}
|
||||||
|
ex = rest
|
||||||
|
}
|
||||||
|
n.Excerpt = truncateRunes(ex, 400)
|
||||||
|
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
|
||||||
|
func splitTag(s string) (tag, rest string) {
|
||||||
|
m := tagPrefixRe.FindStringSubmatchIndex(s)
|
||||||
|
if m == nil {
|
||||||
|
return "", s
|
||||||
|
}
|
||||||
|
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
|
||||||
|
rest = strings.TrimSpace(s[m[1]:])
|
||||||
|
rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ")
|
||||||
|
return tag, strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:max])) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
|
||||||
|
// or "TX5S team lands". The reader can then chase or watch them, which is the
|
||||||
|
// whole reason a news feed sits next to the announcements.
|
||||||
|
func callsInHeadline(title string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
|
||||||
|
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
|
||||||
|
}) {
|
||||||
|
c := strings.ToUpper(tok)
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
|
||||||
|
// Jargon shaped exactly like a callsign. Every one of these was seen in a
|
||||||
|
// real headline before it earned its place here.
|
||||||
|
notACall = map[string]bool{
|
||||||
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
|
||||||
|
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
|
||||||
|
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
|
||||||
|
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters
|
||||||
|
// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one
|
||||||
|
// digit, and a letter somewhere after the first digit — which is what separates
|
||||||
|
// a callsign from a band or a year.
|
||||||
|
func plausibleCall(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Judge the longest part — the real call in "PJ2/W2APF" either way.
|
||||||
|
base := s
|
||||||
|
if strings.Contains(s, "/") {
|
||||||
|
base = ""
|
||||||
|
for _, p := range strings.Split(s, "/") {
|
||||||
|
if len(p) > len(base) {
|
||||||
|
base = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hasLetter, hasDigit, letterAfterDigit bool
|
||||||
|
seenDigit := false
|
||||||
|
for _, r := range base {
|
||||||
|
switch {
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
hasLetter = true
|
||||||
|
if seenDigit {
|
||||||
|
letterAfterDigit = true
|
||||||
|
}
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
seenDigit = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit && letterAfterDigit
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pinned against REAL feed text: the parser reads a fixed sentence, and a
|
||||||
|
// wrong split silently empties a DXpedition list rather than failing loudly.
|
||||||
|
func TestParseActivation(t *testing.T) {
|
||||||
|
desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " +
|
||||||
|
"Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals"
|
||||||
|
a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja")
|
||||||
|
if a == nil {
|
||||||
|
t.Fatal("parseActivation returned nil on a real ADXO description")
|
||||||
|
}
|
||||||
|
if a.DXCC != "St Kitts and Nevis" {
|
||||||
|
t.Errorf("DXCC = %q", a.DXCC)
|
||||||
|
}
|
||||||
|
if a.Callsign != "V47JA" {
|
||||||
|
t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign)
|
||||||
|
}
|
||||||
|
if a.QSL != "LoTW" {
|
||||||
|
t.Errorf("QSL = %q", a.QSL)
|
||||||
|
}
|
||||||
|
if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" {
|
||||||
|
t.Errorf("dates = %q..%q", a.StartDate, a.EndDate)
|
||||||
|
}
|
||||||
|
if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) {
|
||||||
|
t.Errorf("Bands = %v, want %v", a.Bands, want)
|
||||||
|
}
|
||||||
|
if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) {
|
||||||
|
t.Errorf("Modes = %v, want %v", a.Modes, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign column often holds only the prefix; the operators prose holds
|
||||||
|
// the real thing, and a slashed call must lead with the DXCC prefix or no spot
|
||||||
|
// will ever match it.
|
||||||
|
func TestCallsAfterAsAndNormalise(t *testing.T) {
|
||||||
|
if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) {
|
||||||
|
t.Errorf("callsAfterAs = %v", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" {
|
||||||
|
t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" {
|
||||||
|
t.Errorf("normalizeCall rewrote an already-correct call: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationStatus(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct{ start, end, want string }{
|
||||||
|
{"Aug 24, 2026", "Aug 31, 2026", "active"},
|
||||||
|
{"Sep 10, 2026", "Sep 20, 2026", "upcoming"},
|
||||||
|
{"Aug 1, 2026", "Aug 10, 2026", "ended"},
|
||||||
|
{"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on
|
||||||
|
{"garbage", "garbage", "upcoming"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := activationStatus(c.start, c.end, now); got != c.want {
|
||||||
|
t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mining a headline must find calls without inventing them out of jargon.
|
||||||
|
func TestCallsInHeadline(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
title string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"3B7M, St Brandon", []string{"3B7M"}},
|
||||||
|
{"TX5S team lands on Clipperton", []string{"TX5S"}},
|
||||||
|
{"FT8 activity on 160m in 2026", nil},
|
||||||
|
{"VP6D QSL via OQRS, LoTW", []string{"VP6D"}},
|
||||||
|
{"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ const (
|
|||||||
ServiceCloudlog Service = "cloudlog"
|
ServiceCloudlog Service = "cloudlog"
|
||||||
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
|
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
|
||||||
ServiceHamlog Service = "hamlog"
|
ServiceHamlog Service = "hamlog"
|
||||||
|
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
|
||||||
|
// one ADIF record per QSO.
|
||||||
|
ServiceHamQTH Service = "hamqth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -133,6 +136,7 @@ type ExternalServices struct {
|
|||||||
EQSL ServiceConfig `json:"eqsl"`
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||||
Hamlog ServiceConfig `json:"hamlog"`
|
Hamlog ServiceConfig `json:"hamlog"`
|
||||||
|
HamQTH ServiceConfig `json:"hamqth"`
|
||||||
|
|
||||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||||
// it is deleted locally. Off unless the operator turns it on: neither
|
// it is deleted locally. Off unless the operator turns it on: neither
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
|
|||||||
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrHamlogClosed is why nothing is sent to HAMLOG.online any more.
|
||||||
|
//
|
||||||
|
// The site stopped issuing API keys, and the upload API takes nothing else. An
|
||||||
|
// operator without a key cannot obtain one, and one WITH an old key is the
|
||||||
|
// exception this cannot be built around — so the door is closed here rather
|
||||||
|
// than left ajar for a request that can only fail.
|
||||||
|
//
|
||||||
|
// The code stays: their confirmations still arrive as an ADIF FILE (QSL Manager
|
||||||
|
// → HAMLOG.online → Import confirmations), which never needed a key, and the
|
||||||
|
// sent/received state already in operators' logs stays readable, filterable and
|
||||||
|
// bulk-editable.
|
||||||
|
var ErrHamlogClosed = errors.New("hamlog: HAMLOG.online no longer issues API keys, so uploading is not possible — their confirmations can still be imported from a file")
|
||||||
|
|
||||||
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
return UploadResult{}, ErrHamlogClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadHamlogLive is the upload as it was, kept whole against the day keys
|
||||||
|
// come back. Nothing calls it.
|
||||||
|
func uploadHamlogLive(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
key := strings.TrimSpace(cfg.APIKey)
|
key := strings.TrimSpace(cfg.APIKey)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(tc.body))
|
_, _ = w.Write([]byte(tc.body))
|
||||||
}))
|
}))
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
||||||
srv.Close()
|
srv.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: %v", tc.body, err)
|
t.Fatalf("%s: %v", tc.body, err)
|
||||||
@@ -80,8 +81,21 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
|
|
||||||
// Nothing leaves without a key, and the message says where to get one.
|
// Nothing leaves without a key, and the message says where to get one.
|
||||||
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
||||||
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>")
|
_, err := uploadHamlogLive(context.Background(), nil, "http://example.invalid", ServiceConfig{}, "<eor>")
|
||||||
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
||||||
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The door is shut: HAMLOG.online stopped issuing API keys, so an upload that
|
||||||
|
// could only fail is refused before it is attempted. The request-shape tests
|
||||||
|
// above still cover the code kept against the day keys come back.
|
||||||
|
func TestUploadHamlogIsClosed(t *testing.T) {
|
||||||
|
res, err := UploadHamlog(context.Background(), nil, ServiceConfig{APIKey: "KEY123"}, "<eor>")
|
||||||
|
if !errors.Is(err, ErrHamlogClosed) {
|
||||||
|
t.Fatalf("err = %v, want ErrHamlogClosed", err)
|
||||||
|
}
|
||||||
|
if res.OK {
|
||||||
|
t.Error("a refused upload reported success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HamQTH real-time QSO upload.
|
||||||
|
//
|
||||||
|
// One POST per QSO to qso_realtime.php with the account username/password —
|
||||||
|
// the same credentials the HamQTH callbook lookup uses. The answer is the
|
||||||
|
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
|
||||||
|
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
|
||||||
|
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
|
||||||
|
|
||||||
|
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
|
||||||
|
// documentation says "you always have to upload whole log. HamQTH doesn't
|
||||||
|
// support partial upload" — the file REPLACES what is on the site. That is why
|
||||||
|
// it is not the batch path for a selection, and why the caller must have said
|
||||||
|
// so out loud before we get here.
|
||||||
|
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
|
||||||
|
|
||||||
|
// hamqthMaxUpload is the documented ceiling for one upload.
|
||||||
|
const hamqthMaxUpload = 20 << 20
|
||||||
|
|
||||||
|
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
|
||||||
|
// under the limit: the multipart envelope and the form fields ride along too.
|
||||||
|
const hamqthCompressAbove = 12 << 20
|
||||||
|
|
||||||
|
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
|
||||||
|
// endpoint that cannot change anything in the log, which is what the settings
|
||||||
|
// Test button must call.
|
||||||
|
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
|
||||||
|
|
||||||
|
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
|
||||||
|
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
switch {
|
||||||
|
case user == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||||
|
}
|
||||||
|
rec := strings.TrimSpace(adifRecord)
|
||||||
|
if rec == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("u", user)
|
||||||
|
form.Set("p", cfg.Password)
|
||||||
|
// c: the logbook callsign when the account holds several; empty means the
|
||||||
|
// account's own call, which is the common case.
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||||
|
form.Set("c", c)
|
||||||
|
}
|
||||||
|
form.Set("adif", rec)
|
||||||
|
form.Set("prg", "OpsLog")
|
||||||
|
form.Set("cmd", "insert")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
return UploadResult{OK: true}, nil
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
|
||||||
|
// purposes — the QSO is in the logbook, retrying it forever isn't —
|
||||||
|
// same treatment HRDLog's <insert>0 gets.
|
||||||
|
if strings.Contains(strings.ToLower(msg), "dupl") {
|
||||||
|
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "QSO rejected"
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: msg}, nil
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
|
||||||
|
default:
|
||||||
|
if msg != "" && len(msg) < 200 {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
|
||||||
|
//
|
||||||
|
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
|
||||||
|
// for this callsign that is not in this file stops existing. The caller owns
|
||||||
|
// the confirmation.
|
||||||
|
//
|
||||||
|
// The file goes in the multipart field "f" (HamQTH's own curl example:
|
||||||
|
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
|
||||||
|
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
|
||||||
|
// 20 MB and a six-figure log passes it as plain text.
|
||||||
|
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
switch {
|
||||||
|
case user == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||||
|
case strings.TrimSpace(adifText) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte(adifText)
|
||||||
|
name := "opslog.adi"
|
||||||
|
if len(payload) > hamqthCompressAbove {
|
||||||
|
gz, err := tarGzADIF(payload)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
|
||||||
|
}
|
||||||
|
payload, name = gz, "opslog.tar.gz"
|
||||||
|
}
|
||||||
|
if len(payload) > hamqthMaxUpload {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
|
||||||
|
len(payload)>>20, hamqthMaxUpload>>20)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("u", user)
|
||||||
|
_ = mw.WriteField("p", cfg.Password)
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||||
|
_ = mw.WriteField("c", c)
|
||||||
|
}
|
||||||
|
_ = mw.WriteField("send_log", "OK")
|
||||||
|
fw, err := mw.CreateFormFile("f", name)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(payload); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if client == nil {
|
||||||
|
// A whole log is a long POST on a slow uplink.
|
||||||
|
client = &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
msg := strings.TrimSpace(string(raw))
|
||||||
|
if looksLikeHTML(msg) {
|
||||||
|
msg = ""
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg != "" && len(msg) < 300 {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// The site answers in prose, and only its own refusals are worth reading
|
||||||
|
// back: the ADIF itself is validated later, in the background, and any
|
||||||
|
// complaint about it reaches the operator by e-mail rather than here.
|
||||||
|
low := strings.ToLower(msg)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "successfully"):
|
||||||
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
|
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case strings.Contains(low, "cannot upload log for this callsign"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case msg == "":
|
||||||
|
// HTTP 200 with nothing to say: taken as accepted, and said so.
|
||||||
|
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
|
||||||
|
default:
|
||||||
|
return UploadResult{OK: false, Message: msg}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
|
||||||
|
// a .adi/.adif member for HamQTH to find the log in it.
|
||||||
|
func tarGzADIF(adif []byte) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&out)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(adif); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHamQTH verifies the credentials against the callbook session login —
|
||||||
|
// authenticated, and unable to touch the log.
|
||||||
|
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
if user == "" || cfg.Password == "" {
|
||||||
|
return "", fmt.Errorf("hamqth: set the username and password first")
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("u", user)
|
||||||
|
q.Set("p", cfg.Password)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
s := string(body)
|
||||||
|
if strings.Contains(s, "<session_id>") {
|
||||||
|
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
|
||||||
|
}
|
||||||
|
if i := strings.Index(s, "<error>"); i >= 0 {
|
||||||
|
e := s[i+len("<error>"):]
|
||||||
|
if j := strings.Index(e, "</error>"); j >= 0 {
|
||||||
|
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
|
|||||||
// they were already uploaded OR outside the callsign certificate's date range.
|
// they were already uploaded OR outside the callsign certificate's date range.
|
||||||
// Reporting either as success is how a contact came to be stamped "uploaded"
|
// Reporting either as success is how a contact came to be stamped "uploaded"
|
||||||
// while LoTW had never seen it.
|
// while LoTW had never seen it.
|
||||||
|
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
|
||||||
|
// subdivisions are the US county enumeration — "XX,County" with a two-letter
|
||||||
|
// state — and TQSL rejects the whole record over anything else, so a Canadian
|
||||||
|
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
|
||||||
|
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
|
||||||
|
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
|
||||||
|
|
||||||
|
func scrubMyCnty(adif string) string {
|
||||||
|
for {
|
||||||
|
loc := myCntyRe.FindStringSubmatchIndex(adif)
|
||||||
|
if loc == nil {
|
||||||
|
return adif
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
|
||||||
|
end := loc[1] + n
|
||||||
|
if end > len(adif) {
|
||||||
|
end = len(adif)
|
||||||
|
}
|
||||||
|
val := adif[loc[1]:end]
|
||||||
|
if len(val) > 3 && val[2] == ',' {
|
||||||
|
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
|
||||||
|
rest := scrubMyCnty(adif[end:])
|
||||||
|
return adif[:end] + rest
|
||||||
|
}
|
||||||
|
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||||
|
adifRecord = scrubMyCnty(adifRecord)
|
||||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
loc := strings.TrimSpace(cfg.StationLocation)
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
|
||||||
|
// validation is the US "XX,County" enumeration — so anything else must be
|
||||||
|
// stripped before signing, and the US shape must survive untouched.
|
||||||
|
func TestScrubMyCnty(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
|
||||||
|
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
|
||||||
|
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
|
||||||
|
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||||
|
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
|
||||||
|
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
|
||||||
|
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := scrubMyCnty(c.in); got != c.want {
|
||||||
|
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-11
@@ -87,6 +87,7 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
|
hamlogClosedOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
@@ -140,6 +141,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
cfg.EQSL = cfg.EQSL.normalised()
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||||
cfg.Hamlog = cfg.Hamlog.normalised()
|
cfg.Hamlog = cfg.Hamlog.normalised()
|
||||||
|
cfg.HamQTH = cfg.HamQTH.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
|
|
||||||
// Summary of what is armed, written at startup and on every settings save.
|
// Summary of what is armed, written at startup and on every settings save.
|
||||||
@@ -153,7 +155,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
}{
|
}{
|
||||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||||
{"hamlog", cfg.Hamlog},
|
{"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
|
||||||
} {
|
} {
|
||||||
if s.cfg.AutoUpload {
|
if s.cfg.AutoUpload {
|
||||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||||
@@ -229,12 +231,20 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
m.route(ServiceCloudlog, id, c)
|
m.route(ServiceCloudlog, id, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HAMLOG.online — one API key and nothing else to get wrong.
|
// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
|
||||||
if h := cfg.Hamlog; h.AutoUpload {
|
// session rather than per QSO, because an operator who left the switch on
|
||||||
if h.APIKey == "" {
|
// deserves to know why nothing leaves, and does not deserve it every minute.
|
||||||
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
|
if cfg.Hamlog.AutoUpload {
|
||||||
|
m.hamlogClosedOnce.Do(func() {
|
||||||
|
m.logf("extsvc: %v", ErrHamlogClosed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// HamQTH — the callbook credentials double as the logbook login.
|
||||||
|
if h := cfg.HamQTH; h.AutoUpload {
|
||||||
|
if h.Username == "" || h.Password == "" {
|
||||||
|
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
|
||||||
} else {
|
} else {
|
||||||
m.route(ServiceHamlog, id, h)
|
m.route(ServiceHamQTH, id, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,8 +297,9 @@ func (m *Manager) onCloseServices() []Service {
|
|||||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||||
out = append(out, ServiceCloudlog)
|
out = append(out, ServiceCloudlog)
|
||||||
}
|
}
|
||||||
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
|
|
||||||
out = append(out, ServiceHamlog)
|
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
|
||||||
|
out = append(out, ServiceHamQTH)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -336,8 +347,9 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||||
case ServiceCloudlog:
|
case ServiceCloudlog:
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||||
case ServiceHamlog:
|
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
|
case ServiceHamQTH:
|
||||||
|
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return uploaded
|
return uploaded
|
||||||
@@ -577,7 +589,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
switch svc {
|
switch svc {
|
||||||
case ServiceQRZ, ServiceLoTW:
|
case ServiceQRZ, ServiceLoTW:
|
||||||
owner = cfg.ForceStationCallsign
|
owner = cfg.ForceStationCallsign
|
||||||
case ServiceClublog, ServiceHRDLog:
|
case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
|
||||||
owner = cfg.Callsign
|
owner = cfg.Callsign
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
owner = cfg.Username
|
owner = cfg.Username
|
||||||
@@ -669,6 +681,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
|
||||||
|
case ServiceHamQTH:
|
||||||
|
// The c parameter names the logbook when the account holds several;
|
||||||
|
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
|
||||||
default:
|
default:
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,3 +113,27 @@ func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
|
|||||||
ev.DecodeModeRaw, "~")
|
ev.DecodeModeRaw, "~")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reported from a real shack: JTDX decoding FT4 showed up as Q65.
|
||||||
|
//
|
||||||
|
// The two forks disagree about ":" — Q65 in WSJT-X, FT4 in JTDX — so the
|
||||||
|
// character alone cannot answer, and the wrong answer poisons every verdict
|
||||||
|
// that follows: new mode, new slot, the mode filter. The program's own Status
|
||||||
|
// names the mode in full and settles it.
|
||||||
|
func TestAmbiguousModeCharDefersToTheProgram(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw, status, want, why string
|
||||||
|
}{
|
||||||
|
{":", "FT4", "FT4", "JTDX decoding FT4"},
|
||||||
|
{":", "Q65", "Q65", "WSJT-X decoding Q65"},
|
||||||
|
{":", "", "Q65", "no Status yet — WSJT-X's reading, the older and commoner"},
|
||||||
|
// The unambiguous markers are unaffected, Status or no Status.
|
||||||
|
{"~", "FT4", "FT8", "a tilde is FT8 in both"},
|
||||||
|
{"+", "", "FT4", "a plus is FT4 in both"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := DecodeModeName(c.raw, c.status); got != c.want {
|
||||||
|
t.Errorf("DecodeModeName(%q, %q) = %q, want %q — %s", c.raw, c.status, got, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -523,10 +523,24 @@ var decodeModeChar = map[string]string{
|
|||||||
"#": "JT65",
|
"#": "JT65",
|
||||||
"@": "JT9",
|
"@": "JT9",
|
||||||
"&": "MSK144",
|
"&": "MSK144",
|
||||||
":": "Q65",
|
|
||||||
"`": "FST4",
|
"`": "FST4",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ambiguousModeChar is a marker the forks do not agree on.
|
||||||
|
//
|
||||||
|
// ":" is Q65 in WSJT-X and FT4 in JTDX, so the character alone cannot answer:
|
||||||
|
// a JTDX operator decoding FT4 was told they were on Q65, and every verdict
|
||||||
|
// downstream — new mode, new slot, the mode filter — was computed against a
|
||||||
|
// mode nobody was using.
|
||||||
|
//
|
||||||
|
// The sender's own Status settles it. It comes from the same program, names the
|
||||||
|
// mode in full, and is re-sent whenever it changes, so it knows what that
|
||||||
|
// program is decoding in a way one character never can. The fallback is
|
||||||
|
// WSJT-X's reading, which is the older and commoner one.
|
||||||
|
var ambiguousModeChar = map[string]string{
|
||||||
|
":": "Q65",
|
||||||
|
}
|
||||||
|
|
||||||
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
|
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
|
||||||
// is the mode from the same program's last Status, used when the field is a
|
// is the mode from the same program's last Status, used when the field is a
|
||||||
// marker we do not know, or empty.
|
// marker we do not know, or empty.
|
||||||
@@ -535,6 +549,13 @@ func DecodeModeName(raw, statusMode string) string {
|
|||||||
if m, ok := decodeModeChar[raw]; ok {
|
if m, ok := decodeModeChar[raw]; ok {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
if fallback, ok := ambiguousModeChar[raw]; ok {
|
||||||
|
// Believe the program over the character it happened to print.
|
||||||
|
if st := strings.ToUpper(strings.TrimSpace(statusMode)); st != "" {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
|
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
|
||||||
// Anything shorter, or carrying punctuation, is a marker rather than a name.
|
// Anything shorter, or carrying punctuation, is a marker rather than a name.
|
||||||
if len(raw) >= 2 {
|
if len(raw) >= 2 {
|
||||||
|
|||||||
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
r.Callsign = call
|
r.Callsign = call
|
||||||
r.Source = p.Name()
|
r.Source = p.Name()
|
||||||
r.FetchedAt = time.Now().UTC()
|
r.FetchedAt = time.Now().UTC()
|
||||||
fillFromDXCC(&r, dxcc)
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
|
// Cached BEFORE the cty.dat pass, so the row is a copy of the
|
||||||
|
// callbook page rather than of our conclusions about it. Every
|
||||||
|
// read runs the pass again (see the cache-hit path above), so a
|
||||||
|
// later cty.dat update reaches old rows — and a value we derived
|
||||||
|
// can never come back looking like something the page said.
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r)
|
||||||
|
fillFromDXCC(&r, dxcc)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
if !saysNothingAboutLocation(call) {
|
if !saysNothingAboutLocation(call) {
|
||||||
clearHomeLocation(&r)
|
clearHomeLocation(&r)
|
||||||
}
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
|
||||||
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,11 +446,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
r.Continent = cont
|
r.Continent = cont
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if cqz != 0 {
|
// Zones FILL, they do not override.
|
||||||
|
//
|
||||||
|
// The rule above is right for the country and wrong for the zones, because
|
||||||
|
// they answer different questions. An entity is what a callsign IS, and
|
||||||
|
// cty.dat is the authority on that. A zone is where the station SITS, and a
|
||||||
|
// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
|
||||||
|
// and cty.dat carries one representative pair for the whole country. Stamping
|
||||||
|
// it on every UA0 threw away the callbook's per-station answer and recorded a
|
||||||
|
// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
|
||||||
|
// and was logged 17/30.
|
||||||
|
//
|
||||||
|
// So the callbook wins where it spoke, and cty.dat fills the silence.
|
||||||
|
if cqz != 0 && r.CQZ == 0 {
|
||||||
r.CQZ = cqz
|
r.CQZ = cqz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if ituz != 0 {
|
if ituz != 0 && r.ITUZ == 0 {
|
||||||
r.ITUZ = ituz
|
r.ITUZ = ituz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// asiaticRussia stands in for cty.dat: one representative zone pair for a
|
||||||
|
// country eight CQ zones wide.
|
||||||
|
func asiaticRussia(cqz, ituz int) fakeDXCC {
|
||||||
|
return fakeDXCC{"RU0LL": {num: 15, country: "Asiatic Russia", cont: "AS", cqz: cqz, ituz: ituz}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone is where the station SITS; an entity is what the callsign IS. Asiatic
|
||||||
|
// Russia spans CQ 16-23 and ITU 20-34, and cty.dat carries one representative
|
||||||
|
// pair for the whole country — so stamping it over a callbook's per-station
|
||||||
|
// answer records a WAZ credit for a zone the operator never worked.
|
||||||
|
//
|
||||||
|
// Reported with real callsigns: RU0LL is CQ 19 / ITU 34 and was logged 17/30.
|
||||||
|
func TestCallbookZonesSurviveTheEntityDefault(t *testing.T) {
|
||||||
|
// What QRZ said about this very station.
|
||||||
|
r := Result{Callsign: "RU0LL", CQZ: 19, ITUZ: 34}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 19 || r.ITUZ != 34 {
|
||||||
|
t.Errorf("callbook zones were overwritten: CQ%d ITU%d, want CQ19 ITU34", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
if r.Country != "Asiatic Russia" {
|
||||||
|
t.Errorf("the ENTITY must still come from cty.dat, got %q", r.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityZonesFillSilence(t *testing.T) {
|
||||||
|
// A callbook that says nothing about zones still gets an answer.
|
||||||
|
r := Result{Callsign: "RU0LL"}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 17 || r.ITUZ != 30 {
|
||||||
|
t.Errorf("empty zones were not filled: CQ%d ITU%d", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-2
@@ -995,6 +995,12 @@ var bulkEditableExtras = map[string]string{
|
|||||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||||
|
// HamQTH, same story and SENT only — the site publishes no confirmations,
|
||||||
|
// so there is no received side to edit. Bulk-editable for the one case that
|
||||||
|
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
|
||||||
|
// offer to send all over again.
|
||||||
|
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
|
||||||
|
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||||
@@ -1403,6 +1409,10 @@ var filterableExtras = map[string]string{
|
|||||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||||
|
// HamQTH — sent only, and filterable for the question that precedes every
|
||||||
|
// backlog upload: "which contacts have never gone there".
|
||||||
|
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
|
||||||
|
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterableFields returns the whitelist (for the frontend to build its field
|
// FilterableFields returns the whitelist (for the frontend to build its field
|
||||||
@@ -2003,7 +2013,7 @@ type WorkedBefore struct {
|
|||||||
// at all about yesterday.
|
// at all about yesterday.
|
||||||
type BandStatus struct {
|
type BandStatus struct {
|
||||||
Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
|
Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
|
||||||
Class string `json:"class"` // "PH" | "CW" | "DIG"
|
Class string `json:"class"` // "PH" | "CW" | "DIG", or a raw digital mode ("FT8", "RTTY"…)
|
||||||
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
|
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
|
||||||
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
|
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
|
||||||
Call string `json:"call,omitempty"`
|
Call string `json:"call,omitempty"`
|
||||||
@@ -2392,12 +2402,26 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
|||||||
return wb, fmt.Errorf("scan band status: %w", err)
|
return wb, fmt.Errorf("scan band status: %w", err)
|
||||||
}
|
}
|
||||||
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
|
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
|
||||||
k := cellKey{band: band, class: modeClass(mode)}
|
keys := []cellKey{{band: band, class: modeClass(mode)}}
|
||||||
|
// The DIGITAL row can be cycled through the individual modes in the UI —
|
||||||
|
// FT8, then FT4, then RTTY — so the same cell is also published under the
|
||||||
|
// raw mode name. The query already grouped by mode; only the collapse to
|
||||||
|
// a class threw that away, and re-asking the database for it would be a
|
||||||
|
// second scan to learn what we had just read.
|
||||||
|
//
|
||||||
|
// Digital only: PH and CW have nothing to cycle through, and publishing
|
||||||
|
// "SSB" beside "PH" would just double the payload.
|
||||||
|
if um := strings.ToUpper(mode); modeClass(mode) == "DIG" && um != "" {
|
||||||
|
keys = append(keys, cellKey{band: band, class: um})
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
if cur, ok := best[k]; !ok || code > cur {
|
if cur, ok := best[k]; !ok || code > cur {
|
||||||
best[k] = code
|
best[k] = code
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Confirmed beats worked here too, and neither is ever erased by the
|
// Confirmed beats worked here too, and neither is ever erased by the
|
||||||
// entity: this is only ever about the callsign.
|
// entity: this is only ever about the callsign.
|
||||||
|
for _, k := range keys {
|
||||||
switch {
|
switch {
|
||||||
case callC == 1:
|
case callC == 1:
|
||||||
callByCell[k] = "c"
|
callByCell[k] = "c"
|
||||||
@@ -2405,6 +2429,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
|||||||
callByCell[k] = "w"
|
callByCell[k] = "w"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
statusRows.Close()
|
statusRows.Close()
|
||||||
codeStr := bandStatusNames
|
codeStr := bandStatusNames
|
||||||
for k, code := range best {
|
for k, code := range best {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@@ -71,32 +72,99 @@ func NewSerial(comPort string, baud int) *Client {
|
|||||||
return &Client{ComPort: comPort, Baud: baud}
|
return &Client{ComPort: comPort, Baud: baud}
|
||||||
}
|
}
|
||||||
|
|
||||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
// bootSettle is how long a freshly opened serial port is left alone before the
|
||||||
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
// first command.
|
||||||
// reply line.
|
//
|
||||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
|
||||||
var conn io.ReadWriteCloser
|
// the serial port is opened: the DTR line pulses its reset pin, and the
|
||||||
if c.ComPort != "" {
|
// bootloader then holds the processor for a second or more. A command sent into
|
||||||
baud := c.Baud
|
// that window is simply lost, which is exactly how a controller that answers
|
||||||
|
// PuTTY perfectly reports "no reply" here.
|
||||||
|
const bootSettle = 2 * time.Second
|
||||||
|
|
||||||
|
// heldPort is an open serial port, kept between calls.
|
||||||
|
//
|
||||||
|
// The package holds it rather than the Client because the callers build a FRESH
|
||||||
|
// Client for every poll (one per heading request), and the port has to outlive
|
||||||
|
// them. Reopening per command is what made an Arduino controller reboot several
|
||||||
|
// times a second and never answer anything. A serial port is a single-owner
|
||||||
|
// resource in any case: two clients for COM5 would be two handles on one cable.
|
||||||
|
type heldPort struct {
|
||||||
|
p serial.Port
|
||||||
|
openedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
portsMu sync.Mutex
|
||||||
|
openPorts = map[string]*heldPort{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// acquire returns the open port for com, opening it if needed.
|
||||||
|
func acquire(com string, baud int) (*heldPort, error) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok && h.p != nil {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
if baud <= 0 {
|
if baud <= 0 {
|
||||||
baud = 9600
|
baud = 9600
|
||||||
}
|
}
|
||||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
||||||
}
|
}
|
||||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
conn = sp
|
h := &heldPort{p: sp, openedAt: time.Now()}
|
||||||
|
openPorts[com] = h
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop closes and forgets a port, so the next call opens a fresh one. Called
|
||||||
|
// when an exchange fails: a half-spoken conversation is worse than a new one.
|
||||||
|
func drop(com string) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok {
|
||||||
|
if h.p != nil {
|
||||||
|
_ = h.p.Close()
|
||||||
|
}
|
||||||
|
delete(openPorts, com)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTrip sends one CR-terminated command and (when wantReply) reads one
|
||||||
|
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
|
||||||
|
// dials per call, which is what the ARCO's LAN side expects.
|
||||||
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
h, err := acquire(c.ComPort, c.Baud)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Let a just-reset controller finish booting before speaking to it.
|
||||||
|
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
conn = h.p
|
||||||
|
// Whatever is already in the buffer belongs to the last exchange — the
|
||||||
|
// trailing LF of the previous reply, or a line the controller volunteered
|
||||||
|
// while nobody was reading. Read as the answer to THIS command it would
|
||||||
|
// be an answer to the wrong question.
|
||||||
|
drain(h.p)
|
||||||
} else {
|
} else {
|
||||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||||
}
|
}
|
||||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
defer nc.Close()
|
||||||
conn = nc
|
conn = nc
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
|
||||||
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
}
|
}
|
||||||
if !wantReply {
|
if !wantReply {
|
||||||
@@ -121,11 +189,29 @@ func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
|||||||
}
|
}
|
||||||
line := strings.TrimSpace(sb.String())
|
line := strings.TrimSpace(sb.String())
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
// Silence may mean the port is fine and the controller is not, or that
|
||||||
|
// the handle is stale (a USB adapter unplugged and replugged). Let go of
|
||||||
|
// it so the next attempt starts from a clean open rather than repeating
|
||||||
|
// the same silence for ever.
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("no reply to %q", cmd)
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
}
|
}
|
||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drain empties whatever is waiting, without blocking for long.
|
||||||
|
func drain(sp serial.Port) {
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
n, err := sp.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||||
// (overlap rotators accept >360); we normalise to [0,360).
|
// (overlap rotators accept >360); we normalise to [0,360).
|
||||||
func (c *Client) GoTo(az int) error {
|
func (c *Client) GoTo(az int) error {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package gs232
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Real replies, as the controllers actually send them — a K3NG answering "C"
|
||||||
|
// with "+0140" and CR+LF among them (reported from a live controller).
|
||||||
|
func TestAzimuthReplies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"+0140\r\n", 140}, // GS-232A, K3NG firmware
|
||||||
|
{"+0000\r", 0}, // due north
|
||||||
|
{"+0359\r\n", 359}, // just short of it
|
||||||
|
{"AZ=140\r\n", 140}, // GS-232B flavour
|
||||||
|
{"AZ=140 EL=000\r\n", 140}, // GS-232B with elevation on the same line
|
||||||
|
{"\r\n+0075\r\n", 75}, // a leftover terminator ahead of the answer
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m := azRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("no azimuth found in %q", c.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, _ := strconv.Atoi(m[1])
|
||||||
|
if got%360 != c.want {
|
||||||
|
t.Errorf("%q parsed as %d, want %d", c.raw, got%360, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,14 +48,14 @@ func hasFlag(args []string, flag string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||||
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
// try (fail → another OpsLog is running, so exit). On a --post-update or
|
||||||
// previous instance may still be shutting down and holding the mutex, so retry for
|
// --relaunch start the previous instance may still be shutting down and holding
|
||||||
// a few seconds until it frees.
|
// the mutex, so retry for a few seconds until it frees.
|
||||||
func acquireInstance(postUpdate bool) bool {
|
func acquireInstance(wait bool) bool {
|
||||||
if acquireSingleInstance() {
|
if acquireSingleInstance() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !postUpdate {
|
if !wait {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(20 * time.Second)
|
deadline := time.Now().Add(20 * time.Second)
|
||||||
@@ -90,7 +90,11 @@ func main() {
|
|||||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||||
bootLogLaunch()
|
bootLogLaunch()
|
||||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||||
if !acquireInstance(postUpdate) {
|
// A self-relaunch (database switch) races its own parent: the new process
|
||||||
|
// regularly wins the start against the old one's teardown, and the operator
|
||||||
|
// got "OpsLog is already running" for following instructions. Same patience
|
||||||
|
// as post-update.
|
||||||
|
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
|
||||||
// SAID, not merely done. This exit is correct — a second instance would
|
// SAID, not merely done. This exit is correct — a second instance would
|
||||||
// fight the first over the rig — but it happened in total silence: no
|
// fight the first over the rig — but it happened in total silence: no
|
||||||
// window, no data folder, no log, which is indistinguishable from a
|
// window, no data folder, no log, which is indistinguishable from a
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A contact that was not split still has a receive side, and the record
|
||||||
|
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
|
||||||
|
func TestFillRXDefaults(t *testing.T) {
|
||||||
|
hz := int64(14074000)
|
||||||
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
|
||||||
|
fillRXDefaults(&q)
|
||||||
|
if q.BandRX != "20m" {
|
||||||
|
t.Errorf("BandRX = %q, want 20m", q.BandRX)
|
||||||
|
}
|
||||||
|
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
|
||||||
|
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
|
||||||
|
}
|
||||||
|
// Assert on the RECORD another logger reads, not merely on the struct:
|
||||||
|
// both halves of the receive side have to reach it.
|
||||||
|
rec := strings.ToUpper(adif.SingleRecordADIF(q))
|
||||||
|
if !strings.Contains(rec, "<BAND_RX:3>20M") {
|
||||||
|
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec, "<FREQ_RX:9>14.074000") {
|
||||||
|
t.Errorf("FREQ_RX missing from the forwarded record:\n%s", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine split contact keeps what it was given.
|
||||||
|
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
|
||||||
|
tx, rx := int64(14195000), int64(14205000)
|
||||||
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
|
||||||
|
fillRXDefaults(&q)
|
||||||
|
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
|
||||||
|
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,6 +314,30 @@ func bracket(s string) string {
|
|||||||
//
|
//
|
||||||
// Read from the same cached palette as the colours — see spotColorFor — because
|
// Read from the same cached palette as the colours — see spotColorFor — because
|
||||||
// it is consulted on every spot from every cluster.
|
// it is consulted on every spot from every cluster.
|
||||||
|
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
|
||||||
|
//
|
||||||
|
// The panadapter has finite room: spots close in frequency are stacked behind a
|
||||||
|
// "+" and only one of them is drawn. The radio chooses by priority — so an
|
||||||
|
// entity never worked was disappearing behind three stations already in the
|
||||||
|
// log, simply because OpsLog never said which was worth the space.
|
||||||
|
//
|
||||||
|
// The tiers are the operator's own: the entity never worked first, then the
|
||||||
|
// pieces of one already worked (band, mode, slot), then the reference hunts
|
||||||
|
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
|
||||||
|
// at the top with the first — it is how a multi-op sees where it already is.
|
||||||
|
func spotPriority(status string) int {
|
||||||
|
switch status {
|
||||||
|
case "new", "my-call":
|
||||||
|
return 1
|
||||||
|
case "new-band-mode", "new-band", "new-mode", "new-slot":
|
||||||
|
return 2
|
||||||
|
case "new-pota", "new-sota", "new-county", "new-pfx":
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) spotHidden(status string) bool {
|
func (a *App) spotHidden(status string) bool {
|
||||||
a.spotColorsMu.Lock()
|
a.spotColorsMu.Lock()
|
||||||
if a.spotColorsCache == nil {
|
if a.spotColorsCache == nil {
|
||||||
|
|||||||
+1
-1
@@ -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.27.5"
|
appVersion = "0.27.11"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The operator's report: South Africa worked on 15m FT8 but confirmed only by
|
||||||
|
// eQSL/Club Log/QRZ, with the chase set to "new + unconfirmed" over LoTW and
|
||||||
|
// paper QSL. The verdict must be NEW BAND — that is what the settings ask for —
|
||||||
|
// and it must be flagged UNCONFIRMED, so the badge reads "worked, QSL missing"
|
||||||
|
// rather than "never worked here".
|
||||||
|
func TestUnconfirmedBandIsFlagged(t *testing.T) {
|
||||||
|
a := syncTestApp(t)
|
||||||
|
hz := int64(21074000)
|
||||||
|
add := func(call, band, mode string, lotw, eqsl string, dxcc int) {
|
||||||
|
q := qso.QSO{
|
||||||
|
Callsign: call, Band: band, Mode: mode,
|
||||||
|
QSODate: time.Now().UTC().Add(-48 * time.Hour),
|
||||||
|
FreqHz: &hz, DXCC: &dxcc, Country: "South Africa",
|
||||||
|
LOTWRcvd: lotw, EQSLRcvd: eqsl,
|
||||||
|
}
|
||||||
|
if _, err := a.qso.Add(a.ctx, q); err != nil {
|
||||||
|
t.Fatalf("add %s: %v", call, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 20m is LoTW-confirmed, so the ENTITY is confirmed…
|
||||||
|
add("ZS1AAA", "20m", "FT8", "Y", "Y", 462)
|
||||||
|
// …but every 15m contact is confirmed only by eQSL, which this operator
|
||||||
|
// does not count.
|
||||||
|
add("ZS6GAV", "15m", "FT8", "N", "Y", 462)
|
||||||
|
add("ZS4AW", "15m", "FT8", "N", "N", 462)
|
||||||
|
|
||||||
|
pred := qso.ConfirmSourcesPredicate([]string{"lotw", "card"})
|
||||||
|
all, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("all ledger: %v", err)
|
||||||
|
}
|
||||||
|
conf, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, pred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirmed ledger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := all[462]; !ok {
|
||||||
|
t.Fatal("the all-QSO ledger has no South Africa at all")
|
||||||
|
}
|
||||||
|
if _, ok := all[462].Bands["15m"]; !ok {
|
||||||
|
t.Error("all-QSO ledger: 15m missing — the dimming test can never fire")
|
||||||
|
}
|
||||||
|
if _, ok := all[462].Slots["15m"]["FT8"]; !ok {
|
||||||
|
t.Error("all-QSO ledger: 15m/FT8 slot missing")
|
||||||
|
}
|
||||||
|
c, ok := conf[462]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("confirmed ledger: South Africa missing — the 20m LoTW QSO should put it there")
|
||||||
|
}
|
||||||
|
if _, ok := c.Bands["15m"]; ok {
|
||||||
|
t.Error("confirmed ledger has 15m, but no 15m QSO is confirmed by LoTW or card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two halves the UI shows.
|
||||||
|
status := spotEntityStatus(true, false, true, false, "FT8")
|
||||||
|
if status != "new-band" {
|
||||||
|
t.Errorf("status = %q, want new-band", status)
|
||||||
|
}
|
||||||
|
_, bAll := all[462].Bands["15m"]
|
||||||
|
_, mAll := all[462].Modes["FT8"]
|
||||||
|
_, sAll := all[462].Slots["15m"]["FT8"]
|
||||||
|
if got := spotEntityStatus(true, bAll, mAll, sAll, "FT8"); got != "worked" {
|
||||||
|
t.Errorf("all-ledger verdict = %q, want worked (→ the badge should be dimmed)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The operator's exact case, end to end through the real verdict path.
|
||||||
|
//
|
||||||
|
// South Africa is confirmed (LoTW) on 20m and worked-but-unconfirmed on 15m,
|
||||||
|
// with the hunt set to "new + unconfirmed" over LoTW and paper QSL. A 15m FT8
|
||||||
|
// decode must come back NEW BAND — that is what the settings ask — and carry
|
||||||
|
// the UNCONFIRMED flag, which is what draws the badge as a missing QSL rather
|
||||||
|
// than a band never worked.
|
||||||
|
func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "log.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
|
||||||
|
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
|
||||||
|
a.settingsScoped.Store(true)
|
||||||
|
if err := a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed"); err != nil {
|
||||||
|
t.Fatalf("set chase mode: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card"); err != nil {
|
||||||
|
t.Fatalf("set chase sources: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real prefix table, so ZS4AW resolves the way it does in the app.
|
||||||
|
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
|
||||||
|
if err := a.dxcc.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
za := 462
|
||||||
|
add := func(call, band, mode, lotw string) {
|
||||||
|
hz := int64(21074000)
|
||||||
|
if _, err := a.qso.Add(a.ctx, qso.QSO{
|
||||||
|
Callsign: call, Band: band, Mode: mode, FreqHz: &hz,
|
||||||
|
QSODate: time.Now().UTC().Add(-72 * time.Hour),
|
||||||
|
DXCC: &za, Country: "South Africa", LOTWRcvd: lotw,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("add %s: %v", call, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("ZS1AAA", "20m", "FT8", "Y") // the entity IS confirmed, elsewhere
|
||||||
|
add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed
|
||||||
|
add("ZS4AW", "15m", "FT8", "N")
|
||||||
|
|
||||||
|
// A station of the entity that is NOT the one already worked on this slot:
|
||||||
|
// that case has a rule of its own, tested below.
|
||||||
|
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS9XX", Band: "15m", Mode: "FT8"}})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d results", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Status != "new-band" {
|
||||||
|
t.Fatalf("status = %q, want new-band", got[0].Status)
|
||||||
|
}
|
||||||
|
if !got[0].UnconfStatus {
|
||||||
|
t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same station, already worked on the same band and mode, has nothing left
|
||||||
|
// to give: working it again cannot turn a missing QSL into a confirmation. The
|
||||||
|
// need belongs to the entity, not to that callsign, so the badge goes quiet on
|
||||||
|
// it — and stays on every other station of the entity.
|
||||||
|
func TestWorkedSameSlotDropsTheUnconfirmedBadge(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "log.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
|
||||||
|
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
|
||||||
|
a.settingsScoped.Store(true)
|
||||||
|
_ = a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed")
|
||||||
|
_ = a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card")
|
||||||
|
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
|
||||||
|
if err := a.dxcc.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hz := int64(10136000)
|
||||||
|
if _, err := a.qso.Add(a.ctx, qso.QSO{
|
||||||
|
Callsign: "TN8GD", Band: "30m", Mode: "FT8", FreqHz: &hz,
|
||||||
|
QSODate: time.Now().UTC().Add(-24 * time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := a.ClusterSpotStatuses([]SpotQuery{
|
||||||
|
{Call: "TN8GD", Band: "30m", Mode: "FT8"}, // the very station worked
|
||||||
|
{Call: "TN4XY", Band: "30m", Mode: "FT8"}, // another in the same entity
|
||||||
|
})
|
||||||
|
if got[0].Status == "new" || got[0].UnconfStatus {
|
||||||
|
t.Errorf("the worked station still advertises a need: status=%q unconf=%v",
|
||||||
|
got[0].Status, got[0].UnconfStatus)
|
||||||
|
}
|
||||||
|
if got[1].Status != "new" {
|
||||||
|
t.Errorf("another station in the entity lost its badge: status=%q", got[1].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user