Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 |
@@ -1653,6 +1653,11 @@ func (a *App) saveWindowState() {
|
||||
x, y := wruntime.WindowGetPosition(a.ctx)
|
||||
ws.X, ws.Y = x, y
|
||||
}
|
||||
// Logged because this is the only evidence of WHAT was stored: an operator
|
||||
// reporting "it reopens on the wrong screen" cannot tell a position that was
|
||||
// never captured from one that was captured wrong, and the two need opposite
|
||||
// fixes.
|
||||
applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
|
||||
writeWindowState(a.dataDir, ws)
|
||||
}
|
||||
|
||||
@@ -1667,15 +1672,20 @@ func (a *App) restoreWindowPosition() {
|
||||
}
|
||||
ws, ok := readWindowState(a.dataDir)
|
||||
if !ok {
|
||||
applog.Printf("window: no saved geometry — opening where Windows puts it")
|
||||
return
|
||||
}
|
||||
applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
|
||||
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
|
||||
// so a two-screen operator lost OpsLog to the wrong monitor at every launch.
|
||||
// Un-maximise, move to the saved corner (which names the monitor), maximise
|
||||
// again — all while the window is still hidden, so nothing flickers.
|
||||
if ws.Maximised {
|
||||
if ws.X == 0 && ws.Y == 0 {
|
||||
return // never recorded (or genuinely the primary corner) — nothing to do
|
||||
// Either never recorded, or genuinely the primary monitor's corner —
|
||||
// indistinguishable, and both mean "leave it to Windows".
|
||||
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
|
||||
return
|
||||
}
|
||||
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
|
||||
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
|
||||
@@ -1684,6 +1694,8 @@ func (a *App) restoreWindowPosition() {
|
||||
wruntime.WindowUnmaximise(a.ctx)
|
||||
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
|
||||
wruntime.WindowMaximise(a.ctx)
|
||||
gx, gy := wruntime.WindowGetPosition(a.ctx)
|
||||
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
|
||||
return
|
||||
}
|
||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
||||
@@ -3846,7 +3858,9 @@ type AwardStatsResult struct {
|
||||
Rows []AwardStatRow `json:"rows"`
|
||||
}
|
||||
|
||||
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
|
||||
var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
|
||||
"6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||
|
||||
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
|
||||
// reused across award computations. Pulling the whole logbook is the dominant
|
||||
@@ -4176,7 +4190,11 @@ var awardBandPlan = []struct {
|
||||
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
|
||||
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
|
||||
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
|
||||
{"13cm", 2300000000, 2450000000},
|
||||
{"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
|
||||
{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
|
||||
{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
|
||||
{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
|
||||
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
|
||||
}
|
||||
|
||||
func bandForHz(hz int64) string {
|
||||
@@ -9557,21 +9575,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
// QSO date. sinceDate is "YYYY-MM-DD".
|
||||
sinceDate := resolveSince(keyExtQRZLastDownload)
|
||||
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
|
||||
if sinceDate != "" {
|
||||
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…")
|
||||
} else {
|
||||
emit("Fetching QRZ.com logbook (full — no since date)…")
|
||||
}
|
||||
// An automatic run ("last") and an operator-chosen date do not mean the
|
||||
// same thing, so they ask QRZ two different questions:
|
||||
// • "last" → MODSINCE: everything TOUCHED since the last run,
|
||||
// which is what catches a 2015 QSO confirmed yesterday.
|
||||
// • a typed date → BETWEEN: the QSOs MADE from that date to today. The
|
||||
// operator asked for a period of operating, not for
|
||||
// whatever QRZ happened to edit in that period.
|
||||
autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
|
||||
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
|
||||
// server serialise every QSO — 56 MB for a 117k-record log — which it
|
||||
// truncates mid-record, so the parse died two thirds of the way through
|
||||
// and everything past that point was never seen. AFTER: narrows it at the
|
||||
// source; the client-side date filter below stays as the backstop.
|
||||
fetchOpt := "ALL"
|
||||
if sinceDate != "" {
|
||||
fetchOpt = "AFTER:" + sinceDate
|
||||
//
|
||||
// The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
|
||||
// and QRZ rejected the whole fetch, which broke the download outright.
|
||||
// MODSINCE is also the RIGHT window for this job: it selects records
|
||||
// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
|
||||
// back — a QSO-date window would never return it, and confirmations are
|
||||
// exactly what this download is for.
|
||||
//
|
||||
// It stays an optimisation, never a requirement: if QRZ refuses it, fall
|
||||
// back to ALL rather than leaving the operator with no download at all.
|
||||
//
|
||||
// serverWindowed is set only for MODSINCE, because it alone returns QSOs
|
||||
// OLDER than the date — the client-side date filter would drop exactly
|
||||
// those. Under BETWEEN the filter agrees with the server and stays on as a
|
||||
// backstop.
|
||||
fetchOpt, serverWindowed := "ALL", false
|
||||
switch {
|
||||
case sinceDate == "":
|
||||
emit("Fetching QRZ.com logbook (full — no since date)…")
|
||||
case autoWindow:
|
||||
fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
|
||||
emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
|
||||
default:
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
fetchOpt = "BETWEEN:" + sinceDate + "+" + today
|
||||
emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
|
||||
}
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
|
||||
if err != nil && fetchOpt != "ALL" {
|
||||
emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
|
||||
fetchOpt, serverWindowed = "ALL", false
|
||||
fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
|
||||
}
|
||||
if err != nil {
|
||||
emit("Fetch failed: " + err.Error())
|
||||
done(matched, total)
|
||||
@@ -9631,7 +9680,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
return nil
|
||||
}
|
||||
// Date window (client-side): skip QSOs older than the requested date.
|
||||
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||
// ONLY when the server did not window the fetch itself — MODSINCE
|
||||
// returns old QSOs whose CONFIRMATION is recent, and filtering those
|
||||
// out by QSO date would throw away the very records asked for.
|
||||
if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||
return nil
|
||||
}
|
||||
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||
// logged, not counted in any award slot, and exports without a BAND field.
|
||||
//
|
||||
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||
// missing entirely.
|
||||
func TestBandPlansAgree(t *testing.T) {
|
||||
cases := []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{137000, "2190m"},
|
||||
{475000, "630m"},
|
||||
{1840000, "160m"},
|
||||
{3650000, "80m"},
|
||||
{7100000, "40m"},
|
||||
{10120000, "30m"},
|
||||
{14200000, "20m"},
|
||||
{18100000, "17m"},
|
||||
{21200000, "15m"},
|
||||
{24940000, "12m"},
|
||||
{28400000, "10m"},
|
||||
{50150000, "6m"},
|
||||
{70200000, "4m"},
|
||||
{144300000, "2m"},
|
||||
{223500000, "1.25m"},
|
||||
{432000000, "70cm"},
|
||||
{1296000000, "23cm"},
|
||||
// The microwave bands that were missing. 3 cm is the one an operator
|
||||
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||
{2320000000, "13cm"},
|
||||
{3400000000, "9cm"},
|
||||
{5760000000, "6cm"},
|
||||
{10368000000, "3cm"},
|
||||
{24048000000, "1.25cm"},
|
||||
{47088000000, "6mm"},
|
||||
{76032000000, "4mm"},
|
||||
{122250000000, "2.5mm"},
|
||||
{134928000000, "2mm"},
|
||||
{241920000000, "1mm"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
if got := bandForHz(c.hz); got != c.want {
|
||||
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||
if got := cat.BandFromHz(hz); got != "" {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
if got := bandForHz(hz); got != "" {
|
||||
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"version": "0.21.8",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
|
||||
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
|
||||
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
|
||||
],
|
||||
"fr": [
|
||||
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
|
||||
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
|
||||
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.7",
|
||||
"date": "2026-07-28",
|
||||
|
||||
@@ -272,7 +272,11 @@ function bandForMHz(mhz: 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'], [1240, 1300, '23cm'],
|
||||
[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 '';
|
||||
|
||||
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
@@ -2358,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.port')}</Label>
|
||||
<Input type="number" value={catCfg.flex_port || 4992}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||
<PortInput value={catCfg.flex_port || 4992}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
@@ -2482,8 +2482,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.port')}</Label>
|
||||
<Input type="number" value={catCfg.tci_port || 40001}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
|
||||
<PortInput value={catCfg.tci_port || 40001}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
|
||||
</div>
|
||||
<p className="col-span-2 text-xs text-muted-foreground">
|
||||
{t('cat.tciHint')}
|
||||
@@ -2654,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
<PortInput
|
||||
value={ultrabeam.port}
|
||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||
onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -2943,8 +2942,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input type="number" min={1} max={65535} value={amp.port}
|
||||
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
|
||||
<PortInput value={amp.port}
|
||||
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -3114,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
<PortInput
|
||||
value={rotator.port}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
|
||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -4678,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('db.host')}</Label>
|
||||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||
<Label className="text-sm">{t('db.port')}</Label>
|
||||
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
|
||||
<PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
|
||||
<Label className="text-sm">{t('db.database')}</Label>
|
||||
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
|
||||
<Label className="text-sm">{t('db.user')}</Label>
|
||||
@@ -5154,7 +5152,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
|
||||
<Label className="text-sm">Port / encryption</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
|
||||
<PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
|
||||
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -5358,6 +5356,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
// PortInput — a TCP port field you can actually clear.
|
||||
//
|
||||
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
|
||||
// the contents and parseInt gives NaN, so the default is written straight back:
|
||||
// the digits reappear under the cursor and the only way to enter a different
|
||||
// port is to overwrite character by character. Reported on the cluster editor;
|
||||
// the same line existed in eight other places.
|
||||
//
|
||||
// The raw TEXT is the state here, and the parent is only told about a value that
|
||||
// is actually a port. An empty box stays empty while you type; on blur it falls
|
||||
// back to the last good value, so nothing is ever saved as 0 or NaN.
|
||||
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
|
||||
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
|
||||
}) {
|
||||
const [text, setText] = useState(value ? String(value) : '');
|
||||
const typed = useRef(false);
|
||||
// Only adopt the incoming value while the operator has NOT started typing —
|
||||
// settings arrive from the backend after mount, and re-syncing mid-edit is
|
||||
// precisely the behaviour being fixed.
|
||||
useEffect(() => {
|
||||
if (!typed.current) setText(value ? String(value) : '');
|
||||
}, [value]);
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={className}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
typed.current = true;
|
||||
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
|
||||
setText(digits);
|
||||
const n = parseInt(digits, 10);
|
||||
if (n >= min && n <= max) onChange(n);
|
||||
}}
|
||||
onBlur={() => {
|
||||
typed.current = false;
|
||||
const n = parseInt(text, 10);
|
||||
if (!(n >= min && n <= max)) {
|
||||
setText(String(fallback));
|
||||
onChange(fallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ClusterServerEditor edits one row of cluster_servers. Init commands are
|
||||
// free-form (one per line); the backend strips blanks and "//" comments.
|
||||
interface ClusterEditorProps {
|
||||
@@ -5389,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
|
||||
<PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Login callsign (optional)</Label>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// DXCC : field "dxcc" (no pattern) → entity number
|
||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
|
||||
// Microwave. A band missing from this order sorts to the end of an award's
|
||||
// band columns rather than in frequency order — and 3 cm was missing.
|
||||
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||
|
||||
func sortedBands(m map[string]int) []string {
|
||||
idx := map[string]int{}
|
||||
|
||||
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
|
||||
func BandFromHz(hz int64) string {
|
||||
mhz := float64(hz) / 1_000_000
|
||||
switch {
|
||||
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
|
||||
// which then cost them the band on the QSO and in every award count.
|
||||
case mhz >= 0.1357 && mhz <= 0.1378:
|
||||
return "2190m"
|
||||
case mhz >= 0.472 && mhz <= 0.479:
|
||||
return "630m"
|
||||
case mhz >= 0.501 && mhz <= 0.504:
|
||||
return "560m"
|
||||
case mhz >= 1.8 && mhz <= 2.0:
|
||||
return "160m"
|
||||
case mhz >= 3.5 && mhz <= 4.0:
|
||||
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
|
||||
// actually work, and spot — resolved to an empty band: no band on the QSO, no
|
||||
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
|
||||
// which is what an export has to carry.
|
||||
case mhz >= 2300.0 && mhz <= 2450.0:
|
||||
return "13cm"
|
||||
case mhz >= 3300.0 && mhz <= 3500.0:
|
||||
return "9cm"
|
||||
case mhz >= 5650.0 && mhz <= 5925.0:
|
||||
return "6cm"
|
||||
case mhz >= 10000.0 && mhz <= 10500.0:
|
||||
return "3cm"
|
||||
case mhz >= 24000.0 && mhz <= 24250.0:
|
||||
return "1.25cm"
|
||||
case mhz >= 47000.0 && mhz <= 47200.0:
|
||||
return "6mm"
|
||||
case mhz >= 75500.0 && mhz <= 81000.0:
|
||||
return "4mm"
|
||||
case mhz >= 119980.0 && mhz <= 123000.0:
|
||||
return "2.5mm"
|
||||
case mhz >= 134000.0 && mhz <= 149000.0:
|
||||
return "2mm"
|
||||
case mhz >= 241000.0 && mhz <= 250000.0:
|
||||
return "1mm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
// Microwave — a 10 GHz spot used to land with no band at all, so it could
|
||||
// not be filtered, matched against the log, or shown on a band map.
|
||||
case mhz >= 2300.0 && mhz <= 2450.0:
|
||||
return "13cm"
|
||||
case mhz >= 3300.0 && mhz <= 3500.0:
|
||||
return "9cm"
|
||||
case mhz >= 5650.0 && mhz <= 5925.0:
|
||||
return "6cm"
|
||||
case mhz >= 10000.0 && mhz <= 10500.0:
|
||||
return "3cm"
|
||||
case mhz >= 24000.0 && mhz <= 24250.0:
|
||||
return "1.25cm"
|
||||
case mhz >= 47000.0 && mhz <= 47200.0:
|
||||
return "6mm"
|
||||
case mhz >= 75500.0 && mhz <= 81000.0:
|
||||
return "4mm"
|
||||
case mhz >= 119980.0 && mhz <= 123000.0:
|
||||
return "2.5mm"
|
||||
case mhz >= 134000.0 && mhz <= 149000.0:
|
||||
return "2mm"
|
||||
case mhz >= 241000.0 && mhz <= 250000.0:
|
||||
return "1mm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user