Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 |
@@ -116,6 +116,7 @@ const (
|
|||||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||||
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
||||||
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
|
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
|
||||||
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||||
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
|
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
|
||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||||
@@ -272,6 +273,9 @@ const (
|
|||||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||||
// will add their own keys under the same extsvc.* prefix.
|
// will add their own keys under the same extsvc.* prefix.
|
||||||
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
||||||
|
// Opt-in, and off by default: withdrawing a QSO from QRZ or Club Log cannot
|
||||||
|
// be undone, and a local delete is not always meant to reach the world.
|
||||||
|
keyExtDeleteRemote = "extsvc.delete_remote" // also delete from QRZ/Club Log when a QSO is deleted
|
||||||
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
|
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
|
||||||
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
|
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
|
||||||
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
|
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
|
||||||
@@ -359,6 +363,8 @@ type CATSettings struct {
|
|||||||
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
||||||
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
||||||
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
|
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
|
||||||
|
// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
|
||||||
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||||
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
||||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||||
@@ -536,6 +542,11 @@ type App struct {
|
|||||||
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
|
// qsoRecManual marks a take the operator started by hand while automatic
|
||||||
|
// recording is OFF. Such a take opened the sound devices itself, so it must
|
||||||
|
// close them again when it ends — an operator who records one contact does
|
||||||
|
// not expect their microphone held open for the rest of the session.
|
||||||
|
qsoRecManual atomic.Bool
|
||||||
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
|
||||||
@@ -5505,15 +5516,77 @@ func (a *App) DeleteQSO(id int64) error {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
a.deleteRemoteCopies([]int64{id})
|
||||||
return a.qso.Delete(a.ctx, id)
|
return a.qso.Delete(a.ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteRemoteCopies withdraws QSOs from QRZ.com and Club Log, when the
|
||||||
|
// operator has asked for that.
|
||||||
|
//
|
||||||
|
// Called BEFORE the local delete, because both services need the QSO's own
|
||||||
|
// fields to find their copy — Club Log matches on callsign, exact time and
|
||||||
|
// band, and QRZ needs the logid stored on the record. Once the row is gone
|
||||||
|
// there is nothing left to ask with.
|
||||||
|
//
|
||||||
|
// Failures never block the local delete. The operator asked for this QSO to go;
|
||||||
|
// a refusing website is not a reason to keep it, and the log says what happened
|
||||||
|
// so it can be sorted out on the site afterwards.
|
||||||
|
func (a *App) deleteRemoteCopies(ids []int64) {
|
||||||
|
if a.qso == nil || len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
if a.profileHasGroup(markerExtsvc) {
|
||||||
|
prefix = a.profileScope()
|
||||||
|
}
|
||||||
|
m, err := a.getManyScoped(prefix, keyExtDeleteRemote)
|
||||||
|
if err != nil || m[keyExtDeleteRemote] != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := a.loadExternalServices()
|
||||||
|
doQRZ := strings.TrimSpace(cfg.QRZ.APIKey) != ""
|
||||||
|
doClublog := strings.TrimSpace(cfg.Clublog.Email) != "" && cfg.Clublog.Password != ""
|
||||||
|
if !doQRZ && !doClublog {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil || q.Callsign == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if doQRZ {
|
||||||
|
logID := ""
|
||||||
|
if q.Extras != nil {
|
||||||
|
logID = q.Extras["APP_OPSLOG_QRZ_LOGID"]
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(logID) == "" {
|
||||||
|
// Uploaded before OpsLog kept the identifier, or never uploaded.
|
||||||
|
// QRZ has no delete-by-callsign-and-date, so say so plainly rather
|
||||||
|
// than let the operator believe it was withdrawn.
|
||||||
|
applog.Printf("extsvc: QSO %d (%s) has no QRZ logid — remove it on qrz.com by hand", id, q.Callsign)
|
||||||
|
} else if msg, err := extsvc.DeleteQRZ(a.ctx, nil, cfg.QRZ.APIKey, []string{logID}); err != nil {
|
||||||
|
applog.Printf("extsvc: QRZ delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||||
|
} else {
|
||||||
|
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if doClublog {
|
||||||
|
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
|
||||||
|
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||||
|
} else {
|
||||||
|
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||||
// number actually deleted.
|
// number actually deleted.
|
||||||
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
a.deleteRemoteCopies(ids)
|
||||||
return a.qso.DeleteMany(a.ctx, ids)
|
return a.qso.DeleteMany(a.ctx, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6653,7 +6726,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, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -6672,6 +6745,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
YaesuPort: m[keyCATYaesuPort],
|
YaesuPort: m[keyCATYaesuPort],
|
||||||
YaesuBaud: 38400,
|
YaesuBaud: 38400,
|
||||||
KenwoodPort: m[keyCATKenwoodPort],
|
KenwoodPort: m[keyCATKenwoodPort],
|
||||||
|
KenwoodHost: m[keyCATKenwoodHost],
|
||||||
KenwoodBaud: 9600,
|
KenwoodBaud: 9600,
|
||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
@@ -6827,6 +6901,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||||
|
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
|
||||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||||
@@ -7054,10 +7129,20 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
|
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Every exit from here ends the take, including the digital-mode discard and
|
||||||
|
// the snapshot failure below — so release the devices from one place rather
|
||||||
|
// than remembering to do it at each return.
|
||||||
|
defer a.stopManualQSORecorder()
|
||||||
|
// Both of these were SILENT returns. When an operator reports "the recording
|
||||||
|
// was not saved", the log has to say which of the two happened — no take was
|
||||||
|
// running, or the mode disqualified it — because they are different problems
|
||||||
|
// with different fixes and neither leaves a file behind to inspect.
|
||||||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||||||
|
applog.Printf("qso-rec: nothing saved for %s — no recording was running at log time", q.Callsign)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !recordableMode(q.Mode) {
|
if !recordableMode(q.Mode) {
|
||||||
|
applog.Printf("qso-rec: nothing saved for %s — mode %q carries no audio worth keeping", q.Callsign, q.Mode)
|
||||||
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
|
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -7531,12 +7616,146 @@ func (a *App) QSOAudioResetClock() bool {
|
|||||||
return a.qsoRec.Active()
|
return a.qsoRec.Active()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSOAudioManualReady reports whether a recording can be started BY HAND: the
|
||||||
|
// capture devices are configured, but automatic recording is off.
|
||||||
|
//
|
||||||
|
// It is what decides whether the entry strip offers a record button. Without
|
||||||
|
// it the button would appear for operators who have no sound card configured
|
||||||
|
// and could only ever produce an error.
|
||||||
|
func (a *App) QSOAudioManualReady() bool {
|
||||||
|
if a.qsoRec == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
return !cfg.QSORecord && strings.TrimSpace(cfg.FromRadio) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSOAudioManualStart begins a recording when automatic recording is off.
|
||||||
|
//
|
||||||
|
// With the automatic recorder disabled the capture engine is not merely idle,
|
||||||
|
// it is not running at all — so this starts it first. That costs the pre-roll:
|
||||||
|
// an automatic recording opens with the seconds BEFORE the callsign was typed,
|
||||||
|
// a manual one can only start now. Better to say that in a comment than to have
|
||||||
|
// an operator wonder why one file has the DX's call in it and the other does
|
||||||
|
// not.
|
||||||
|
func (a *App) QSOAudioManualStart() bool {
|
||||||
|
if a.qsoRec == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !a.qsoRec.Running() {
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if strings.TrimSpace(cfg.FromRadio) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||||
|
applog.Printf("qso-rec: manual start failed: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
|
||||||
|
if cfg.FromGain == 0 {
|
||||||
|
fromGain = 1
|
||||||
|
}
|
||||||
|
if cfg.MicGain == 0 {
|
||||||
|
micGain = 1
|
||||||
|
}
|
||||||
|
a.qsoRec.SetGains(fromGain, micGain)
|
||||||
|
a.qsoRecManual.Store(true)
|
||||||
|
applog.Printf("qso-rec: manual recording started by the operator")
|
||||||
|
}
|
||||||
|
a.qsoRec.BeginQSO()
|
||||||
|
return a.qsoRec.Active()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopManualQSORecorder releases the sound devices after a hand-started take,
|
||||||
|
// but only if this session opened them. When automatic recording is on the
|
||||||
|
// engine belongs to the app and must keep running for the next contact.
|
||||||
|
func (a *App) stopManualQSORecorder() {
|
||||||
|
if a.qsoRec == nil || !a.qsoRecManual.Swap(false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.qsoRec.Stop()
|
||||||
|
applog.Printf("qso-rec: manual recording finished — sound devices released")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSOAudioStop freezes the recording without ending it, so it can be played
|
||||||
|
// back and still be saved with the QSO afterwards.
|
||||||
|
func (a *App) QSOAudioStop() bool {
|
||||||
|
if a.qsoRec == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.qsoRec.PauseQSO()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSOAudioResume continues a frozen recording, appending to what is there.
|
||||||
|
func (a *App) QSOAudioResume() bool {
|
||||||
|
if a.qsoRec == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.qsoRec.ResumeQSO()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSOAudioPlayOnAir transmits the recording made so far, through the DVK's
|
||||||
|
// output device and PTT.
|
||||||
|
//
|
||||||
|
// The situation this is for: you are working a station, you have been recording,
|
||||||
|
// and they ask to hear it. You stop, you send it to them, and the recording is
|
||||||
|
// still saved with the QSO.
|
||||||
|
//
|
||||||
|
// It goes out over the AIR. So it refuses unless the take has been stopped
|
||||||
|
// first: playing while still recording would feed the transmitted audio back
|
||||||
|
// into the same take, and on a rig monitoring its own transmission that is a
|
||||||
|
// loop. Stopping is the operator's statement that the take is finished, and it
|
||||||
|
// is one click they have already made.
|
||||||
|
func (a *App) QSOAudioPlayOnAir() error {
|
||||||
|
if a.qsoRec == nil || a.audioMgr == nil {
|
||||||
|
return fmt.Errorf("audio not initialized")
|
||||||
|
}
|
||||||
|
if !a.qsoRec.Paused() {
|
||||||
|
return fmt.Errorf("stop the recording before playing it on the air")
|
||||||
|
}
|
||||||
|
pcm, err := a.qsoRec.PeekQSO()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A plain WAV in the data dir, overwritten each time: the player takes a
|
||||||
|
// path, and MP3 encoding would add seconds to something the operator is
|
||||||
|
// waiting on with the transmitter keyed.
|
||||||
|
path := filepath.Join(a.dataDir, "qso_playback.wav")
|
||||||
|
if err := audio.WritePCM(path, pcm); err != nil {
|
||||||
|
return fmt.Errorf("prepare playback: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if err := a.pttKey(cfg); err != nil {
|
||||||
|
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
||||||
|
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
||||||
|
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||||||
|
a.pttMu.Lock()
|
||||||
|
a.dvkPttKeyed = true
|
||||||
|
a.pttMu.Unlock()
|
||||||
|
}
|
||||||
|
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
|
||||||
|
a.pttMu.Lock()
|
||||||
|
keyed := a.dvkPttKeyed
|
||||||
|
gen := a.pttGen
|
||||||
|
a.dvkPttKeyed = false
|
||||||
|
a.pttMu.Unlock()
|
||||||
|
if keyed {
|
||||||
|
go a.dvkUnkeyPTT(gen)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||||
// abandoned without logging).
|
// abandoned without logging).
|
||||||
func (a *App) QSOAudioCancel() {
|
func (a *App) QSOAudioCancel() {
|
||||||
if a.qsoRec != nil {
|
if a.qsoRec != nil {
|
||||||
a.qsoRec.DiscardQSO()
|
a.qsoRec.DiscardQSO()
|
||||||
}
|
}
|
||||||
|
a.stopManualQSORecorder()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||||
@@ -8934,10 +9153,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
|
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
out.DeleteRemote = m[keyExtDeleteRemote] == "1"
|
||||||
out.QRZ = extsvc.ServiceConfig{
|
out.QRZ = extsvc.ServiceConfig{
|
||||||
APIKey: m[keyExtQRZAPIKey],
|
APIKey: m[keyExtQRZAPIKey],
|
||||||
ForceStationCallsign: m[keyExtQRZForceCall],
|
ForceStationCallsign: m[keyExtQRZForceCall],
|
||||||
@@ -9079,8 +9299,13 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if cfg.Cloudlog.AutoUpload {
|
if cfg.Cloudlog.AutoUpload {
|
||||||
clgAuto = "1"
|
clgAuto = "1"
|
||||||
}
|
}
|
||||||
|
delRemote := "0"
|
||||||
|
if cfg.DeleteRemote {
|
||||||
|
delRemote = "1"
|
||||||
|
}
|
||||||
scope := a.profileScope() // write under the active profile's prefix
|
scope := a.profileScope() // write under the active profile's prefix
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
|
keyExtDeleteRemote: delRemote,
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||||
keyExtQRZAutoUpload: auto,
|
keyExtQRZAutoUpload: auto,
|
||||||
@@ -10262,6 +10487,23 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
|||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitBulkProgress reports how far a bulk update has got.
|
||||||
|
//
|
||||||
|
// The QRZ pass is the one that needs it: it is a NETWORK round trip per QSO, so
|
||||||
|
// 102 contacts imported from a contest take a couple of minutes during which
|
||||||
|
// nothing moved on screen and the only honest reading was "it has frozen". The
|
||||||
|
// cty.dat and ClubLog passes are local and usually instant, but they report too
|
||||||
|
// — a progress bar that appears only when things are slow teaches people that
|
||||||
|
// its absence means trouble.
|
||||||
|
func (a *App) emitBulkProgress(op string, processed, total int, call string) {
|
||||||
|
wruntime.EventsEmit(a.ctx, "bulkupdate:progress", map[string]any{
|
||||||
|
"op": op,
|
||||||
|
"processed": processed,
|
||||||
|
"total": total,
|
||||||
|
"call": call,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
||||||
// the configured providers) for each QSO id and overwrites the geographic
|
// the configured providers) for each QSO id and overwrites the geographic
|
||||||
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
||||||
@@ -10272,13 +10514,23 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
|||||||
return 0, fmt.Errorf("not initialized")
|
return 0, fmt.Errorf("not initialized")
|
||||||
}
|
}
|
||||||
changed := 0
|
changed := 0
|
||||||
for _, id := range ids {
|
total := len(ids)
|
||||||
|
a.emitBulkProgress("qrz", 0, total, "")
|
||||||
|
defer a.emitBulkProgress("qrz", total, total, "")
|
||||||
|
for i, id := range ids {
|
||||||
q, err := a.qso.GetByID(a.ctx, id)
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
if err != nil || q.Callsign == "" {
|
if err != nil || q.Callsign == "" {
|
||||||
|
a.emitBulkProgress("qrz", i+1, total, "")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Reported BEFORE the lookup, so the callsign on screen is the one being
|
||||||
|
// waited on rather than the one already finished — with a slow provider
|
||||||
|
// that is the difference between "working on F4BPO" and a name that
|
||||||
|
// lags a QSO behind whatever is actually taking the time.
|
||||||
|
a.emitBulkProgress("qrz", i, total, q.Callsign)
|
||||||
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
a.emitBulkProgress("qrz", i+1, total, "")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r.Country != "" {
|
if r.Country != "" {
|
||||||
@@ -10330,6 +10582,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
|||||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||||
changed++
|
changed++
|
||||||
}
|
}
|
||||||
|
a.emitBulkProgress("qrz", i+1, total, "")
|
||||||
}
|
}
|
||||||
if changed > 0 {
|
if changed > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
@@ -10531,6 +10784,17 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Keep the identifier the service handed back. QRZ deletes ONLY by logid —
|
||||||
|
// its API has no delete-by-callsign-and-date — so a QSO uploaded without
|
||||||
|
// recording this can never be withdrawn from QRZ afterwards except by hand.
|
||||||
|
// It costs one small write per upload and it is the only chance to get it.
|
||||||
|
if strings.TrimSpace(logID) != "" && id != 0 {
|
||||||
|
key := "APP_OPSLOG_" + strings.ToUpper(string(svc)) + "_LOGID"
|
||||||
|
if err := a.qso.SetExtra(ctx, id, key, strings.TrimSpace(logID)); err != nil {
|
||||||
|
applog.Printf("extsvc: store %s logid for QSO %d: %v", svc, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
switch svc {
|
switch svc {
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
@@ -12259,7 +12523,14 @@ func (a *App) reloadCAT() {
|
|||||||
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
|
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
|
||||||
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a
|
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a
|
||||||
// single round trip where OmniRig needed a rig file to describe each one.
|
// single round trip where OmniRig needed a rig file to describe each one.
|
||||||
|
// A network address wins over the COM port when both are filled: it is the
|
||||||
|
// more deliberate setting, and silently preferring the wire would leave an
|
||||||
|
// operator staring at a host they typed and a radio that never answers.
|
||||||
|
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
|
||||||
|
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
|
||||||
|
} else {
|
||||||
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
|
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
|
||||||
|
}
|
||||||
case "icom":
|
case "icom":
|
||||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||||
// Same civ protocol the network backend reuses for remote.
|
// Same civ protocol the network backend reuses for remote.
|
||||||
@@ -14478,6 +14749,17 @@ func (a *App) SetCIVTrace(on bool) {
|
|||||||
cat.SetCIVTrace(on)
|
cat.SetCIVTrace(on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CIVTraceEnabled and WinkeyerTraceEnabled report whether each trace is running.
|
||||||
|
//
|
||||||
|
// The settings dialog needs them. Its checkboxes are React state initialised to
|
||||||
|
// false on every mount, so a trace left on came back UNTICKED while it was still
|
||||||
|
// running: the operator ticks the box to \"switch it on\", which switches it off,
|
||||||
|
// and the log they send then contains no trace at all. Reported by a Xiegu user.
|
||||||
|
func (a *App) CIVTraceEnabled() bool { return cat.CIVTraceEnabled() }
|
||||||
|
|
||||||
|
// WinkeyerTraceEnabled — same, for the keyer trace.
|
||||||
|
func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() }
|
||||||
|
|
||||||
// WinkeyerConnect opens the serial link using the saved config.
|
// WinkeyerConnect opens the serial link using the saved config.
|
||||||
func (a *App) WinkeyerConnect() error {
|
func (a *App) WinkeyerConnect() error {
|
||||||
if a.winkeyer == nil {
|
if a.winkeyer == nil {
|
||||||
|
|||||||
@@ -1,4 +1,44 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.22.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
|
||||||
|
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
|
||||||
|
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
|
||||||
|
"Updating selected QSOs from the callsign databases now shows a progress bar with the callsign being queried, in a corner rather than a dialog so the rest of OpsLog stays usable — a contest log is thousands of contacts and one network round trip each. The menu entry is renamed \"Update from the callsign databases\": it has always queried every configured provider, QRZ.com then HamQTH.",
|
||||||
|
"The world map can show the grey line: the day/night terminator with its twilight band, redrawn every minute. Button at the top right of the map, off by default, and the choice is remembered.",
|
||||||
|
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
|
||||||
|
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
|
||||||
|
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
|
||||||
|
"The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||||
|
"With automatic recording off but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards.",
|
||||||
|
"A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.",
|
||||||
|
"The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.",
|
||||||
|
"Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.",
|
||||||
|
"QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.",
|
||||||
|
"Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected.",
|
||||||
|
"New award: DARC DOK Award (DLD), the German Deutschland-Diplom, matched on the DOK reference in the QSO."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
|
||||||
|
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
|
||||||
|
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
|
||||||
|
"La mise à jour des QSO sélectionnés depuis les annuaires affiche désormais une barre de progression avec l'indicatif en cours d'interrogation, dans un coin plutôt qu'en fenêtre : le reste d'OpsLog reste utilisable — un log de concours, c'est des milliers de contacts et un aller-retour réseau pour chacun. L'entrée de menu devient « Mettre à jour depuis les annuaires » : elle a toujours interrogé tous les annuaires configurés, QRZ.com puis HamQTH.",
|
||||||
|
"La carte du monde peut afficher la ligne grise : le terminateur jour/nuit et sa bande de crépuscule, redessinés chaque minute. Bouton en haut à droite de la carte, désactivé par défaut, et le choix est mémorisé.",
|
||||||
|
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
|
||||||
|
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
|
||||||
|
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
|
||||||
|
"La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||||
|
"Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés.",
|
||||||
|
"Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.",
|
||||||
|
"Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.",
|
||||||
|
"Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.",
|
||||||
|
"Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.",
|
||||||
|
"Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas.",
|
||||||
|
"Nouveau diplôme : DARC DOK Award (DLD), le Deutschland-Diplom allemand, reconnu d'après la référence DOK du QSO."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.22.2",
|
"version": "0.22.2",
|
||||||
"date": "2026-07-30",
|
"date": "2026-07-30",
|
||||||
|
|||||||
+172
-17
@@ -44,6 +44,7 @@ import {
|
|||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
|
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
@@ -99,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
@@ -168,11 +170,7 @@ function parseAwardRefs(s: any): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return s;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
}
|
||||||
function fmtFreq(hz?: number): string {
|
function fmtFreq(hz?: number): string {
|
||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
@@ -392,6 +390,8 @@ export default function App() {
|
|||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||||
|
const modesRef = useRef(modes);
|
||||||
|
useEffect(() => { modesRef.current = modes; }, [modes]);
|
||||||
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
||||||
|
|
||||||
// === Entry ===
|
// === Entry ===
|
||||||
@@ -766,16 +766,51 @@ export default function App() {
|
|||||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||||
// second while a recording is in progress.
|
// second while a recording is in progress.
|
||||||
const [recSeconds, setRecSeconds] = useState(0);
|
const [recSeconds, setRecSeconds] = useState(0);
|
||||||
|
// Mirrored in a ref so the clock can RESUME from where it stopped rather than
|
||||||
|
// restart, without the timer effect depending on the value it sets.
|
||||||
|
const recSecondsRef = useRef(0);
|
||||||
|
useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]);
|
||||||
// Bumped on every (re)start so the timer resets even when `recording` was
|
// Bumped on every (re)start so the timer resets even when `recording` was
|
||||||
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||||
const [recTick, setRecTick] = useState(0);
|
const [recTick, setRecTick] = useState(0);
|
||||||
|
// True when the sound devices are configured but automatic recording is OFF —
|
||||||
|
// the only case where a record button makes sense. Asking the backend rather
|
||||||
|
// than reading a preference here keeps the two from drifting apart.
|
||||||
|
const [manualRecReady, setManualRecReady] = useState(false);
|
||||||
|
// A stopped take is still a take: the audio is kept and will be saved with the
|
||||||
|
// QSO. This only says the clock has stopped and the recording can be played.
|
||||||
|
const [recStopped, setRecStopped] = useState(false);
|
||||||
|
const stopRecording = () => {
|
||||||
|
QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {});
|
||||||
|
};
|
||||||
|
const resumeRecording = () => {
|
||||||
|
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
|
||||||
|
};
|
||||||
|
const playRecordingOnAir = () => {
|
||||||
|
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||||
|
useEffect(() => { refreshManualRecReady(); }, []);
|
||||||
|
const startManualRecording = () => {
|
||||||
|
QSOAudioManualStart().then((active) => {
|
||||||
|
setRecording(active);
|
||||||
|
setRecTick((t) => t + 1);
|
||||||
|
if (!active) setError(t('rec.manualFailed'));
|
||||||
|
}).catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recording) { setRecSeconds(0); return; }
|
if (!recording) { setRecSeconds(0); return; }
|
||||||
|
// A stopped take freezes the clock where it is: it must show the length of
|
||||||
|
// the file, not the time since the QSO began.
|
||||||
|
if (recStopped) return;
|
||||||
|
const from = recSecondsRef.current;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
setRecSeconds(0);
|
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [recording, recTick]);
|
}, [recording, recTick, recStopped]);
|
||||||
|
// recTick means "a fresh take" — that is the only case where the clock returns
|
||||||
|
// to zero, as opposed to resuming after a stop.
|
||||||
|
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
|
||||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||||
// Lets us restart from zero when the operator edits the call to a different
|
// Lets us restart from zero when the operator edits the call to a different
|
||||||
// station mid-recording, instead of continuing the old take.
|
// station mid-recording, instead of continuing the old take.
|
||||||
@@ -1518,6 +1553,8 @@ export default function App() {
|
|||||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||||
|
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
|
||||||
|
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
|
||||||
// Field remapping, off unless the operator adds a row. Contest software puts
|
// Field remapping, off unless the operator adds a row. Contest software puts
|
||||||
// the exchange where its own module keeps it rather than where ADIF says: the
|
// the exchange where its own module keeps it rather than where ADIF says: the
|
||||||
// RSGB IOTA contest exports the island reference in STATE, which imports as a
|
// RSGB IOTA contest exports the island reference in STATE, which imports as a
|
||||||
@@ -2042,9 +2079,29 @@ export default function App() {
|
|||||||
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
||||||
function applyModeFromSpot(m: string) {
|
function applyModeFromSpot(m: string) {
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
setMode(m);
|
const logged = catModeToLoggedMode(m);
|
||||||
|
setMode(logged);
|
||||||
rstUserEditedRef.current = false;
|
rstUserEditedRef.current = false;
|
||||||
applyModePreset(m);
|
applyModePreset(logged);
|
||||||
|
}
|
||||||
|
// catModeToLoggedMode maps what a radio reports onto a mode this station
|
||||||
|
// actually logs.
|
||||||
|
//
|
||||||
|
// Radios report the SIDEBAND — a Kenwood or Yaesu on 40 m answers LSB, not
|
||||||
|
// SSB. The mode list is SSB, so nothing matched and the selector sat empty
|
||||||
|
// while the frequency tracked perfectly: "the mode is not recognised".
|
||||||
|
//
|
||||||
|
// It is also the ADIF-correct direction. LSB and USB are SUBMODEs there, not
|
||||||
|
// modes; MODE=LSB is not a valid ADIF value and is what would be sent to LoTW
|
||||||
|
// and eQSL. So this protects the upload as much as the selector.
|
||||||
|
//
|
||||||
|
// A station that has deliberately put LSB or USB in its own mode list keeps
|
||||||
|
// them: the list is the operator's statement of what they log.
|
||||||
|
function catModeToLoggedMode(m: string): string {
|
||||||
|
const list = modesRef.current;
|
||||||
|
if (!m || list.includes(m)) return m;
|
||||||
|
if ((m === 'LSB' || m === 'USB') && list.includes('SSB')) return 'SSB';
|
||||||
|
return m;
|
||||||
}
|
}
|
||||||
function applyModePreset(m: string) {
|
function applyModePreset(m: string) {
|
||||||
if (rstUserEditedRef.current) return;
|
if (rstUserEditedRef.current) return;
|
||||||
@@ -2211,8 +2268,9 @@ export default function App() {
|
|||||||
else if (s.mode) nextMode = s.mode;
|
else if (s.mode) nextMode = s.mode;
|
||||||
}
|
}
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
setMode(nextMode);
|
const logged = catModeToLoggedMode(nextMode);
|
||||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
setMode(logged);
|
||||||
|
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2402,6 +2460,14 @@ export default function App() {
|
|||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
|
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||||
|
const total = Number(p?.total ?? 0);
|
||||||
|
const processed = Number(p?.processed ?? 0);
|
||||||
|
// The closing event (processed === total) clears the overlay: the work is
|
||||||
|
// done, and a bar left at 100% would have to be dismissed by hand.
|
||||||
|
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
|
||||||
|
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
|
||||||
|
});
|
||||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||||
});
|
});
|
||||||
@@ -2428,7 +2494,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2935,9 +3001,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function bulkUpdateFromQRZ(ids: number[]) {
|
async function bulkUpdateFromQRZ(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`);
|
|
||||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setBulkProgress(null); }
|
||||||
}
|
}
|
||||||
async function bulkUpdateFromClublog(ids: number[]) {
|
async function bulkUpdateFromClublog(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -3647,17 +3713,74 @@ export default function App() {
|
|||||||
DUPE
|
DUPE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
{/* Not recording, but able to: offer a record button in the slot the
|
||||||
|
counter will occupy. It disappears the moment recording starts, so the
|
||||||
|
two never fight over the space — and it is only offered on modes that
|
||||||
|
produce audio worth keeping. */}
|
||||||
|
{!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={startManualRecording}
|
||||||
|
title={t('rec.manualStart')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded-full cursor-pointer hover:bg-destructive-muted"
|
||||||
|
>
|
||||||
|
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* A stopped take: play it to the station being worked, or carry on
|
||||||
|
recording. Both sit where the counter is, which has shifted left to
|
||||||
|
make room. */}
|
||||||
|
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={playRecordingOnAir}
|
||||||
|
title={t('rec.playOnAir')}
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={resumeRecording}
|
||||||
|
title={t('rec.resume')}
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-full bg-destructive" />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
|
||||||
|
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Stop and the running counter share ONE flex row. They were two absolute
|
||||||
|
boxes at guessed offsets and overlapped as soon as the counter passed a
|
||||||
|
minute — a layout that only looked right for the first 60 seconds. */}
|
||||||
|
{recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => { e.stopPropagation(); stopRecording(); }}
|
||||||
|
title={t('rec.stop')}
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="size-2 bg-current" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={resetRecordingClock}
|
onClick={resetRecordingClock}
|
||||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
className="inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||||
>
|
>
|
||||||
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
ref={callsignRef}
|
||||||
@@ -3666,7 +3789,10 @@ export default function App() {
|
|||||||
// exactly the same. A primary-tinted border plus an inset left bar make
|
// exactly the same. A primary-tinted border plus an inset left bar make
|
||||||
// it the obvious one without adding a colour the theme doesn't own. The
|
// it the obvious one without adding a colour the theme doesn't own. The
|
||||||
// contest-dupe state still overrides it — that one is a warning.
|
// contest-dupe state still overrides it — that one is a warning.
|
||||||
|
// Room on the right for the REC badges while they are shown, so a long
|
||||||
|
// callsign runs under them instead of being typed over.
|
||||||
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
||||||
|
recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
|
||||||
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
|
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
|
||||||
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
||||||
value={callsign}
|
value={callsign}
|
||||||
@@ -6158,7 +6284,7 @@ export default function App() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
initialSection={settingsSection}
|
initialSection={settingsSection}
|
||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady(); }}
|
||||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
icomAvailable={catState.backend === 'icom'}
|
icomAvailable={catState.backend === 'icom'}
|
||||||
@@ -6384,6 +6510,35 @@ export default function App() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deliberately NOT a modal. A contest log is thousands of QSOs and one
|
||||||
|
network round trip each, so this runs for ten minutes \u2014 a dialog would
|
||||||
|
hold the whole application hostage for the duration, and the operator
|
||||||
|
has a radio to work. It sits in the corner, above everything, and
|
||||||
|
stays out of the way. */}
|
||||||
|
{bulkProgress && (
|
||||||
|
<div className="fixed bottom-3 right-3 z-50 w-64 rounded-lg border border-border bg-card/95 backdrop-blur shadow-lg px-3 py-2 space-y-1.5">
|
||||||
|
<div className="text-xs font-medium">{t('bulk.progressTitle')}</div>
|
||||||
|
{(() => {
|
||||||
|
const { processed, total, call } = bulkProgress;
|
||||||
|
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
|
||||||
|
style={total > 0 ? { width: `${pct}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span className="font-mono">{call || '\u00a0'}</span>
|
||||||
|
<span className="tabular-nums">{total > 0 ? `${processed} / ${total}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{importing && (
|
{importing && (
|
||||||
<Dialog open>
|
<Dialog open>
|
||||||
<DialogContent className="max-w-sm px-6" hideClose>
|
<DialogContent className="max-w-sm px-6" hideClose>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
@@ -191,17 +192,52 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
return idx.length ? idx : all;
|
return idx.length ? idx : all;
|
||||||
}, [stats, awardBands]);
|
}, [stats, awardBands]);
|
||||||
|
|
||||||
|
// Sort order for the reference table. Reference is the default because that
|
||||||
|
// is how award lists are published and how an operator reads a paper list;
|
||||||
|
// description answers the other question — "have I worked anything in
|
||||||
|
// Alicante" — which reference order scatters across the whole table.
|
||||||
|
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||||
|
// travels with a copied data/ folder like every other UI preference here.
|
||||||
|
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
||||||
|
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
||||||
|
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||||
|
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||||
|
const toggleRefSort = (k: 'ref' | 'name') => {
|
||||||
|
if (k === refSort) {
|
||||||
|
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
setRefSortDir(d);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', d);
|
||||||
|
} else {
|
||||||
|
setRefSort(k);
|
||||||
|
setRefSortDir('asc');
|
||||||
|
writeUiPref('opslog.awardRefSort', k);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
return (current.refs ?? []).filter((r) => {
|
const rows = (current.refs ?? []).filter((r) => {
|
||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter]);
|
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
if (refSort === 'name') {
|
||||||
|
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||||
|
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||||
|
// than after "Zaragoza".
|
||||||
|
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||||
|
if (c !== 0) return c * dir;
|
||||||
|
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||||
|
}
|
||||||
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
|
});
|
||||||
|
}, [current, refSearch, refFilter, refSort, refSortDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -386,8 +422,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.description')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
{gridBands.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
@@ -424,8 +470,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.name')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
// across restarts. Default on.
|
// across restarts. Default on.
|
||||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||||
|
|
||||||
|
// Grey line — the day/night terminator with its twilight band. Off by
|
||||||
|
// default: it is a working tool for the operator who wants it, not decoration
|
||||||
|
// for the one who does not.
|
||||||
|
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||||
|
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const autoZoomRef = useRef(autoZoom);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Draw the grey line, and keep it moving.
|
||||||
|
//
|
||||||
|
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||||
|
// time, which is invisible, but a map left on all day would otherwise be
|
||||||
|
// silently wrong by evening — and the whole point of the display is knowing
|
||||||
|
// where the line is NOW.
|
||||||
|
//
|
||||||
|
// It lives in its own pane below the overlay pane so the path and beam are
|
||||||
|
// never hidden behind the shading, and is non-interactive so it can never
|
||||||
|
// swallow a click meant for the map.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = worldMap.current;
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
if (!greyline) {
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
greylineLayer.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m.getPane('greyline')) {
|
||||||
|
const pane = m.createPane('greyline');
|
||||||
|
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||||
|
pane.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const now = new Date();
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
const g = L.layerGroup([], { pane: 'greyline' });
|
||||||
|
|
||||||
|
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||||
|
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||||
|
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||||
|
// begins — the shading looked like a rectangle laid over the planet.
|
||||||
|
//
|
||||||
|
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||||
|
// cover every zoom level this map reaches; they cost nothing, being three
|
||||||
|
// polygons on a canvas layer.
|
||||||
|
const COPIES = [-360, 0, 360];
|
||||||
|
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||||
|
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||||
|
|
||||||
|
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||||
|
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||||
|
// which is the band operators actually mean by "grey line".
|
||||||
|
const nightRing = nightPolygon(now, 0);
|
||||||
|
const twilightRing = nightPolygon(now, -6);
|
||||||
|
// The terminator itself, so the line is readable at a glance rather than
|
||||||
|
// being inferred from where the shading fades. Dropping the last two
|
||||||
|
// points leaves the terminator alone, without the segment that closes the
|
||||||
|
// polygon along the dark pole.
|
||||||
|
const lineRing = nightRing.slice(0, -2);
|
||||||
|
|
||||||
|
for (const by of COPIES) {
|
||||||
|
L.polygon(shifted(twilightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polygon(shifted(nightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polyline(shifted(lineRing, by), {
|
||||||
|
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
}
|
||||||
|
g.addTo(m);
|
||||||
|
greylineLayer.current = g;
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const id = window.setInterval(draw, 60_000);
|
||||||
|
return () => { window.clearInterval(id); };
|
||||||
|
}, [greyline]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, wo = worldOverlay.current;
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
>
|
>
|
||||||
Zoom DX
|
Zoom DX
|
||||||
</button>
|
</button>
|
||||||
|
{/* Grey line toggle, under the zoom button. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = !greyline;
|
||||||
|
setGreyline(v);
|
||||||
|
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||||
|
}}
|
||||||
|
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||||
|
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grey line
|
||||||
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
|
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||||
|
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
|
|||||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both formatters follow the display preference (Settings → General). What is
|
||||||
|
// STORED never changes — see lib/dateFormat.
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return s;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateOnly(s);
|
||||||
const t = String(s).trim();
|
|
||||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
|
||||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
||||||
const d = new Date(t);
|
|
||||||
if (isNaN(d.getTime())) return t;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
@@ -178,8 +170,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── QSL ──
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||||
@@ -187,32 +179,32 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||||
|
|
||||||
// ── LoTW ──
|
// ── LoTW ──
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── eQSL ──
|
// ── eQSL ──
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ 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 },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
@@ -303,7 +295,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
// Rebuild the columns when the format changes: the formatters are captured
|
||||||
|
// inside the column definitions, so nothing else would notice.
|
||||||
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
SetWinkeyerTrace,
|
SetWinkeyerTrace,
|
||||||
SetCIVTrace,
|
SetCIVTrace,
|
||||||
|
CIVTraceEnabled,
|
||||||
|
WinkeyerTraceEnabled,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
@@ -67,6 +69,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
@@ -1093,7 +1096,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
||||||
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,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
@@ -1136,8 +1139,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
});
|
});
|
||||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||||
|
//
|
||||||
|
// But it must still SHOW its real state. These start false on every mount, so
|
||||||
|
// a trace already running came back unticked; the operator ticks the box to
|
||||||
|
// turn it on, which turns it off, and the log they send has no trace in it.
|
||||||
|
// Reported by a Xiegu user. Hydrated from the backend below.
|
||||||
|
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
|
||||||
const [wkTrace, setWkTrace] = useState(false);
|
const [wkTrace, setWkTrace] = useState(false);
|
||||||
const [civTrace, setCivTrace] = useState(false);
|
const [civTrace, setCivTrace] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
|
||||||
|
WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
|
||||||
|
}, []);
|
||||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
// ── Audio (DVK + QSO recorder) ──
|
// ── Audio (DVK + QSO recorder) ──
|
||||||
@@ -1276,7 +1289,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
upload_flags: string[]; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: 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: '',
|
||||||
@@ -1284,7 +1297,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: 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);
|
||||||
@@ -2516,7 +2529,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(catCfg.backend === 'icom' || catCfg.backend === 'xiegu') && (
|
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
|
||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||||||
@@ -2552,6 +2565,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kenwoodHost')}</Label>
|
||||||
|
<Input
|
||||||
|
value={catCfg.kenwood_host || ''}
|
||||||
|
placeholder="192.168.1.50:4532"
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -4203,6 +4225,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
<ProfileScopeNote profile={activeProfileObj} />
|
<ProfileScopeNote profile={activeProfileObj} />
|
||||||
|
|
||||||
|
{/* Applies to BOTH services, so it sits above the tab strip rather than
|
||||||
|
inside one tab where it would look like a QRZ-only setting (and be
|
||||||
|
invisible to anyone who only opens the Club Log tab). */}
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!extSvc.delete_remote}
|
||||||
|
onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('es.deleteRemote')}
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Tab strip */}
|
{/* Tab strip */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
@@ -5187,6 +5224,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
|
||||||
|
— that is what ADIF specifies and what sorts correctly, and a
|
||||||
|
stored format that followed a preference would make the log
|
||||||
|
unreadable the day the preference changed. */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
|
||||||
|
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
|
||||||
|
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||||||
|
<button key={code} type="button" title={sample}
|
||||||
|
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||||||
|
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
||||||
|
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
|
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// Localized column catalog (shared with the Recent QSOs grid).
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||||
|
//
|
||||||
|
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||||
|
// upload. This is the reading layer only, because an operator reading
|
||||||
|
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||||
|
// and that is a display problem, not a data one.
|
||||||
|
//
|
||||||
|
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||||
|
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||||
|
// preference would become unreadable the day the preference changed.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||||
|
|
||||||
|
const KEY = 'opslog.dateFormat';
|
||||||
|
|
||||||
|
function read(): DateFormat {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: DateFormat = read();
|
||||||
|
|
||||||
|
// A minimal store so every grid and panel re-renders the moment the format
|
||||||
|
// changes, rather than showing the old one until the next restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getDateFormat(): DateFormat {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDateFormat(f: DateFormat): void {
|
||||||
|
current = f;
|
||||||
|
writeUiPref(KEY, f);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDateFormat(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||||
|
// have been synced from the database at startup, which happens AFTER this
|
||||||
|
// module's first read.
|
||||||
|
export function reloadDateFormat(): void {
|
||||||
|
const v = read();
|
||||||
|
if (v !== current) {
|
||||||
|
current = v;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||||
|
// in the French one would be conventional but it also invites the reader to
|
||||||
|
// take it for a US date, which is exactly the confusion being fixed.
|
||||||
|
function order(y: number, m: number, d: number): string {
|
||||||
|
switch (current) {
|
||||||
|
case 'fr':
|
||||||
|
return `${pad(d)}-${pad(m)}-${y}`;
|
||||||
|
case 'us':
|
||||||
|
return `${pad(m)}-${pad(d)}-${y}`;
|
||||||
|
}
|
||||||
|
return `${y}-${pad(m)}-${pad(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||||
|
//
|
||||||
|
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||||
|
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||||
|
// twice a year.
|
||||||
|
export function formatDateTimeUTC(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(String(s));
|
||||||
|
if (isNaN(d.getTime())) return String(s);
|
||||||
|
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||||
|
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||||
|
export function formatDateOnly(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = String(s).trim();
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
const d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
|
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Grey line — the day/night terminator, and the twilight band around it where
|
||||||
|
// HF propagation briefly does things it does at no other time.
|
||||||
|
//
|
||||||
|
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||||
|
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||||
|
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||||
|
// this has to work in a field shack with no internet.
|
||||||
|
//
|
||||||
|
// Everything here is pure so it can be tested against known positions; the
|
||||||
|
// drawing lives in the map component.
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// julianDay for a date, including the fraction of the day.
|
||||||
|
export function julianDay(d: Date): number {
|
||||||
|
return d.getTime() / 86400000 + 2440587.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||||
|
//
|
||||||
|
// Returned together because they are computed from the same intermediates and
|
||||||
|
// every caller needs both — splitting them into two exported functions would
|
||||||
|
// double the work at each call for the sake of a tidier signature.
|
||||||
|
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||||
|
const jd = julianDay(date);
|
||||||
|
const n = jd - 2451545.0; // days from J2000.0
|
||||||
|
|
||||||
|
// Mean longitude and mean anomaly of the Sun.
|
||||||
|
const L = (280.460 + 0.9856474 * n) % 360;
|
||||||
|
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||||
|
|
||||||
|
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||||
|
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||||
|
|
||||||
|
// Obliquity of the ecliptic, slowly decreasing.
|
||||||
|
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||||
|
|
||||||
|
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||||
|
|
||||||
|
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||||
|
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||||
|
// terminator about the prime meridian. Six months of a plausible-looking
|
||||||
|
// wrong answer is exactly the kind of bug that survives review.
|
||||||
|
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||||
|
if (ra < 0) ra += 360;
|
||||||
|
|
||||||
|
// Greenwich mean sidereal time, in degrees.
|
||||||
|
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||||
|
|
||||||
|
let gha = gmst - ra;
|
||||||
|
gha = ((gha % 360) + 360) % 360;
|
||||||
|
return { dec, gha };
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||||
|
// sits at `altitude` degrees above (or below) the horizon.
|
||||||
|
//
|
||||||
|
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||||
|
// of the grey line as operators use the term.
|
||||||
|
//
|
||||||
|
// Returns null where no such latitude exists at that longitude — inside the
|
||||||
|
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||||
|
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const h = altitude * DEG;
|
||||||
|
|
||||||
|
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||||
|
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||||
|
//
|
||||||
|
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||||
|
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||||
|
// local in December came out as daylight because the branch chosen put the
|
||||||
|
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||||
|
// entirely plausible and is exactly wrong.
|
||||||
|
//
|
||||||
|
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||||
|
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||||
|
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||||
|
// which is the physical requirement — the twilight curve is a small offset
|
||||||
|
// from the terminator, never on the other side of the planet.
|
||||||
|
const A = Math.sin(d);
|
||||||
|
const B = Math.cos(d) * Math.cos(ha);
|
||||||
|
const R = Math.hypot(A, B);
|
||||||
|
if (R === 0) return null;
|
||||||
|
const s = Math.sin(h) / R;
|
||||||
|
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||||
|
|
||||||
|
// The classical terminator. Division by zero is deliberate and correct here:
|
||||||
|
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||||
|
// exactly what atan(±Infinity) = ±90° expresses.
|
||||||
|
const t = Math.tan(d);
|
||||||
|
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||||
|
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||||
|
|
||||||
|
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||||
|
const fold = (x: number) => {
|
||||||
|
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||||
|
if (v > 90) v = 180 - v;
|
||||||
|
if (v < -90) v = -180 - v;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||||
|
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||||
|
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||||
|
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||||
|
//
|
||||||
|
// The ring runs along the terminator from west to east and closes along
|
||||||
|
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||||
|
// getting it wrong shades the lit half of the planet.
|
||||||
|
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||||
|
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||||
|
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||||
|
// ring stays closed rather than tearing open across the map.
|
||||||
|
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||||
|
const pole = dec > 0 ? -90 : 90;
|
||||||
|
ring.push([pole, 180]);
|
||||||
|
ring.push([pole, -180]);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
+12
-12
@@ -63,7 +63,7 @@ const en: Dict = {
|
|||||||
// Language chooser
|
// Language chooser
|
||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
|
||||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
@@ -135,8 +135,8 @@ const en: Dict = {
|
|||||||
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
||||||
'imp.stationTitle': 'Fill my station fields from my profile',
|
'imp.stationTitle': 'Fill my station fields from my profile',
|
||||||
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
||||||
'imp.cancel': 'Cancel', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
||||||
'imp.progressTitle': 'Importing ADIF…',
|
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||||
'imp.complete': 'Import complete.',
|
'imp.complete': 'Import complete.',
|
||||||
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
|
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
|
||||||
@@ -277,7 +277,7 @@ const en: Dict = {
|
|||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (native CI-V)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (native CAT)', 'cat.optKenwood': 'Kenwood (native)', 'cat.civTrace': 'Log the CI-V protocol', 'cat.civTraceHint': 'Writes every CI-V frame to and from the radio into the log, as hex. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
@@ -298,7 +298,7 @@ const en: Dict = {
|
|||||||
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
|
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
|
||||||
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
|
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
|
||||||
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
||||||
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
'es.soon': 'soon', 'es.deleteRemote': 'Also delete from QRZ.com and Club Log', 'es.deleteRemoteHint': 'When you delete a QSO here, withdraw it from those services too. QRZ.com can only delete records OpsLog uploaded itself \u2014 older ones must be removed on the website.', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
||||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
||||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||||
@@ -398,7 +398,7 @@ const en: Dict = {
|
|||||||
'awed.importReplace': 'Replace mine',
|
'awed.importReplace': 'Replace mine',
|
||||||
'awed.importCopy': 'Import as {code}-2',
|
'awed.importCopy': 'Import as {code}-2',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from the callsign databases', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
|
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
|
||||||
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
|
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
|
||||||
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
|
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
|
||||||
@@ -475,7 +475,7 @@ const fr: Dict = {
|
|||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||||
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
|
||||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
@@ -546,8 +546,8 @@ const fr: Dict = {
|
|||||||
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
||||||
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
||||||
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
||||||
'imp.cancel': 'Annuler', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
||||||
'imp.progressTitle': 'Import ADIF en cours…',
|
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||||
'imp.complete': 'Import terminé.',
|
'imp.complete': 'Import terminé.',
|
||||||
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
|
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
|
||||||
@@ -678,7 +678,7 @@ const fr: Dict = {
|
|||||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (CI-V natif)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (CAT natif)', 'cat.optKenwood': 'Kenwood (natif)', 'cat.civTrace': 'Journaliser le protocole CI-V', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CI-V \u00e9chang\u00e9e avec la radio, en hexad\u00e9cimal. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
@@ -698,7 +698,7 @@ const fr: Dict = {
|
|||||||
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
|
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
|
||||||
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
|
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
|
||||||
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
||||||
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
'es.soon': 'bientôt', 'es.deleteRemote': 'Supprimer aussi sur QRZ.com et Club Log', 'es.deleteRemoteHint': 'Quand vous supprimez un QSO ici, le retirer aussi de ces services. QRZ.com ne peut supprimer que les enregistrements envoy\u00e9s par OpsLog \u2014 les plus anciens doivent \u00eatre retir\u00e9s sur le site.', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
||||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
||||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||||
@@ -790,7 +790,7 @@ const fr: Dict = {
|
|||||||
'awed.importKeepMine': 'Garder le mien',
|
'awed.importKeepMine': 'Garder le mien',
|
||||||
'awed.importReplace': 'Remplacer le mien',
|
'awed.importReplace': 'Remplacer le mien',
|
||||||
'awed.importCopy': 'Importer en {code}-2',
|
'awed.importCopy': 'Importer en {code}-2',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
|
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
|
||||||
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).',
|
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).',
|
||||||
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
|
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Colouring for QSL / confirmation status letters.
|
||||||
|
//
|
||||||
|
// These columns are read by COLOUR before they are read as letters: an operator
|
||||||
|
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||||
|
// hundred times.
|
||||||
|
//
|
||||||
|
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||||
|
// and a column of coloured boxes would shout louder than the callsign it
|
||||||
|
// belongs to.
|
||||||
|
//
|
||||||
|
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||||
|
// and the contrast stays right on the light ones.
|
||||||
|
|
||||||
|
// ADIF confirmation values, and what they mean to the operator:
|
||||||
|
// Y confirmed / sent
|
||||||
|
// N not sent, not received
|
||||||
|
// R requested — queued, waiting on the other station or the service
|
||||||
|
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||||
|
// good news nor bad, and colouring it would put it in one camp or the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||||
|
// rather than guessed at.
|
||||||
|
export function qslStatusClass(value: unknown): string {
|
||||||
|
switch (String(value ?? '').trim().toUpperCase()) {
|
||||||
|
case 'Y':
|
||||||
|
return 'text-success';
|
||||||
|
case 'N':
|
||||||
|
return 'text-destructive';
|
||||||
|
case 'R':
|
||||||
|
return 'text-info';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||||
|
// status columns already used so the letters stay in a straight column.
|
||||||
|
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||||
|
const c = qslStatusClass(p?.value);
|
||||||
|
return c ? 'font-mono ' + c : 'font-mono';
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||||
|
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||||
|
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||||
// Cluster filter selections — restored on reopen.
|
// Cluster filter selections — restored on reopen.
|
||||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { syncPortablePrefs } from './lib/uiPref'
|
|||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
import { ThemeProvider, initTheme } from './lib/theme'
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||||
|
import { reloadDateFormat } from './lib/dateFormat'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ syncPortablePrefs().finally(() => {
|
|||||||
// Stamp the persisted theme onto <html> before render so the correct
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
// palette is applied immediately (portable pref hydrated just above).
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
initTheme()
|
initTheme()
|
||||||
|
// The date format module read localStorage at import time, which is BEFORE
|
||||||
|
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||||
|
// copied data/ folder would show ISO until the next launch.
|
||||||
|
reloadDateFormat()
|
||||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||||
// rejected promises. Installed before the first render so a fault during
|
// rejected promises. Installed before the first render so a fault during
|
||||||
// startup is reported too.
|
// startup is reported too.
|
||||||
|
|||||||
@@ -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.22.2';
|
export const APP_VERSION = '0.22.3';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+14
@@ -91,6 +91,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CIVTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
@@ -747,10 +749,20 @@ export function QSOAudioBegin():Promise<boolean>;
|
|||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
export function QSOAudioCancel():Promise<void>;
|
||||||
|
|
||||||
|
export function QSOAudioManualReady():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioManualStart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir():Promise<void>;
|
||||||
|
|
||||||
export function QSOAudioResetClock():Promise<boolean>;
|
export function QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioResume():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioStop():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
export function RecomputeAllAwardRefs():Promise<number>;
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
@@ -1037,6 +1049,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function WinkeyerStop():Promise<void>;
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|
||||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CIVTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
export function CWDecoderRunning() {
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
@@ -1442,6 +1446,18 @@ export function QSOAudioCancel() {
|
|||||||
return window['go']['main']['App']['QSOAudioCancel']();
|
return window['go']['main']['App']['QSOAudioCancel']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioManualReady() {
|
||||||
|
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioManualStart() {
|
||||||
|
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir() {
|
||||||
|
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOAudioResetClock() {
|
export function QSOAudioResetClock() {
|
||||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1466,14 @@ export function QSOAudioRestart() {
|
|||||||
return window['go']['main']['App']['QSOAudioRestart']();
|
return window['go']['main']['App']['QSOAudioRestart']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioResume() {
|
||||||
|
return window['go']['main']['App']['QSOAudioResume']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioStop() {
|
||||||
|
return window['go']['main']['App']['QSOAudioStop']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QuitApp() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
@@ -2022,6 +2046,10 @@ export function WinkeyerStop() {
|
|||||||
return window['go']['main']['App']['WinkeyerStop']();
|
return window['go']['main']['App']['WinkeyerStop']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkedBefore(arg1, arg2) {
|
export function WorkedBefore(arg1, arg2) {
|
||||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1297,6 +1297,7 @@ export namespace extsvc {
|
|||||||
hrdlog: ServiceConfig;
|
hrdlog: ServiceConfig;
|
||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
cloudlog: ServiceConfig;
|
cloudlog: ServiceConfig;
|
||||||
|
delete_remote: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -1310,6 +1311,7 @@ export namespace extsvc {
|
|||||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
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.delete_remote = source["delete_remote"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -1906,6 +1908,7 @@ export namespace main {
|
|||||||
xiegu_addr: number;
|
xiegu_addr: number;
|
||||||
yaesu_port: string;
|
yaesu_port: string;
|
||||||
yaesu_baud: number;
|
yaesu_baud: number;
|
||||||
|
kenwood_host: string;
|
||||||
kenwood_port: string;
|
kenwood_port: string;
|
||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
@@ -1944,6 +1947,7 @@ export namespace main {
|
|||||||
this.xiegu_addr = source["xiegu_addr"];
|
this.xiegu_addr = source["xiegu_addr"];
|
||||||
this.yaesu_port = source["yaesu_port"];
|
this.yaesu_port = source["yaesu_port"];
|
||||||
this.yaesu_baud = source["yaesu_baud"];
|
this.yaesu_baud = source["yaesu_baud"];
|
||||||
|
this.kenwood_host = source["kenwood_host"];
|
||||||
this.kenwood_port = source["kenwood_port"];
|
this.kenwood_port = source["kenwood_port"];
|
||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Recorder struct {
|
|||||||
// Mixed output state (guarded by mu).
|
// Mixed output state (guarded by mu).
|
||||||
ring []int16 // last prerollSamples of mixed audio
|
ring []int16 // last prerollSamples of mixed audio
|
||||||
active bool
|
active bool
|
||||||
|
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||||
|
// captured so far is kept and the take still ends normally when the QSO is
|
||||||
|
// logged. It is what lets an operator stop, play the recording back on the
|
||||||
|
// air to the station they are working, and still have it saved with the QSO.
|
||||||
|
paused bool
|
||||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.twoSrc = micDev != "" && micDev != fromDev
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
r.stopCh = make(chan struct{})
|
r.stopCh = make(chan struct{})
|
||||||
r.running = true
|
r.running = true
|
||||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||||
stop := r.stopCh
|
stop := r.stopCh
|
||||||
twoSrc := r.twoSrc
|
twoSrc := r.twoSrc
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -206,7 +211,7 @@ func (r *Recorder) mixTick() {
|
|||||||
if len(r.ring) > r.prerollSamples {
|
if len(r.ring) > r.prerollSamples {
|
||||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||||
}
|
}
|
||||||
if r.active {
|
if r.active && !r.paused {
|
||||||
r.acc = append(r.acc, mixed...)
|
r.acc = append(r.acc, mixed...)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -221,7 +226,7 @@ func (r *Recorder) BeginQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||||
@@ -236,7 +241,7 @@ func (r *Recorder) RestartQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||||
@@ -266,7 +271,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return nil, fmt.Errorf("recording was empty")
|
return nil, fmt.Errorf("recording was empty")
|
||||||
@@ -295,7 +300,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
|||||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +318,7 @@ func (r *Recorder) Stop() {
|
|||||||
close(stop)
|
close(stop)
|
||||||
r.wg.Wait()
|
r.wg.Wait()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.ring, r.acc, r.active = nil, nil, false
|
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA, r.bufB = nil, nil
|
r.bufA, r.bufB = nil, nil
|
||||||
@@ -346,3 +351,55 @@ func int16sToBytes(s []int16) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||||
|
// nothing is thrown away, and logging the QSO still writes the file.
|
||||||
|
//
|
||||||
|
// This exists for one situation, which is common enough on the bands to be
|
||||||
|
// worth the state: you are working a station, you have been recording, and they
|
||||||
|
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||||
|
// recording is still saved with the QSO afterwards.
|
||||||
|
func (r *Recorder) PauseQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||||
|
func (r *Recorder) ResumeQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paused reports whether the active take is frozen.
|
||||||
|
func (r *Recorder) Paused() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.active && r.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||||
|
//
|
||||||
|
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||||
|
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||||
|
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||||
|
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return nil, fmt.Errorf("no active recording")
|
||||||
|
}
|
||||||
|
if len(r.acc) == 0 {
|
||||||
|
return nil, fmt.Errorf("recording is empty")
|
||||||
|
}
|
||||||
|
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+152
-3
@@ -29,7 +29,9 @@ package cat
|
|||||||
// from the radio, and the rig file decides what a "Freq" property means.
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -43,11 +45,26 @@ import (
|
|||||||
type Kenwood struct {
|
type Kenwood struct {
|
||||||
portName string
|
portName string
|
||||||
baud int
|
baud int
|
||||||
|
// host is "address:port" for a serial link reached over the network — a
|
||||||
|
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
||||||
|
//
|
||||||
|
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
||||||
|
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
||||||
|
// that is a different piece of work needing one of those radios to confirm
|
||||||
|
// it. What this covers is the same CAT byte stream over a socket instead of
|
||||||
|
// a wire, which is how most operators actually put a rig on the network.
|
||||||
|
host string
|
||||||
digital string // mode name logged for data (FT8 by default)
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
port serial.Port
|
port serial.Port
|
||||||
|
|
||||||
|
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||||
|
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||||
|
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||||
|
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||||
|
dialPort func() (serial.Port, error)
|
||||||
|
|
||||||
model string
|
model string
|
||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
@@ -56,6 +73,14 @@ type Kenwood struct {
|
|||||||
unsupported map[string]bool
|
unsupported map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||||
|
// a COM port (ser2net and friends).
|
||||||
|
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
||||||
|
k := NewKenwood("", 0, digital)
|
||||||
|
k.host = strings.TrimSpace(hostPort)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||||
if baud <= 0 {
|
if baud <= 0 {
|
||||||
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||||
@@ -71,8 +96,8 @@ func (k *Kenwood) Name() string { return "kenwood" }
|
|||||||
func (k *Kenwood) Connect() error {
|
func (k *Kenwood) Connect() error {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
if k.portName == "" {
|
if k.portName == "" && k.host == "" {
|
||||||
return fmt.Errorf("kenwood: no serial port configured")
|
return fmt.Errorf("kenwood: no serial port or network address configured")
|
||||||
}
|
}
|
||||||
// Close any handle still held before opening another: Connect runs again on
|
// Close any handle still held before opening another: Connect runs again on
|
||||||
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||||
@@ -82,8 +107,11 @@ func (k *Kenwood) Connect() error {
|
|||||||
_ = k.port.Close()
|
_ = k.port.Close()
|
||||||
k.port = nil
|
k.port = nil
|
||||||
}
|
}
|
||||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
p, err := k.openPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||||
|
}
|
||||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||||
}
|
}
|
||||||
p.SetReadTimeout(300 * time.Millisecond)
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
@@ -112,9 +140,19 @@ func (k *Kenwood) Connect() error {
|
|||||||
}
|
}
|
||||||
if !answered {
|
if !answered {
|
||||||
k.model = ""
|
k.model = ""
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||||
|
}
|
||||||
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||||
}
|
}
|
||||||
|
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||||
|
// after a network connect is the kind of line that sends someone hunting a
|
||||||
|
// serial fault that does not exist.
|
||||||
|
if k.host != "" {
|
||||||
|
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
||||||
|
} else {
|
||||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +192,34 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
|||||||
// IF reports the frequency of the VFO in USE (what the operator hears).
|
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||||
rx := f.FreqHz
|
rx := f.FreqHz
|
||||||
tx := rx
|
tx := rx
|
||||||
|
|
||||||
|
// IF's split bit is not filled in by every rig that speaks this dialect —
|
||||||
|
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
||||||
|
// reads perfectly and split never appears. So ask the question directly as
|
||||||
|
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
||||||
|
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
||||||
|
//
|
||||||
|
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
||||||
|
// this costs two short commands per poll only where it actually works.
|
||||||
|
split := f.Split
|
||||||
|
if !split {
|
||||||
|
rxv, rxOK := k.askVFO("FR;")
|
||||||
|
txv, txOK := k.askVFO("FT;")
|
||||||
|
if rxOK && txOK && rxv != txv {
|
||||||
|
split = true
|
||||||
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||||
|
// same breath, and a rig that leaves the split bit empty may be just
|
||||||
|
// as vague about the VFO field.
|
||||||
|
// f.VFO too, not just the reported state: the block below picks the
|
||||||
|
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
||||||
|
// disagreeing would read the transmit frequency off the wrong dial.
|
||||||
|
if rxv == "A" || rxv == "B" {
|
||||||
|
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Split = split
|
||||||
|
|
||||||
if f.Split {
|
if f.Split {
|
||||||
// The transmit VFO is the other one. Read it rather than assume, and fall
|
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||||
// back to simplex if it cannot be read: a wrong TX frequency is written
|
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||||
@@ -229,6 +295,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
traceText("kenwood", "TX", cmd)
|
||||||
_, err := k.port.Write([]byte(cmd))
|
_, err := k.port.Write([]byte(cmd))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -264,6 +331,7 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
|||||||
}
|
}
|
||||||
frame := string(buf[:i+1])
|
frame := string(buf[:i+1])
|
||||||
buf = buf[i+1:]
|
buf = buf[i+1:]
|
||||||
|
traceText("kenwood", "RX", frame)
|
||||||
if frame == "?;" {
|
if frame == "?;" {
|
||||||
// The rig rejected the command. Remember it so the poll loop stops
|
// The rig rejected the command. Remember it so the poll loop stops
|
||||||
// paying a 600 ms timeout for it on every cycle.
|
// paying a 600 ms timeout for it on every cycle.
|
||||||
@@ -411,3 +479,84 @@ var kenwoodModels = map[string]string{
|
|||||||
"024": "TS-990S",
|
"024": "TS-990S",
|
||||||
"025": "TS-890S",
|
"025": "TS-890S",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||||
|
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||||
|
if k.dialPort != nil {
|
||||||
|
return k.dialPort()
|
||||||
|
}
|
||||||
|
if k.host != "" {
|
||||||
|
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tcpSerial{conn: c}, nil
|
||||||
|
}
|
||||||
|
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||||
|
// code path whether the rig is on a wire or on the network.
|
||||||
|
//
|
||||||
|
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
||||||
|
// to raise, and failing them would break a caller that sets them defensively.
|
||||||
|
type tcpSerial struct{ conn net.Conn }
|
||||||
|
|
||||||
|
func (t *tcpSerial) Read(p []byte) (int, error) {
|
||||||
|
n, err := t.conn.Read(p)
|
||||||
|
// A read deadline expiring is this transport's "no data yet", exactly what a
|
||||||
|
// serial read timeout means to the caller — not a dead link. Reporting it as
|
||||||
|
// an error would make ask() abandon a rig that is merely thinking.
|
||||||
|
if err != nil {
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
||||||
|
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
||||||
|
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
||||||
|
if d <= 0 {
|
||||||
|
return t.conn.SetReadDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
return t.conn.SetReadDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
||||||
|
func (t *tcpSerial) Drain() error { return nil }
|
||||||
|
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
||||||
|
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
||||||
|
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
||||||
|
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
||||||
|
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||||
|
return &serial.ModemStatusBits{}, nil
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
||||||
|
|
||||||
|
// askVFO asks FR; or FT; and returns "A" or "B".
|
||||||
|
//
|
||||||
|
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
||||||
|
// Anything else (a rig that answers with more fields, or not at all) returns
|
||||||
|
// false, and the caller keeps whatever IF said rather than inventing a split.
|
||||||
|
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
||||||
|
r, err := k.ask(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
||||||
|
if body == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch body[0] {
|
||||||
|
case '0':
|
||||||
|
return "A", true
|
||||||
|
case '1':
|
||||||
|
return "B", true
|
||||||
|
}
|
||||||
|
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
||||||
|
// nothing is better than mapping it onto A or B and reporting a split that
|
||||||
|
// does not exist.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Kenwood backend driven against a rig that answers, with no hardware.
|
||||||
|
//
|
||||||
|
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
||||||
|
// exchange is a guess however carefully it was written. But this repository
|
||||||
|
// already contains the other half of the conversation: internal/catemu ANSWERS
|
||||||
|
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
||||||
|
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
||||||
|
// 38-character IF layout, same ID019 — so the two halves are checked against
|
||||||
|
// each other rather than against my reading of a manual.
|
||||||
|
//
|
||||||
|
// What this proves: the round trip completes, the model is identified, and
|
||||||
|
// frequency, mode, VFO and split come back correct, including the second read
|
||||||
|
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
||||||
|
// same timings, or that its firmware fills every IF field the way the
|
||||||
|
// documentation says. Those still need a radio.
|
||||||
|
|
||||||
|
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
||||||
|
// meaning; the rest satisfy the interface.
|
||||||
|
type fakeSerial struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
toRig *strings.Builder // what the backend has written
|
||||||
|
fromRig []byte // what the rig has queued for the backend
|
||||||
|
answer func(cmd string) string
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Write(p []byte) (int, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.closed {
|
||||||
|
return 0, fmt.Errorf("closed")
|
||||||
|
}
|
||||||
|
f.toRig.Write(p)
|
||||||
|
// A rig answers as each terminated command arrives.
|
||||||
|
for {
|
||||||
|
s := f.toRig.String()
|
||||||
|
i := strings.IndexByte(s, ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmd := s[:i+1]
|
||||||
|
rest := s[i+1:]
|
||||||
|
f.toRig.Reset()
|
||||||
|
f.toRig.WriteString(rest)
|
||||||
|
if r := f.answer(cmd); r != "" {
|
||||||
|
f.fromRig = append(f.fromRig, r...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Read(p []byte) (int, error) {
|
||||||
|
// A real port blocks until data or timeout; the backend polls, so returning
|
||||||
|
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.closed {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return 0, fmt.Errorf("closed")
|
||||||
|
}
|
||||||
|
if len(f.fromRig) > 0 {
|
||||||
|
n := copy(p, f.fromRig)
|
||||||
|
f.fromRig = f.fromRig[n:]
|
||||||
|
f.mu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Close() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
||||||
|
func (f *fakeSerial) Drain() error { return nil }
|
||||||
|
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
||||||
|
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
||||||
|
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
||||||
|
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
||||||
|
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||||
|
return &serial.ModemStatusBits{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
||||||
|
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
||||||
|
|
||||||
|
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
||||||
|
// the Kenwood digit; split selects which VFO transmits.
|
||||||
|
type ts2000 struct {
|
||||||
|
vfoA, vfoB int64
|
||||||
|
mode byte
|
||||||
|
onB bool
|
||||||
|
split bool
|
||||||
|
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
||||||
|
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
||||||
|
// emulation, where the frequency reads perfectly and split never appears.
|
||||||
|
lazyIF bool
|
||||||
|
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
||||||
|
noVFOCmds bool
|
||||||
|
seen []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ts2000) answer(cmd string) string {
|
||||||
|
r.seen = append(r.seen, cmd)
|
||||||
|
cur := r.vfoA
|
||||||
|
vfoDigit := byte('0')
|
||||||
|
if r.onB {
|
||||||
|
cur, vfoDigit = r.vfoB, '1'
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cmd == "ID;":
|
||||||
|
return "ID019;" // TS-2000, exactly what catemu reports
|
||||||
|
case cmd == "FA;":
|
||||||
|
return fmt.Sprintf("FA%011d;", r.vfoA)
|
||||||
|
case cmd == "FB;":
|
||||||
|
return fmt.Sprintf("FB%011d;", r.vfoB)
|
||||||
|
case cmd == "IF;":
|
||||||
|
split := byte('0')
|
||||||
|
if r.split && !r.lazyIF {
|
||||||
|
split = '1'
|
||||||
|
}
|
||||||
|
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
||||||
|
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
||||||
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
||||||
|
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
||||||
|
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
||||||
|
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
||||||
|
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
||||||
|
r.mode = cmd[2]
|
||||||
|
return ""
|
||||||
|
case cmd == "FR;" || cmd == "FT;":
|
||||||
|
if r.noVFOCmds {
|
||||||
|
return "?;" // rejected, as a rig that does not know the command answers
|
||||||
|
}
|
||||||
|
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
||||||
|
// the rig is in split.
|
||||||
|
v := vfoDigit
|
||||||
|
if cmd == "FT;" && r.split {
|
||||||
|
if vfoDigit == '0' {
|
||||||
|
v = '1'
|
||||||
|
} else {
|
||||||
|
v = '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
||||||
|
}
|
||||||
|
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
||||||
|
return func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
if k.model != "TS-2000" {
|
||||||
|
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplex on A.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
||||||
|
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On B: everything must follow the VFO in use, the fault that took several
|
||||||
|
// rounds to settle in the Yaesu backend.
|
||||||
|
rig.onB = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
||||||
|
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuning must write to the VFO in use, not blindly to FA.
|
||||||
|
if err := k.SetFrequency(14265000); err != nil {
|
||||||
|
t.Fatalf("set freq: %v", err)
|
||||||
|
}
|
||||||
|
if rig.vfoB != 14265000 {
|
||||||
|
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
||||||
|
}
|
||||||
|
if rig.vfoA != 14250000 {
|
||||||
|
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
||||||
|
// frequency, so the two must not be swapped — a mistake that writes the
|
||||||
|
// wrong frequency into every logged QSO.
|
||||||
|
rig.split = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
||||||
|
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
||||||
|
s.FreqHz, s.RxFreqHz, s.Split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
||||||
|
rig.split = false
|
||||||
|
if err := k.SetMode("CW"); err != nil {
|
||||||
|
t.Fatalf("set mode: %v", err)
|
||||||
|
}
|
||||||
|
if rig.mode != '3' {
|
||||||
|
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT is a bare command, and the rig must have actually seen it.
|
||||||
|
if err := k.SetPTT(true); err != nil {
|
||||||
|
t.Fatalf("ptt: %v", err)
|
||||||
|
}
|
||||||
|
if err := k.SetPTT(false); err != nil {
|
||||||
|
t.Fatalf("ptt off: %v", err)
|
||||||
|
}
|
||||||
|
seen := strings.Join(rig.seen, " ")
|
||||||
|
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
||||||
|
if !strings.Contains(seen, want) {
|
||||||
|
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port that opens onto silence must be reported as such, not as a connected
|
||||||
|
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
||||||
|
// time on the Yaesu backend.
|
||||||
|
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
||||||
|
}
|
||||||
|
err := k.Connect()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a silent port was reported as a connected rig")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not answering") {
|
||||||
|
t.Errorf("error was %q — it should say the rig is not answering", err)
|
||||||
|
}
|
||||||
|
if k.model != "" {
|
||||||
|
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split found through FR/FT when the rig never fills IF's split bit.
|
||||||
|
//
|
||||||
|
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
||||||
|
// perfectly, split never appeared. Rather than guess at which IF column that
|
||||||
|
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
||||||
|
// a different VFO from the receive one — which is what FR and FT answer, and
|
||||||
|
// the same rule the Yaesu backend settled on after several wrong turns.
|
||||||
|
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
// Simplex: FR and FT agree, and nothing may be invented from that.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if s.Split {
|
||||||
|
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||||
|
rig.split = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split {
|
||||||
|
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
||||||
|
}
|
||||||
|
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||||
|
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
||||||
|
// bit still works. The fallback must not cost a timeout on every poll, nor
|
||||||
|
// break the rigs that were already fine.
|
||||||
|
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
if _, err := k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
asked := 0
|
||||||
|
for _, c := range rig.seen {
|
||||||
|
if c == "FR;" || c == "FT;" {
|
||||||
|
asked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
after := 0
|
||||||
|
for _, c := range rig.seen {
|
||||||
|
if c == "FR;" || c == "FT;" {
|
||||||
|
after++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after != asked {
|
||||||
|
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IF's own split bit still drives the result on such a rig.
|
||||||
|
rig.split = true
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||||
|
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-18
@@ -75,37 +75,53 @@ func DebugLogPath() string {
|
|||||||
return filepath.Join(base, "OpsLog", "cat.log")
|
return filepath.Join(base, "OpsLog", "cat.log")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CI-V byte trace ────────────────────────────────────────────────────────
|
// ── CAT wire trace ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Opt-in, off by default. Turning it on logs every CI-V frame sent and received
|
// Opt-in, off by default. Turning it on logs every frame sent and received —
|
||||||
// as hex, exactly like the WinKeyer protocol trace.
|
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
|
||||||
//
|
//
|
||||||
// That trace exists because a fault nobody could reason about — "the keyer sends
|
// It exists because a fault nobody could reason about — "the keyer sends one
|
||||||
// one element then stalls" — was settled in one line the moment the actual bytes
|
// element then stalls" — was settled in one line the moment the actual bytes
|
||||||
// were visible. The CI-V link has now produced the same class of report: a MOX
|
// were visible, and the CAT links have since produced the same class of report:
|
||||||
// button that sets split instead of transmitting, on a rig whose PTT command is
|
// a MOX button that sets split instead of transmitting, and a split the Kenwood
|
||||||
// demonstrably correct in the source. Guessing at that from the command table
|
// backend does not recognise on a rig whose frequency it reads perfectly.
|
||||||
// has already cost this project a wrong fix; the bytes will say what the rig was
|
//
|
||||||
// really asked.
|
// Both are questions about what the RIG actually said. Guessing at that from a
|
||||||
var civTrace atomic.Bool
|
// protocol document has already cost this project a wrong fix that broke the
|
||||||
|
// case which already worked.
|
||||||
|
var catTrace atomic.Bool
|
||||||
|
|
||||||
// SetCIVTrace turns the CI-V byte trace on or off.
|
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
|
||||||
|
// was written for; it now covers the text backends too.
|
||||||
func SetCIVTrace(on bool) {
|
func SetCIVTrace(on bool) {
|
||||||
civTrace.Store(on)
|
catTrace.Store(on)
|
||||||
if on {
|
if on {
|
||||||
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged")
|
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||||
} else {
|
} else {
|
||||||
debugLog.Printf("civ trace OFF")
|
debugLog.Printf("wire trace OFF")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||||
func CIVTraceEnabled() bool { return civTrace.Load() }
|
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||||
|
|
||||||
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it).
|
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||||
func traceCIV(dir string, b []byte) {
|
func traceCIV(dir string, b []byte) {
|
||||||
if !civTrace.Load() || len(b) == 0 {
|
if !catTrace.Load() || len(b) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
debugLog.Printf("civ %s % X", dir, b)
|
debugLog.Printf("civ %s % X", dir, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
|
||||||
|
//
|
||||||
|
// The text is QUOTED. A truncated or empty reply must be visible as such
|
||||||
|
// rather than vanish into the line: "" and ";" look identical unquoted, and
|
||||||
|
// telling them apart is the whole question when a rig answers a command it
|
||||||
|
// does not really support.
|
||||||
|
func traceText(backend, dir, s string) {
|
||||||
|
if !catTrace.Load() || s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debugLog.Printf("%s %s %q", backend, dir, s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
// Withdrawing a QSO from the external services after it is deleted locally.
|
||||||
|
//
|
||||||
|
// The two services could hardly be less alike, and the difference decides what
|
||||||
|
// is possible for QSOs already in the log:
|
||||||
|
//
|
||||||
|
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
|
||||||
|
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
|
||||||
|
// started recording that identifier therefore cannot be withdrawn from
|
||||||
|
// here; the operator has to do it on the website. QRZ's own documentation
|
||||||
|
// puts it plainly: "There is no undo from this operation."
|
||||||
|
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
|
||||||
|
// the band. Nothing has to have been stored in advance, so this works for
|
||||||
|
// every QSO in the log, past or future.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
|
||||||
|
const clublogDeleteURL = "https://clublog.org/delete.php"
|
||||||
|
|
||||||
|
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
|
||||||
|
//
|
||||||
|
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
|
||||||
|
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
|
||||||
|
//
|
||||||
|
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
|
||||||
|
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
|
||||||
|
// the QSO is being deleted locally either way, and "it was already gone from
|
||||||
|
// QRZ" is the outcome the operator wanted. The message is returned so the
|
||||||
|
// caller can log what actually happened.
|
||||||
|
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", fmt.Errorf("qrz: api key not set")
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(logIDs))
|
||||||
|
for _, id := range logIDs {
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("KEY", apiKey)
|
||||||
|
form.Set("ACTION", "DELETE")
|
||||||
|
form.Set("LOGIDS", strings.Join(ids, ","))
|
||||||
|
|
||||||
|
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("qrz: %w", err)
|
||||||
|
}
|
||||||
|
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
|
||||||
|
|
||||||
|
vals, _ := url.ParseQuery(strings.TrimSpace(body))
|
||||||
|
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||||
|
count := strings.TrimSpace(vals.Get("COUNT"))
|
||||||
|
switch result {
|
||||||
|
case "OK", "PARTIAL", "FAIL":
|
||||||
|
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
|
||||||
|
case "AUTH":
|
||||||
|
return "", fmt.Errorf("qrz: the logbook key was refused")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClublog removes one QSO from a Club Log logbook.
|
||||||
|
//
|
||||||
|
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
|
||||||
|
// second, so a rounded or local-time value simply finds nothing. band is an
|
||||||
|
// ADIF band name ("20m"), translated below to the band number Club Log wants.
|
||||||
|
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
|
||||||
|
email := strings.TrimSpace(cfg.Email)
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
dx := strings.ToUpper(strings.TrimSpace(dxCall))
|
||||||
|
switch {
|
||||||
|
case email == "":
|
||||||
|
return "", fmt.Errorf("clublog: account email not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return "", fmt.Errorf("clublog: password not set")
|
||||||
|
case call == "":
|
||||||
|
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||||
|
case dx == "":
|
||||||
|
return "", fmt.Errorf("clublog: no callsign to delete")
|
||||||
|
case whenUTC.IsZero():
|
||||||
|
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
|
||||||
|
}
|
||||||
|
bandID, ok := clublogBandID(band)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("email", email)
|
||||||
|
form.Set("password", cfg.Password)
|
||||||
|
form.Set("callsign", call)
|
||||||
|
form.Set("dxcall", dx)
|
||||||
|
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
form.Set("bandid", strconv.Itoa(bandID))
|
||||||
|
api := strings.TrimSpace(cfg.APIKey)
|
||||||
|
if api == "" {
|
||||||
|
api = clublogAppAPIKey
|
||||||
|
}
|
||||||
|
form.Set("api", api)
|
||||||
|
|
||||||
|
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("clublog: %w", err)
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(body)
|
||||||
|
LogSink("clublog: DELETE raw response: %s", msg)
|
||||||
|
// "QSO Not Deleted" means no record matched. That is not an error worth
|
||||||
|
// stopping for — the QSO is going away locally regardless, and a QSO absent
|
||||||
|
// from Club Log is the state being asked for.
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clublogBandID maps an ADIF band name to Club Log's band number.
|
||||||
|
//
|
||||||
|
// The list is Club Log's own, and it is deliberately NOT derived from the
|
||||||
|
// frequency: Club Log accepts these values and no others, so a band outside it
|
||||||
|
// must be reported rather than approximated to a neighbour.
|
||||||
|
func clublogBandID(band string) (int, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||||
|
case "160m":
|
||||||
|
return 160, true
|
||||||
|
case "80m":
|
||||||
|
return 80, true
|
||||||
|
case "60m":
|
||||||
|
return 60, true
|
||||||
|
case "40m":
|
||||||
|
return 40, true
|
||||||
|
case "30m":
|
||||||
|
return 30, true
|
||||||
|
case "20m":
|
||||||
|
return 20, true
|
||||||
|
case "17m":
|
||||||
|
return 17, true
|
||||||
|
case "15m":
|
||||||
|
return 15, true
|
||||||
|
case "12m":
|
||||||
|
return 12, true
|
||||||
|
case "10m":
|
||||||
|
return 10, true
|
||||||
|
case "6m":
|
||||||
|
return 6, true
|
||||||
|
case "4m":
|
||||||
|
return 4, true
|
||||||
|
case "2m":
|
||||||
|
return 2, true
|
||||||
|
case "70cm":
|
||||||
|
return 70, true
|
||||||
|
case "23cm":
|
||||||
|
return 23, true
|
||||||
|
case "13cm":
|
||||||
|
return 13, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// postForm is the shared request/read for both deletes.
|
||||||
|
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
b := string(raw)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
|
||||||
|
// that maps to the wrong number does not fail — it points the delete at a
|
||||||
|
// different QSO, or at nothing, silently. So the table is pinned.
|
||||||
|
func TestClublogBandID(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
band string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
|
||||||
|
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
|
||||||
|
{"6m", 6}, {"4m", 4}, {"2m", 2},
|
||||||
|
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
|
||||||
|
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
|
||||||
|
} {
|
||||||
|
got, ok := clublogBandID(c.band)
|
||||||
|
if !ok || got != c.want {
|
||||||
|
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bands Club Log does not accept must be REFUSED, never approximated to a
|
||||||
|
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
|
||||||
|
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
|
||||||
|
if id, ok := clublogBandID(bad); ok {
|
||||||
|
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,13 @@ type ExternalServices struct {
|
|||||||
HRDLog ServiceConfig `json:"hrdlog"`
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
EQSL ServiceConfig `json:"eqsl"`
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// service can undo it, and a local delete is not always meant to reach the
|
||||||
|
// world — a duplicate cleared from a contest log was never meant to be a
|
||||||
|
// statement about the DX's log.
|
||||||
|
DeleteRemote bool `json:"delete_remote"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
|
|||||||
}
|
}
|
||||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TraceEnabled reports whether the protocol trace is running.
|
||||||
|
//
|
||||||
|
// The settings dialog needs it: the checkbox is React state that starts false on
|
||||||
|
// every mount, so a trace switched on stayed on in the backend while the box
|
||||||
|
// reappeared unticked — the operator ticks it again, which switches it OFF, and
|
||||||
|
// the log they then send has no trace in it at all.
|
||||||
|
func TraceEnabled() bool { return traceOn.Load() }
|
||||||
|
|||||||
+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.22.2"
|
appVersion = "0.22.3"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user