Compare commits
4
Commits
v0.20.1
...
4ab4f70349
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b |
@@ -1850,13 +1850,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||
a.applyQSLDefaults(&q)
|
||||
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
||||
q.Email = lr.Email
|
||||
}
|
||||
}
|
||||
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
|
||||
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
|
||||
// form carries it here. There is deliberately NO second lookup at log time: it
|
||||
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
||||
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
||||
id, err = a.qso.Add(a.ctx, q)
|
||||
if err != nil && db.IsConnLost(err) {
|
||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||
@@ -4528,7 +4527,16 @@ func (a *App) GetQSORate() QSORate {
|
||||
if a.qso == nil {
|
||||
return QSORate{}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
|
||||
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
|
||||
// so each op sees their own performance, not the cumulative station rate. An
|
||||
// empty operator (single-op / station owner) matches all their QSOs.
|
||||
operator := ""
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
operator = p.Operator
|
||||
}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(counts) < 2 {
|
||||
return QSORate{}
|
||||
}
|
||||
@@ -5292,7 +5300,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
||||
// Bound the whole lookup: give the providers a couple of seconds, then let
|
||||
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
|
||||
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
|
||||
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
|
||||
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
r, err := a.lookup.Lookup(ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||
}
|
||||
@@ -5822,8 +5837,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
||||
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
||||
// Persist ONLY this extras key (targeted) — a full-row Update from this
|
||||
// in-memory copy could revert a column a concurrent post-log action changed.
|
||||
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
||||
applog.Printf("qso-rec: store recording path: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6695,17 +6712,10 @@ func (a *App) markRecordingSent(id int64) {
|
||||
if a.qso == nil || id == 0 {
|
||||
return
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
|
||||
return
|
||||
}
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
|
||||
// Targeted extras write — never a full-row Update, which (from a stale copy)
|
||||
// could revert a clublog/qrz upload-status another action just stamped.
|
||||
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
|
||||
applog.Printf("qso-rec: mark sent %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||
return err
|
||||
}
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||
// row unchanged apart from this field.
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||
//
|
||||
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||
}
|
||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||
|
||||
+32
-6
@@ -605,6 +605,11 @@ export default function App() {
|
||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
|
||||
// can't stop a burst of Enter presses / clicks fired before the re-render — each
|
||||
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
|
||||
// lookup made the log take seconds. This ref blocks the repeat immediately.
|
||||
const savingRef = useRef(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
@@ -1153,6 +1158,10 @@ export default function App() {
|
||||
const [lookupError, setLookupError] = useState('');
|
||||
const lookupTimerRef = useRef<number | null>(null);
|
||||
const wbTimerRef = useRef<number | null>(null);
|
||||
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
|
||||
// in-flight lookup discards its result when it finally returns instead of
|
||||
// re-populating a field the operator just cleared.
|
||||
const lookupGenRef = useRef(0);
|
||||
const [wb, setWb] = useState<WB | null>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
|
||||
@@ -2155,7 +2164,9 @@ export default function App() {
|
||||
}, [spots]);
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return; // a log is already in flight — ignore the repeat
|
||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||
savingRef.current = true;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||
@@ -2225,13 +2236,21 @@ export default function App() {
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally { setSaving(false); }
|
||||
} finally { setSaving(false); savingRef.current = false; }
|
||||
}
|
||||
|
||||
// resetEntry clears the form for the next QSO. Triggered after a
|
||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||
// are preserved so backdated batches stay productive.
|
||||
function resetEntry() {
|
||||
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
|
||||
// up" spinner immediately, and invalidate any request already in flight so its
|
||||
// late result can't re-fill the field we're about to clear (the "ESC clears the
|
||||
// QSO but the QRZ lookup keeps going" bug).
|
||||
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
|
||||
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
|
||||
lookupGenRef.current++;
|
||||
setLookupBusy(false);
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||
@@ -2436,14 +2455,15 @@ export default function App() {
|
||||
}
|
||||
async function runLookup(call: string) {
|
||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||
setLookupBusy(true);
|
||||
try {
|
||||
const r = await LookupCallsign(call);
|
||||
// Discard a STALE result: the operator already moved to another call
|
||||
// (clicked a new spot / typed) while this lookup was in flight. Applying it
|
||||
// would clobber the current call's fields and zoom the map to the wrong
|
||||
// station — the bug where replacing a call didn't re-zoom the map.
|
||||
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||
// was in flight. Applying it would clobber the current fields and zoom the
|
||||
// map to the wrong station.
|
||||
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||
lastLookedUpRef.current = call;
|
||||
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
||||
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
||||
@@ -2496,9 +2516,15 @@ export default function App() {
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
} finally { setLookupBusy(false); }
|
||||
}
|
||||
} finally {
|
||||
// Only clear the spinner if we're still the current lookup — a newer one
|
||||
// (or an ESC that already reset it) owns the busy state otherwise.
|
||||
if (gen === lookupGenRef.current) setLookupBusy(false);
|
||||
}
|
||||
}
|
||||
function scheduleLookup(value: string, force?: boolean) {
|
||||
setLookupError('');
|
||||
|
||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||
lastRx atomic.Int64
|
||||
|
||||
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||
dead atomic.Bool
|
||||
|
||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||
// Torn down alongside the CI-V/control streams in Close.
|
||||
audio *icomAudio
|
||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||
func (n *icomNet) Alive() bool {
|
||||
if n.dead.Load() {
|
||||
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||
}
|
||||
last := n.lastRx.Load()
|
||||
if last == 0 {
|
||||
return true // just connected, nothing received yet — give it a chance
|
||||
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
|
||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case 0x05: // rig-initiated disconnect — it dropped US
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||
default:
|
||||
// Anything else on the control stream is (almost always) the rig's
|
||||
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
|
||||
n.resend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||
|
||||
+17
-1
@@ -32,6 +32,7 @@ type OmniRig struct {
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||
|
||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||
@@ -80,11 +81,20 @@ func (o *OmniRig) Connect() error {
|
||||
o.rig = rigVar.ToIDispatch()
|
||||
|
||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
||||
o.rigType = rt.ToString()
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
|
||||
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
|
||||
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
|
||||
// shows.
|
||||
func (o *OmniRig) isIC7610() bool {
|
||||
return strings.Contains(strings.ToUpper(o.rigType), "7610")
|
||||
}
|
||||
|
||||
func (o *OmniRig) Disconnect() {
|
||||
if o.rig != nil {
|
||||
o.rig.Release()
|
||||
@@ -199,6 +209,12 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
s.FreqHz = freqMain
|
||||
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
|
||||
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
|
||||
// explicitly for the 7610 — what the operator actually wants to see.
|
||||
if o.isIC7610() && freqA != 0 {
|
||||
s.FreqHz = freqA
|
||||
}
|
||||
if s.FreqHz == 0 {
|
||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||
s.FreqHz = freqB
|
||||
|
||||
+23
-61
@@ -87,77 +87,39 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
|
||||
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
|
||||
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
|
||||
// of concurrent requests at once — Club Log's nginx answers that with 403, the
|
||||
// upload is counted as failed and the QSO stays at "R" despite the others going
|
||||
// through. One-at-a-time with a small gap keeps every upload under the limit.
|
||||
uploadCh chan uploadJob
|
||||
}
|
||||
|
||||
// uploadJob is one queued auto-upload.
|
||||
type uploadJob struct {
|
||||
svc Service
|
||||
id int64
|
||||
cfg ServiceConfig
|
||||
attempt int // 0 on first try; incremented on each retry
|
||||
}
|
||||
|
||||
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
|
||||
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
|
||||
const (
|
||||
uploadGap = 250 * time.Millisecond
|
||||
maxUploadAttempts = 4
|
||||
)
|
||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||
const maxUploadAttempts = 4
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
m := &Manager{
|
||||
return &Manager{
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
uploadCh: make(chan uploadJob, 4096),
|
||||
}
|
||||
go m.uploadWorker()
|
||||
return m
|
||||
}
|
||||
|
||||
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
|
||||
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
|
||||
// failure is re-queued with an exponential back-off, so a QSO that hit a
|
||||
// momentary rate-limit still ends up marked instead of stuck at "R".
|
||||
func (m *Manager) uploadWorker() {
|
||||
for job := range m.uploadCh {
|
||||
ok, retryable := m.upload(job.svc, job.id, job.cfg)
|
||||
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
|
||||
next := job
|
||||
next.attempt++
|
||||
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
|
||||
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
|
||||
}
|
||||
time.Sleep(uploadGap)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueUpload queues a first-attempt upload without blocking the logging path.
|
||||
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
|
||||
}
|
||||
|
||||
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
|
||||
// enormous burst), it falls back to a goroutine rather than dropping the upload —
|
||||
// a dropped upload would leave the QSO stuck at "R".
|
||||
func (m *Manager) enqueueJob(job uploadJob) {
|
||||
select {
|
||||
case m.uploadCh <- job:
|
||||
default:
|
||||
go m.upload(job.svc, job.id, job.cfg)
|
||||
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||
// independent — never serialised through a shared worker, because a single slow
|
||||
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||
// the operator on the newest build while everyone on the old concurrent path was
|
||||
// fine).
|
||||
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||
go func() {
|
||||
ok, retryable := m.upload(svc, id, cfg)
|
||||
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) logf(format string, args ...any) {
|
||||
@@ -234,17 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// scheduleUpload either queues the upload now (immediate) or arms a timer that
|
||||
// queues it later (delayed). Both go through the serialised worker so uploads are
|
||||
// never fired concurrently in a burst.
|
||||
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||
// one never holds up the rest.
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||
time.AfterFunc(d, func() { m.enqueueUpload(svc, id, cfg) })
|
||||
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||
return
|
||||
}
|
||||
m.enqueueUpload(svc, id, cfg)
|
||||
m.attemptUpload(svc, id, cfg, 0)
|
||||
}
|
||||
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
|
||||
+49
-10
@@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||
// be seconds stale, and writing it all back silently reverts any column another
|
||||
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||
// the key.
|
||||
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||
if id == 0 || strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("missing id or key")
|
||||
}
|
||||
var extrasJSON sql.NullString
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||
return fmt.Errorf("load extras: %w", err)
|
||||
}
|
||||
m := decodeExtras(extrasJSON.String)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
if value == "" {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = value
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`,
|
||||
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||
return fmt.Errorf("set extra %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||
if q.ID == 0 {
|
||||
@@ -1864,25 +1896,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
||||
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
|
||||
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
|
||||
// sees their OWN performance, not the cumulative rate; empty operator matches every
|
||||
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since
|
||||
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large
|
||||
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format
|
||||
// agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
||||
// QSO inside the trailing windows is among the most recently inserted.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
// 2000 rows covers a full hour for one operator even in a busy multi-op run
|
||||
// (other operators' rows are discarded before counting).
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var dateStr sql.NullString
|
||||
if err := rows.Scan(&dateStr); err != nil {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||
continue // a different operator's QSO — not part of my rate
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user