Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0771d797 | ||
|
|
22e4266d38 | ||
|
|
e3332d1e27 | ||
|
|
5cebf163c5 | ||
|
|
cfd85ff9c3 | ||
|
|
c6f479750f | ||
|
|
386a8ad531 | ||
|
|
bcd7e409ba |
@@ -116,7 +116,10 @@ const (
|
|||||||
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
||||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||||
|
// Put the RADIO in USB for a digital mode instead of asking for the mode by
|
||||||
|
// name. Every backend, because the reason is the radio, not the link.
|
||||||
|
keyCATDigiUSB = "cat.digi_usb"
|
||||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||||
@@ -473,7 +476,10 @@ type CATSettings struct {
|
|||||||
// reports, "A"/"B" force one. Needed because that report is only as good as
|
// reports, "A"/"B" force one. Needed because that report is only as good as
|
||||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||||
|
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
|
||||||
|
// than naming the mode. What the QSO is LOGGED as never changes.
|
||||||
|
DigiAsUSB bool `json:"digi_as_usb"`
|
||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||||
@@ -2948,6 +2954,28 @@ func (a *App) reloadLookupProviders() {
|
|||||||
|
|
||||||
// --- QSO bindings ---
|
// --- QSO bindings ---
|
||||||
|
|
||||||
|
// fillRXDefaults stamps the receive side when the contact was not split.
|
||||||
|
//
|
||||||
|
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
|
||||||
|
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
|
||||||
|
// which door it came through. It shows up outside OpsLog: the record forwarded
|
||||||
|
// to another logger over UDP is written from the QSO as logged, and a receiver
|
||||||
|
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
|
||||||
|
// path that left it blank.
|
||||||
|
//
|
||||||
|
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
|
||||||
|
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
|
||||||
|
// the export and the forwarded copy all say the same thing.
|
||||||
|
func fillRXDefaults(q *qso.QSO) {
|
||||||
|
if strings.TrimSpace(q.BandRX) == "" {
|
||||||
|
q.BandRX = q.Band
|
||||||
|
}
|
||||||
|
if q.FreqRXHz == nil && q.FreqHz != nil {
|
||||||
|
v := *q.FreqHz
|
||||||
|
q.FreqRXHz = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
@@ -2963,6 +2991,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q, true)
|
a.applyStationDefaults(&q, true)
|
||||||
|
fillRXDefaults(&q)
|
||||||
fillDistance(&q)
|
fillDistance(&q)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
||||||
@@ -8212,7 +8241,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -8313,6 +8342,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||||
out.OmniRigVFO = v
|
out.OmniRigVFO = v
|
||||||
}
|
}
|
||||||
|
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
|
||||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||||
out.OmniRigNum = n
|
out.OmniRigNum = n
|
||||||
}
|
}
|
||||||
@@ -8421,6 +8451,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATBackend: s.Backend,
|
keyCATBackend: s.Backend,
|
||||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||||
|
keyCATDigiUSB: boolStr(s.DigiAsUSB),
|
||||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||||
keyCATFlexSpots: flexSpots,
|
keyCATFlexSpots: flexSpots,
|
||||||
@@ -11460,6 +11491,135 @@ func (a *App) TestClublogUpload() (string, error) {
|
|||||||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
|
||||||
|
//
|
||||||
|
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
|
||||||
|
// it has to be driven a full backlog costs the better part of an hour. This is
|
||||||
|
// the other endpoint HamQTH offers: a whole log as one file, which is why it
|
||||||
|
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
|
||||||
|
// nothing that is not in the file.
|
||||||
|
//
|
||||||
|
// Scoped to the callsign this profile uploads as: a database holding two
|
||||||
|
// operators' contacts must not push one operator's QSOs into the other's log.
|
||||||
|
func (a *App) UploadFullLogHamQTH() error {
|
||||||
|
if a.qso == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
cfg := a.loadExternalServices().HamQTH
|
||||||
|
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
|
||||||
|
return fmt.Errorf("set the HamQTH username and password first")
|
||||||
|
}
|
||||||
|
go a.runFullLogHamQTH(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
|
||||||
|
emit := func(line string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := func(n int) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := a.ctx
|
||||||
|
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
|
||||||
|
|
||||||
|
// Written to a temp file rather than a buffer so the ordinary, tested
|
||||||
|
// exporter does the work — the same one the Export menu uses.
|
||||||
|
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
|
||||||
|
if err != nil {
|
||||||
|
emit("Export failed: " + err.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := tmp.Name()
|
||||||
|
_ = tmp.Close()
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if owner != "" {
|
||||||
|
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
|
||||||
|
}
|
||||||
|
emit("Exporting the log…")
|
||||||
|
var res adif.ExportResult
|
||||||
|
if owner != "" {
|
||||||
|
// station_callsign empty OR the owner call — an old QSO logged before
|
||||||
|
// the field existed belongs to whoever is uploading now.
|
||||||
|
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
|
||||||
|
{Field: "station_callsign", Op: "eq", Value: ""},
|
||||||
|
{Field: "station_callsign", Op: "eq", Value: owner},
|
||||||
|
}}
|
||||||
|
res, err = a.ExportADIFFiltered(path, false, f, nil)
|
||||||
|
} else {
|
||||||
|
res, err = a.ExportADIF(path, false, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
emit("Export failed: " + err.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, rerr := os.ReadFile(path)
|
||||||
|
if rerr != nil {
|
||||||
|
emit("Export failed: " + rerr.Error())
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, _ := a.qso.Count(ctx)
|
||||||
|
if total > 0 && int64(res.Count) != total {
|
||||||
|
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
|
||||||
|
}
|
||||||
|
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
|
||||||
|
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
|
||||||
|
|
||||||
|
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
|
||||||
|
if uerr != nil || !up.OK {
|
||||||
|
msg := up.Message
|
||||||
|
if uerr != nil {
|
||||||
|
msg = uerr.Error()
|
||||||
|
}
|
||||||
|
emit("Upload failed: " + msg)
|
||||||
|
applog.Printf("hamqth: full-log upload failed: %s", msg)
|
||||||
|
done(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit("HamQTH replied: " + up.Message)
|
||||||
|
// Said plainly, because the numbers will not agree for a while and an
|
||||||
|
// operator comparing them straight away has every reason to think the
|
||||||
|
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
|
||||||
|
// own pace. What it makes of each record is reported by E-MAIL, never in
|
||||||
|
// this reply — so a count that stops short means the site rejected records,
|
||||||
|
// and the mail says which.
|
||||||
|
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
|
||||||
|
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
|
||||||
|
|
||||||
|
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
|
||||||
|
// the rows that are not already stamped need writing.
|
||||||
|
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
|
||||||
|
if lerr != nil {
|
||||||
|
applog.Printf("hamqth: marking sent: %v", lerr)
|
||||||
|
} else if len(pending) > 0 {
|
||||||
|
ids := make([]int64, 0, len(pending))
|
||||||
|
for _, q := range pending {
|
||||||
|
ids = append(ids, q.ID)
|
||||||
|
}
|
||||||
|
date := time.Now().UTC().Format("20060102")
|
||||||
|
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
|
||||||
|
applog.Printf("hamqth: marking sent: %v", e)
|
||||||
|
}
|
||||||
|
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
|
||||||
|
applog.Printf("hamqth: marking sent date: %v", e)
|
||||||
|
}
|
||||||
|
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
|
||||||
|
}
|
||||||
|
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
|
||||||
|
}
|
||||||
|
done(res.Count)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
|
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
|
||||||
// authenticated, and unable to touch the log.
|
// authenticated, and unable to touch the log.
|
||||||
func (a *App) TestHamQTHUpload() (string, error) {
|
func (a *App) TestHamQTHUpload() (string, error) {
|
||||||
@@ -14676,6 +14836,7 @@ func (a *App) SetCATMode(mode string) error {
|
|||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
}
|
}
|
||||||
|
mode = a.catModeForRadio(mode)
|
||||||
err := a.cat.SetMode(mode)
|
err := a.cat.SetMode(mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
||||||
@@ -14683,6 +14844,31 @@ func (a *App) SetCATMode(mode string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
|
||||||
|
// in, for operators who have asked for USB on digital.
|
||||||
|
//
|
||||||
|
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
|
||||||
|
// the microphone path, and that is what most rigs need. Asking for the mode by
|
||||||
|
// name is the better answer on a modern transceiver with a DATA/PKT position,
|
||||||
|
// and the wrong one on an older set where the CAT layer resolves "digital" to
|
||||||
|
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
|
||||||
|
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
|
||||||
|
// file, and there is no arguing with it from here — so the option sends what
|
||||||
|
// the radio can actually do.
|
||||||
|
//
|
||||||
|
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
|
||||||
|
// what the contact WAS, not what the front panel says.
|
||||||
|
func (a *App) catModeForRadio(mode string) string {
|
||||||
|
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if qso.ModeClass(mode) != "DIG" {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
|
||||||
|
return "USB"
|
||||||
|
}
|
||||||
|
|
||||||
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
||||||
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
|
||||||
|
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
|
||||||
|
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
|
||||||
|
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
|
||||||
|
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbook’s QSOs that leaves, the file size, and HamQTH’s own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
|
||||||
|
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
|
||||||
|
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen d’y revenir.",
|
||||||
|
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu d’une requête par QSO, une première synchro passe de près d’une heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site n’a pas d’envoi partiel) : il demande donc confirmation, se limite à l’indicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
|
||||||
|
"ADIF sortant (transfert d’un QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. L’import le faisait déjà, pas les chemins de log — ce qu’un QSO transportait dépendait donc de la porte par laquelle il était entré.",
|
||||||
|
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date d’envoi), le filtre de QSO et l’édition groupée — cette dernière pour qu’un log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à l’envoi.",
|
||||||
|
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — l’indicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
|
||||||
|
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande n’est touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne l’efface jamais.",
|
||||||
|
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.6",
|
"version": "0.27.6",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+15
-18
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||||
import { formatDistance } from '@/lib/units';
|
import { formatDistance } from '@/lib/units';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -432,21 +433,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bandForMHz(mhz: number): string {
|
|
||||||
if (!mhz || isNaN(mhz)) return '';
|
|
||||||
const plan: [number, number, string][] = [
|
|
||||||
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
|
||||||
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
|
||||||
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
|
||||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
|
||||||
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
|
|
||||||
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
|
||||||
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
|
||||||
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
|
||||||
];
|
|
||||||
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
||||||
// CW gold, phone green, digital blue, unknown muted.
|
// CW gold, phone green, digital blue, unknown muted.
|
||||||
@@ -8124,12 +8110,23 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Disconnect all
|
Disconnect all
|
||||||
</Button>
|
</Button>
|
||||||
{clusterServerStatuses.length === 0 && (
|
{clusterServers.filter((x) => x.enabled).length === 0 && (
|
||||||
<span className="text-xs text-muted-foreground italic">
|
<span className="text-xs text-muted-foreground italic">
|
||||||
No active sessions — configure clusters in Settings → DX Cluster.
|
No active sessions — configure clusters in Settings → DX Cluster.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{clusterServerStatuses.map((s) => {
|
{clusterServers
|
||||||
|
.filter((x) => x.enabled)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((srv) => {
|
||||||
|
// A disconnected server has no session to report, so it has no
|
||||||
|
// entry in the status list at all — the pill stands in for it
|
||||||
|
// as "disconnected", which is exactly the state you click to
|
||||||
|
// undo.
|
||||||
|
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
|
||||||
|
const s: ServerStatus = live ?? {
|
||||||
|
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
|
||||||
|
};
|
||||||
const isMaster = clusterServers
|
const isMaster = clusterServers
|
||||||
.filter((x) => x.enabled)
|
.filter((x) => x.enabled)
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
||||||
@@ -8159,7 +8156,7 @@ export default function App() {
|
|||||||
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||||||
'bg-muted text-muted-foreground border-border',
|
'bg-muted text-muted-foreground border-border',
|
||||||
)}
|
)}
|
||||||
title={`${s.name} — ${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''} · ${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
|
title={`${s.name} — ${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
|
||||||
>
|
>
|
||||||
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
||||||
{s.name}
|
{s.name}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
|
||||||
|
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -723,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{service === 'hamlog' ? (
|
{service === 'hamqth' ? (
|
||||||
|
// HamQTH's file endpoint REPLACES the remote log — its own
|
||||||
|
// documentation is explicit that partial uploads do not exist. So
|
||||||
|
// it is offered as its own deliberate act, never as the batch path
|
||||||
|
// behind "send these": that would delete everything not selected.
|
||||||
|
<Button variant="outline" size="sm" disabled={busy}
|
||||||
|
title={t('qslm.hqFullTitle')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
|
||||||
|
// Same three lines every other action here runs: without
|
||||||
|
// setShowLog the whole upload reported itself into a panel
|
||||||
|
// nobody was showing, and the tab sat on "Pick a service".
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
|
try { await UploadFullLogHamQTH(); }
|
||||||
|
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
|
||||||
|
}}>
|
||||||
|
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
|
||||||
|
</Button>
|
||||||
|
) : service === 'hamlog' ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
title={t('qslm.hamlogImportTitle')}>
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
@@ -312,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
: { khz: '', hz: '' };
|
: { khz: '', hz: '' };
|
||||||
|
// Correcting a frequency corrects its band. The pair has to agree — the log,
|
||||||
|
// every award and every upload are read on the BAND — and an operator fixing
|
||||||
|
// a wrong frequency is not also expecting to fix the band by hand, which is
|
||||||
|
// exactly how a QSO ends up filed on 20m at 7 MHz.
|
||||||
|
//
|
||||||
|
// Only when the number lands in a known allocation: half a frequency is typed
|
||||||
|
// on the way to all of it, and a band must never be blanked by that.
|
||||||
|
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
|
||||||
|
if (!khz.trim()) return;
|
||||||
|
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
|
||||||
|
if (b) set(field, b as any);
|
||||||
|
};
|
||||||
|
|
||||||
const f0 = splitHz(draft.freq_hz);
|
const f0 = splitHz(draft.freq_hz);
|
||||||
const fr0 = splitHz(draft.freq_rx_hz);
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
@@ -634,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
|
// HamQTH, the extras again — sent only, the site having no confirmations.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
|
|||||||
@@ -1612,7 +1612,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
|
||||||
});
|
});
|
||||||
// Brand + connection, derived from the stored backend rather than held
|
// Brand + connection, derived from the stored backend rather than held
|
||||||
// separately: two sources for one fact drift apart the first time something
|
// separately: two sources for one fact drift apart the first time something
|
||||||
@@ -3840,6 +3840,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label> </>
|
</label> </>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
|
||||||
|
<Checkbox
|
||||||
|
checked={!!catCfg.digi_as_usb}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
|
||||||
|
/>
|
||||||
|
{t('cat.digiUsb')}
|
||||||
|
</label>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
|||||||
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
|
|||||||
export function bandSegments(band: string): Seg[] {
|
export function bandSegments(band: string): Seg[] {
|
||||||
return plan().segments[band] ?? [];
|
return plan().segments[band] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
|
||||||
|
// outside every known allocation.
|
||||||
|
//
|
||||||
|
// Lives here rather than in a panel because more than one place has to answer
|
||||||
|
// the same question the same way: the entry form retunes on a typed frequency,
|
||||||
|
// and the QSO editor has to keep band and frequency agreeing when one of them
|
||||||
|
// is corrected. Kept in step with BandFromHz on the Go side.
|
||||||
|
export function bandForMHz(mhz: number): string {
|
||||||
|
if (!mhz || isNaN(mhz)) return '';
|
||||||
|
const plan: [number, number, string][] = [
|
||||||
|
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||||
|
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||||
|
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||||
|
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
||||||
|
// Microwave, ADIF 3.1.7 ranges.
|
||||||
|
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
||||||
|
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
||||||
|
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -1397,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UploadCallsign(arg1:string):Promise<string>;
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH():Promise<void>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -2730,6 +2730,10 @@ export function UploadCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['UploadCallsign'](arg1);
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH() {
|
||||||
|
return window['go']['main']['App']['UploadFullLogHamQTH']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2298,6 +2298,7 @@ export namespace main {
|
|||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
omnirig_vfo: string;
|
omnirig_vfo: string;
|
||||||
|
digi_as_usb: boolean;
|
||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
@@ -2350,6 +2351,7 @@ export namespace main {
|
|||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
this.omnirig_vfo = source["omnirig_vfo"];
|
this.omnirig_vfo = source["omnirig_vfo"];
|
||||||
|
this.digi_as_usb = source["digi_as_usb"];
|
||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package extsvc
|
package extsvc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +22,20 @@ import (
|
|||||||
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
|
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
|
||||||
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
|
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
|
||||||
|
|
||||||
|
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
|
||||||
|
// documentation says "you always have to upload whole log. HamQTH doesn't
|
||||||
|
// support partial upload" — the file REPLACES what is on the site. That is why
|
||||||
|
// it is not the batch path for a selection, and why the caller must have said
|
||||||
|
// so out loud before we get here.
|
||||||
|
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
|
||||||
|
|
||||||
|
// hamqthMaxUpload is the documented ceiling for one upload.
|
||||||
|
const hamqthMaxUpload = 20 << 20
|
||||||
|
|
||||||
|
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
|
||||||
|
// under the limit: the multipart envelope and the form fields ride along too.
|
||||||
|
const hamqthCompressAbove = 12 << 20
|
||||||
|
|
||||||
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
|
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
|
||||||
// endpoint that cannot change anything in the log, which is what the settings
|
// endpoint that cannot change anything in the log, which is what the settings
|
||||||
// Test button must call.
|
// Test button must call.
|
||||||
@@ -92,6 +110,127 @@ func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
|
||||||
|
//
|
||||||
|
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
|
||||||
|
// for this callsign that is not in this file stops existing. The caller owns
|
||||||
|
// the confirmation.
|
||||||
|
//
|
||||||
|
// The file goes in the multipart field "f" (HamQTH's own curl example:
|
||||||
|
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
|
||||||
|
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
|
||||||
|
// 20 MB and a six-figure log passes it as plain text.
|
||||||
|
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
switch {
|
||||||
|
case user == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||||
|
case strings.TrimSpace(adifText) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte(adifText)
|
||||||
|
name := "opslog.adi"
|
||||||
|
if len(payload) > hamqthCompressAbove {
|
||||||
|
gz, err := tarGzADIF(payload)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
|
||||||
|
}
|
||||||
|
payload, name = gz, "opslog.tar.gz"
|
||||||
|
}
|
||||||
|
if len(payload) > hamqthMaxUpload {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
|
||||||
|
len(payload)>>20, hamqthMaxUpload>>20)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("u", user)
|
||||||
|
_ = mw.WriteField("p", cfg.Password)
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||||
|
_ = mw.WriteField("c", c)
|
||||||
|
}
|
||||||
|
_ = mw.WriteField("send_log", "OK")
|
||||||
|
fw, err := mw.CreateFormFile("f", name)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(payload); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if client == nil {
|
||||||
|
// A whole log is a long POST on a slow uplink.
|
||||||
|
client = &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
msg := strings.TrimSpace(string(raw))
|
||||||
|
if looksLikeHTML(msg) {
|
||||||
|
msg = ""
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg != "" && len(msg) < 300 {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// The site answers in prose, and only its own refusals are worth reading
|
||||||
|
// back: the ADIF itself is validated later, in the background, and any
|
||||||
|
// complaint about it reaches the operator by e-mail rather than here.
|
||||||
|
low := strings.ToLower(msg)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "successfully"):
|
||||||
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
|
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case strings.Contains(low, "cannot upload log for this callsign"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case msg == "":
|
||||||
|
// HTTP 200 with nothing to say: taken as accepted, and said so.
|
||||||
|
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
|
||||||
|
default:
|
||||||
|
return UploadResult{OK: false, Message: msg}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
|
||||||
|
// a .adi/.adif member for HamQTH to find the log in it.
|
||||||
|
func tarGzADIF(adif []byte) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&out)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(adif); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestHamQTH verifies the credentials against the callbook session login —
|
// TestHamQTH verifies the credentials against the callbook session login —
|
||||||
// authenticated, and unable to touch the log.
|
// authenticated, and unable to touch the log.
|
||||||
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
|||||||
@@ -995,6 +995,12 @@ var bulkEditableExtras = map[string]string{
|
|||||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||||
|
// HamQTH, same story and SENT only — the site publishes no confirmations,
|
||||||
|
// so there is no received side to edit. Bulk-editable for the one case that
|
||||||
|
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
|
||||||
|
// offer to send all over again.
|
||||||
|
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
|
||||||
|
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||||
@@ -1403,6 +1409,10 @@ var filterableExtras = map[string]string{
|
|||||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||||
|
// HamQTH — sent only, and filterable for the question that precedes every
|
||||||
|
// backlog upload: "which contacts have never gone there".
|
||||||
|
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
|
||||||
|
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterableFields returns the whitelist (for the frontend to build its field
|
// FilterableFields returns the whitelist (for the frontend to build its field
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A contact that was not split still has a receive side, and the record
|
||||||
|
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
|
||||||
|
func TestFillRXDefaults(t *testing.T) {
|
||||||
|
hz := int64(14074000)
|
||||||
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
|
||||||
|
fillRXDefaults(&q)
|
||||||
|
if q.BandRX != "20m" {
|
||||||
|
t.Errorf("BandRX = %q, want 20m", q.BandRX)
|
||||||
|
}
|
||||||
|
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
|
||||||
|
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
|
||||||
|
}
|
||||||
|
rec := adif.SingleRecordADIF(q)
|
||||||
|
if !strings.Contains(strings.ToUpper(rec), "<BAND_RX:3>20M") {
|
||||||
|
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine split contact keeps what it was given.
|
||||||
|
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
|
||||||
|
tx, rx := int64(14195000), int64(14205000)
|
||||||
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
|
||||||
|
fillRXDefaults(&q)
|
||||||
|
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
|
||||||
|
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user