feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort

- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown.
- Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment.
- Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped.
- Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time.
- Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
This commit is contained in:
2026-07-23 13:22:02 +02:00
parent 1070637c40
commit 2823f3e401
13 changed files with 606 additions and 214 deletions
+82
View File
@@ -5171,6 +5171,11 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
// Frequency is numeric and drives the band, so it takes a dedicated path
// (freq_hz + band updated together) rather than the generic text setter.
if field == "freq" {
return a.bulkSetFrequency(ids, value)
}
col, ok := bulkFieldColumns[field]
if !ok {
return 0, fmt.Errorf("unknown field %q", field)
@@ -5189,6 +5194,34 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
return n, nil
}
// bulkSetFrequency parses a MHz frequency and sets freq_hz + band across the
// selected QSOs. Accepts a comma or dot decimal separator (fr locale) and
// rejects anything outside the ham bands so a typo can't corrupt the batch.
func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) {
s := strings.ReplaceAll(strings.TrimSpace(value), ",", ".")
if s == "" {
return 0, fmt.Errorf("enter a frequency in MHz (e.g. 7.155)")
}
mhz, err := strconv.ParseFloat(s, 64)
if err != nil || mhz <= 0 {
return 0, fmt.Errorf("invalid frequency %q — enter MHz, e.g. 7.155", value)
}
hz := int64(math.Round(mhz * 1_000_000))
band := cat.BandFromHz(hz)
if band == "" {
return 0, fmt.Errorf("%.4f MHz is not in a ham band", mhz)
}
n, err := a.qso.BulkSetFrequency(a.ctx, ids, hz, band)
if err != nil {
return 0, err
}
if n > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // band change may shift award slots → refresh
}
return n, nil
}
// WorkedBefore returns prior contacts with the given callsign at both
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
// will infer it from past QSOs with the same call when possible.
@@ -7178,6 +7211,55 @@ func (a *App) SendQSORecordingEmail(id int64) error {
return nil
}
// developerEmail is where "Help → Send log to F4BPO" delivers diagnostic logs.
const developerEmail = "[email protected]"
// SMTPConfigured reports whether an SMTP server is set up, so the UI can show the
// "Send log to F4BPO" help entry only when there's actually a way to send.
func (a *App) SMTPConfigured() bool {
s, _ := a.GetEmailSettings()
return strings.TrimSpace(s.Host) != ""
}
// SendLogToDeveloper e-mails the app's diagnostic logs (opslog.log, the previous
// session's opslog.log.1, and any crash log) to the developer so problems users
// hit in the field can be diagnosed. Requires a configured SMTP server
// (Settings → E-mail). A short header identifies the sender's call and version.
func (a *App) SendLogToDeveloper() error {
s, _ := a.GetEmailSettings()
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("no SMTP server configured — set one in Settings → E-mail")
}
logPath := applog.Path()
if logPath == "" {
return fmt.Errorf("log file not available")
}
// The current log, plus the rotated one and the crash log when they exist.
attach := []string{logPath}
for _, extra := range []string{logPath + ".1", filepath.Join(filepath.Dir(logPath), "opslog-crash.log")} {
if _, err := os.Stat(extra); err == nil {
attach = append(attach, extra)
}
}
call := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
call = p.Callsign
}
}
subject := fmt.Sprintf("OpsLog log — v%s", appVersion)
if call != "" {
subject = fmt.Sprintf("OpsLog log — %s v%s", call, appVersion)
}
body := fmt.Sprintf("OpsLog diagnostic log attached.\n\nCallsign: %s\nVersion: %s\n\n73", call, appVersion)
if err := email.SendFiles(a.emailConfig(s), developerEmail, subject, body, attach); err != nil {
applog.Printf("email: send log to developer failed: %v", err)
return err
}
applog.Printf("email: diagnostic log sent to developer (%s)", developerEmail)
return nil
}
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
// ClublogCtyInfo is the UI status of the ClubLog exception data.