6 Commits
16 changed files with 332 additions and 155 deletions
+69 -6
View File
@@ -3356,6 +3356,8 @@ type QSLBulkUpdate struct {
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged Via string `json:"via"` // QSL_VIA — "" = unchanged
Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged
} }
// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the // BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
@@ -3388,6 +3390,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.Via); v != "" { if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true q.QSLVia, changed = v, true
} }
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
}
if v := strings.TrimSpace(u.Comment); v != "" {
q.Comment, changed = v, true
}
if changed { if changed {
if a.qso.Update(a.ctx, q) == nil { if a.qso.Update(a.ctx, q) == nil {
n++ n++
@@ -4207,13 +4215,33 @@ func (a *App) saveQSORecording(q *qso.QSO) {
return return
} }
applog.Printf("qso-rec: saved %s", path) applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists. // Auto-send the recording once the file exists. Gated ONLY on its own
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" { // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
_ = a.sendRecordingEmail(qc, path) // master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
// Enabled silently skipped sending with no log when only this box was on.)
// Give the operator visible feedback either way.
if es, _ := a.GetEmailSettings(); es.AutoSend {
if strings.TrimSpace(qc.Email) == "" {
applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
} else if err := a.sendRecordingEmail(qc, path); err != nil {
a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
} else {
a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
a.markRecordingSent(qc.ID)
}
} }
}() }()
} }
// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", msg)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.). // sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string { func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ToUpper(strings.TrimSpace(s))
@@ -4687,6 +4715,28 @@ func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
return r.Replace(tmpl) return r.Replace(tmpl)
} }
// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
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)
}
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator. // sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings() s, _ := a.GetEmailSettings()
@@ -4732,7 +4782,11 @@ func (a *App) SendQSORecordingEmail(id int64) error {
if _, e := os.Stat(path); e != nil { if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name) return fmt.Errorf("recording file missing: %s", name)
} }
return a.sendRecordingEmail(q, path) if err := a.sendRecordingEmail(q, path); err != nil {
return err
}
a.markRecordingSent(id)
return nil
} }
// ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
@@ -5626,7 +5680,9 @@ func uploadColumnFor(service string) string {
// FindQSOsForUpload returns QSOs whose sent status for the given service // FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required. // matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { // FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
@@ -5634,7 +5690,7 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, er
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
} }
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
} }
// UploadQSOsManual uploads the given QSO ids to a service on demand // UploadQSOsManual uploads the given QSO ids to a service on demand
@@ -7363,6 +7419,13 @@ func (a *App) FlexSetAudioLevel(l int) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
} }
func (a *App) FlexSetMute(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
}
func (a *App) FlexSetNB(on bool) error { func (a *App) FlexSetNB(on bool) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
+99 -26
View File
@@ -117,7 +117,7 @@ const emptyDetails: DetailsState = {
prop_mode: '', my_rig: '', my_antenna: '', prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined, tx_pwr: undefined,
sat_name: '', sat_mode: '', sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx_string: '', stx_string: '',
email: '', email: '',
award_refs: '', award_refs: '',
}; };
@@ -206,6 +206,24 @@ function rstCategory(mode: string): keyof RSTLists {
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone'; return 'phone';
} }
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's // rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load. // editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] { function rstOptions(mode: string, lists: RSTLists): string[] {
@@ -403,10 +421,12 @@ export default function App() {
// User changed band/mode in the entry strip → push to the rig if CAT is up. // User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state. // Both calls are fire-and-forget; CAT will reflect back via cat:state.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) { function onBandUserChange(v: string) {
setBand(v); setBand(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
const hz = qsyFreqHz(v, mode); const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {}); if (hz > 0) SetCATFrequency(hz).catch(() => {});
} }
@@ -415,7 +435,7 @@ export default function App() {
setMode(v); setMode(v);
applyModePreset(v); applyModePreset(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.mode) {
SetCATMode(v).catch(() => {}); SetCATMode(v).catch(() => {});
} }
} }
@@ -470,11 +490,25 @@ export default function App() {
}; };
// Transient success toast (bottom-right, auto-dismiss). Used for things // Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill. // like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => { const toastQueue = useRef<string[]>([]);
setToast(msg); const toastTimer = useRef<number | undefined>(undefined);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []); }, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately.
useEffect(() => { useEffect(() => {
@@ -1059,6 +1093,12 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the // Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns). // rotator is disabled (the backend just reads settings and returns).
useEffect(() => { useEffect(() => {
@@ -1246,12 +1286,20 @@ export default function App() {
// source behaves identically. // source behaves identically.
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz); const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) { // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
tuneRigCAT(s.freq_hz, m); // then tune the rig if CAT is connected. We must not wait for the CAT status
} else { // echo to update the display: on a remote Flex link that echo lags by seconds,
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
if (s.band) setBand(s.band); // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
} }
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotPOTA((s as any).pota_ref);
@@ -1441,30 +1489,38 @@ export default function App() {
// typing: only update when the field is empty, already shows this call, or // typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not // still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed. // a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => { // force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim(); const call = String(raw ?? '').trim();
if (!call) return false; if (!call) return false;
const upper = call.toUpperCase(); const upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase(); const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current; const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up onCallsignInput(call, { force: true }); // programmatic → always look up
return true; return true;
}; };
const unsubDX = EventsOn('udp:dx_call', (p: any) => { const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target. // External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
}); });
const unsubRC = EventsOn('udp:remote_call', (raw: string) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
}); });
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1561,7 +1617,7 @@ export default function App() {
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
}; };
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his); out = out.replace(/\*/g, myCall).replace(/!/g, his);
@@ -1576,15 +1632,22 @@ export default function App() {
} }
async function wkSend(rawText: string) { async function wkSend(rawText: string) {
setWkSent(''); setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has // <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO // finished sending — so the QSO isn't logged (and the form cleared) while CW
// isn't logged (and the form cleared) while the CW is still going out. // is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return; if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save(); void save();
} }
// stopAutoCall cancels any running auto-call loop. // stopAutoCall cancels any running auto-call loop.
@@ -1599,8 +1662,14 @@ export default function App() {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; if (!m) break;
await wkSend(m.text); await wkSend(m.text);
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start // Wait for the message to finish before the gap+resend. Cap the wait at the
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish // ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
// link the keyer's "busy" status lags badly and stays stuck true for tens of
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
if (gen !== autoCallGenRef.current) break; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -1691,6 +1760,10 @@ export default function App() {
// when you first entered the call (minutes early) and won't match LoTW. // when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart; const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
const payload: any = { const payload: any = {
callsign: callsign.trim().toUpperCase(), callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(), qso_date: start.toISOString(),
@@ -1709,7 +1782,7 @@ export default function App() {
tx_pwr: details.tx_pwr, tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode, sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id, contest_id: details.contest_id,
srx: details.srx, stx: details.stx, srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
@@ -2819,7 +2892,7 @@ export default function App() {
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in"> <div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" /> <Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span> <span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button> <button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
</div> </div>
)} )}
</div> </div>
+7 -4
View File
@@ -35,8 +35,11 @@ export interface DetailsState {
sat_name: string; sat_name: string;
sat_mode: string; sat_mode: string;
contest_id: string; contest_id: string;
srx?: number; // Contest exchanges as free text — most are serials (001) but some are
stx?: number; // alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string; email: string;
// Award references for the contacted station (set via the Awards tab picker). // Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064". // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} /> <Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field> </Field>
<Field label="SRX"> <Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} /> <Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field> </Field>
<Field label="STX"> <Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} /> <Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field> </Field>
<Field label="Contacted email" span={3}> <Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} /> <Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
+17 -10
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -19,7 +19,7 @@ type FlexState = {
proc_enable: boolean; proc_level: number; proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number; mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -34,7 +34,7 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
@@ -363,14 +363,21 @@ export function FlexPanel() {
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} /> onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> <button type="button" disabled={rxOff}
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span> title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} /> <Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
+11 -13
View File
@@ -164,21 +164,19 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a // Draw the lobe as a single FILLED shape that HUGS the great-circle
// filled polygon: a polygon breaks badly near the poles on Mercator // curve: bound it by the two edge radials (az ± half-beamwidth), each a
// (its edges run off toward ±90° and the fill smears across the map), // great-circle line, joined by a short far cap. Filling between the two
// while each radial LINE stays clean. The overlapping lines read as a // curved edges follows the arc instead of making a straight triangle.
// lobe — solid near the antenna, fanning out toward the front. Works const edgeL = radial(az - half);
// for any azimuth, north/south included. const edgeR = radial(az + half);
for (let b = az - half; b <= az + half + 0.001; b += 1.5) { const lobe = [[from.lat, from.lon], ...edgeL, ...edgeR.slice().reverse()] as [number, number][];
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]); L.polygon(unwrapLon(lobe) as L.LatLngExpression[],
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo); { stroke: false, fillColor: '#ff2d2d', fillOpacity: 0.18, smoothFactor: 0 }).addTo(wo);
}
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays // Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray // readable on any basemap. Same dashArray so the casing tracks each dash.
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo); L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 }) L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
+58 -63
View File
@@ -10,6 +10,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -60,6 +61,14 @@ const QSL_STATUSES = [
{ v: 'I', label: 'Ignore' }, { v: 'I', label: 'Ignore' },
]; ];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: '— leave —' },
{ v: 'Bureau', label: 'Bureau' },
{ v: 'Direct', label: 'Direct' },
{ v: 'Electronic', label: 'Electronic' },
];
type LogQSO = { type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState(''); const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]); const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set()); const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false); const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState(''); const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y'); const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_'); const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState(''); const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState(''); const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => { const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase(); const c = paperCall.trim().toUpperCase();
@@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[]; const list = (r ?? []) as LogQSO[];
setPaperRows(list); setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id))); setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
}, [paperCall]); }, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() { async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd, rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate), sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate), rcvd_date: ymd(qslRcvdDate),
via: qslVia, via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any); } as any);
setPaperMsg(`${n} QSO updated.`); setPaperMsg(`${n} QSO updated.`);
await searchPaper(); await searchPaper();
@@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
} }
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly. const [rows, setRows] = useState<any[]>([]);
const gridRef = useRef<AgGridReact<UploadRow>>(null);
const [selectedCount, setSelectedCount] = useState(0); const [selectedCount, setSelectedCount] = useState(0);
const selectAllNext = useRef(false); // selectAll once after the next data load const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
@@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload. // Grid selection → just track the count; ids are read from the grid at upload.
function onUploadSelChanged() { function onUploadRowSelected(ids: number[]) {
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0); setUploadSelIds(ids);
} setSelectedCount(ids.length);
// After "Select required" loads new rows, select them all (the old default).
function onUploadRowsLoaded() {
if (selectAllNext.current) {
selectAllNext.current = false;
gridRef.current?.api?.selectAll();
}
} }
const shownConfs = useMemo(() => confirmations.filter((c) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
@@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as any[];
selectAllNext.current = true; // pre-select everything once the grid renders
setRows(list); setRows(list);
setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length); setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload'); setViewMode('upload');
setShowLog(false); setShowLog(false);
} catch (e: any) { } catch (e: any) {
@@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}, [service, sent]); }, [service, sent]);
async function upload() { async function upload() {
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id); const ids = uploadSelIds;
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); } try { await UploadQSOsManual(service, ids); }
@@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
paperRows.length === 0 ? ( paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div> <div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : ( ) : (
<table className="w-full text-xs border-collapse"> // Same grid as Recent QSOs (full columns + column picker). Selection
<thead className="sticky top-0 bg-card"> // drives which QSOs the apply-form below updates; a search selects all.
<tr className="text-left text-muted-foreground border-b border-border"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th> <RecentQSOsGrid
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th> rows={paperRows as any}
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th> total={paperRows.length}
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th> selectAllSignal={paperSelAllSig}
</tr> onRowSelected={(ids) => setPaperSel(new Set(ids))}
</thead> />
<tbody> </div>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
) )
) : service === 'pota' ? ( ) : service === 'pota' ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div> <div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : ( ) : (
<div className="h-full w-full"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<AgGridReact<UploadRow> <RecentQSOsGrid
ref={gridRef} rows={rows as any}
theme={qslTheme} total={rows.length}
rowData={rows} selectAllSignal={uploadSelAllSig}
columnDefs={UPLOAD_COLS} onRowSelected={onUploadRowSelected}
defaultColDef={{ sortable: true, resizable: true, filter: true }}
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
onSelectionChanged={onUploadSelChanged}
onRowDataUpdated={onUploadRowsLoaded}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/> />
</div> </div>
)} )}
@@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> <Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>} {paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
+11 -5
View File
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]); }, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</thead> </thead>
<tbody> <tbody>
{CONFIRMATIONS.map((c) => ( {CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}> <tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{c.label}</td> <td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td> <td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0"> <TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F> <F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F> <F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F> <F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F> <F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F> <F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F> <F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
+11 -1
View File
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
@@ -157,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ── // ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -223,7 +228,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc', 'Contest', 'Propagation', 'My station', 'Misc',
]; ];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -308,6 +313,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.14'; export const APP_VERSION = '0.14.1';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+3 -1
View File
@@ -130,7 +130,7 @@ export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>; export function FlexATUBypass():Promise<void>;
@@ -174,6 +174,8 @@ export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>; export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>; export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>; export function FlexSetNBLevel(arg1:number):Promise<void>;
+4
View File
@@ -314,6 +314,10 @@ export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1); return window['go']['main']['App']['FlexSetMonLevel'](arg1);
} }
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) { export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1); return window['go']['main']['App']['FlexSetNB'](arg1);
} }
+6 -24
View File
@@ -515,6 +515,7 @@ export namespace cat {
agc_mode?: string; agc_mode?: string;
agc_threshold: number; agc_threshold: number;
audio_level: number; audio_level: number;
mute: boolean;
nb: boolean; nb: boolean;
nb_level: number; nb_level: number;
nr: boolean; nr: boolean;
@@ -563,6 +564,7 @@ export namespace cat {
this.agc_mode = source["agc_mode"]; this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"]; this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"]; this.audio_level = source["audio_level"];
this.mute = source["mute"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nb_level = source["nb_level"]; this.nb_level = source["nb_level"];
this.nr = source["nr"]; this.nr = source["nr"];
@@ -1562,6 +1564,8 @@ export namespace main {
sent_date: string; sent_date: string;
rcvd_date: string; rcvd_date: string;
via: string; via: string;
notes: string;
comment: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLBulkUpdate(source); return new QSLBulkUpdate(source);
@@ -1574,6 +1578,8 @@ export namespace main {
this.sent_date = source["sent_date"]; this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"]; this.rcvd_date = source["rcvd_date"];
this.via = source["via"]; this.via = source["via"];
this.notes = source["notes"];
this.comment = source["comment"];
} }
} }
export class QSLDefaults { export class QSLDefaults {
@@ -2895,30 +2901,6 @@ export namespace qso {
return a; return a;
} }
} }
export class UploadRow {
id: number;
qso_date: string;
callsign: string;
band: string;
mode: string;
country: string;
status: string;
static createFrom(source: any = {}) {
return new UploadRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = source["qso_date"];
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
+2
View File
@@ -251,6 +251,7 @@ type FlexTXState struct {
AGCMode string `json:"agc_mode,omitempty"` AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"` AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"` AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
NB bool `json:"nb"` NB bool `json:"nb"`
NBLevel int `json:"nb_level"` NBLevel int `json:"nb_level"`
NR bool `json:"nr"` NR bool `json:"nr"`
@@ -312,6 +313,7 @@ type FlexController interface {
SetAGCMode(string) error SetAGCMode(string) error
SetAGCThreshold(int) error SetAGCThreshold(int) error
SetAudioLevel(int) error SetAudioLevel(int) error
SetMute(bool) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
SetNR(bool) error SetNR(bool) error
+7
View File
@@ -73,6 +73,7 @@ type flexSlice struct {
agcMode string // off | slow | med | fast agcMode string // off | slow | med | fast
agcThreshold int // 0-100 agcThreshold int // 0-100
audioLevel int // 0-100 (RX volume) audioLevel int // 0-100 (RX volume)
mute bool // RX audio muted
nb bool // noise blanker nb bool // noise blanker
nbLevel int nbLevel int
nr bool // noise reduction nr bool // noise reduction
@@ -670,6 +671,8 @@ func (f *Flex) handleStatus(payload string) {
s.agcThreshold = atoiDefault(val, s.agcThreshold) s.agcThreshold = atoiDefault(val, s.agcThreshold)
case "audio_level": case "audio_level":
s.audioLevel = atoiDefault(val, s.audioLevel) s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute":
s.mute = val == "1"
case "nb": case "nb":
s.nb = val == "1" s.nb = val == "1"
case "nb_level": case "nb_level":
@@ -1029,6 +1032,7 @@ func (f *Flex) FlexState() FlexTXState {
st.AGCMode = rx.agcMode st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel st.AudioLevel = rx.audioLevel
st.Mute = rx.mute
st.NB = rx.nb st.NB = rx.nb
st.NBLevel = rx.nbLevel st.NBLevel = rx.nbLevel
st.NR = rx.nr st.NR = rx.nr
@@ -1076,6 +1080,8 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.agcThreshold = toInt(val) rx.agcThreshold = toInt(val)
case "audio_level": case "audio_level":
rx.audioLevel = toInt(val) rx.audioLevel = toInt(val)
case "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
case "nb": case "nb":
rx.nb = val == "1" rx.nb = val == "1"
case "nb_level": case "nb_level":
@@ -1126,6 +1132,7 @@ func (f *Flex) SetAGCMode(m string) error {
} }
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
+25
View File
@@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{
// ListForUpload returns QSOs whose per-service sent-status column equals // ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required". // value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] { if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column) return nil, fmt.Errorf("invalid upload column %q", column)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.14" appVersion = "0.14.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.