Compare commits

...
15 Commits
Author SHA1 Message Date
rouggy 01235624ee chore: release v0.11.1 2026-06-16 21:09:00 +02:00
rouggy a7bbc53c35 feat: Mainview can choose Map, cluster or worked before 2026-06-16 21:04:19 +02:00
rouggy 3d15f20c7f fix: proper beam heading on the map 2026-06-16 20:24:49 +02:00
rouggy e5c6bddb29 fix: /AM not recognizing the country now showing 2026-06-16 20:05:03 +02:00
rouggy 75ee5344a4 chore: release v0.11 2026-06-16 20:00:15 +02:00
rouggy 957182611d feat: check for available updates 2026-06-16 19:52:23 +02:00
rouggy 69d0780bac feat: added versionning & About window 2026-06-16 19:36:56 +02:00
rouggy 33af122964 awards 2026-06-16 09:50:48 +02:00
rouggy 22e3bb4a18 up 2026-06-15 23:45:14 +02:00
rouggy 29fd832bcd up 2026-06-14 01:35:40 +02:00
rouggy 67203cd4a8 up 2026-06-14 00:55:27 +02:00
rouggy 08162fa126 mysql 2026-06-13 21:56:38 +02:00
rouggy 81e505e040 up 2026-06-13 19:14:24 +02:00
rouggy 0b3e22c97e fix 2026-06-13 17:25:48 +02:00
rouggy d3ba7c71f4 fix issues 2026-06-13 16:55:57 +02:00
64 changed files with 6009 additions and 913 deletions
+10 -1
View File
@@ -13,7 +13,16 @@
"Bash(git config *)", "Bash(git config *)",
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)", "Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)", "Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
"Bash(which git-credential-manager *)" "Bash(which git-credential-manager *)",
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
"Bash(gofmt -w internal/qslcard/*.go)",
"Bash(awk '{print $3}')",
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
"Bash(ls -la .claude/)",
"Bash(cat .claude/settings.local.json)",
"Bash(cat .claude/settings.json)"
] ]
} }
} }
+782 -130
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -34,6 +34,10 @@ const (
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
) )
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
const ( const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}" defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
@@ -74,7 +78,7 @@ type QSLPresetInfo struct {
// so picking through the native dialog is the reliable route to real paths). // so picking through the native dialog is the reliable route to real paths).
func (a *App) QSLPickPhotos() ([]string, error) { func (a *App) QSLPickPhotos() ([]string, error) {
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{ return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose card photos (13)", Title: "Choose card photos (15)",
Filters: []wruntime.FileFilter{ Filters: []wruntime.FileFilter{
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"}, {DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
}, },
@@ -87,8 +91,8 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
if len(photoPaths) == 0 { if len(photoPaths) == 0 {
return nil, fmt.Errorf("no photos selected") return nil, fmt.Errorf("no photos selected")
} }
if len(photoPaths) > 3 { if len(photoPaths) > 5 {
return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths)) return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
} }
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths)) photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
for _, p := range photoPaths { for _, p := range photoPaths {
@@ -425,17 +429,14 @@ 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) applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err return err
} }
// Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of // Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which // NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// an eQSL.cc upload may also set. q came straight from GetByID, so a full // independent. q came straight from GetByID, so a full Update rewrites the
// Update rewrites the row unchanged apart from these fields. // row unchanged apart from this field.
now := time.Now().UTC()
q.EQSLSent = "Y"
q.EQSLSentDate = now.Format("20060102")
if q.Extras == nil { if q.Extras == nil {
q.Extras = map[string]string{} q.Extras = map[string]string{}
} }
q.Extras["APP_OPSLOG_QSL_SENT"] = now.Format(time.RFC3339) q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil { if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err) 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) return fmt.Errorf("eQSL sent but status not saved: %w", err)
@@ -502,7 +503,7 @@ func (a *App) maybeAutoSendEQSL(q qso.QSO) {
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" { if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
return return
} }
if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") { if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
return return
} }
p, err := a.profiles.Active(a.ctx) p, err := a.profiles.Active(a.ctx)
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
// launches on startup (JSON array of AutostartProgram).
const keyAutostartPrograms = "autostart.programs"
// AutostartProgram is one external application OpsLog can launch when it starts
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
type AutostartProgram struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Args string `json:"args"`
Enabled bool `json:"enabled"`
}
// AutostartLaunchResult reports what happened for one program when launching.
type AutostartLaunchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // launched | already_running | missing | disabled | error
Message string `json:"message"`
}
// GetAutostartPrograms returns the active profile's autostart list.
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
out := []AutostartProgram{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []AutostartProgram{}, nil
}
return out, nil
}
// SaveAutostartPrograms persists the autostart list for the active profile.
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(progs)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
}
// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose a program to launch on startup",
Filters: []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// LaunchAutostartPrograms starts every enabled program that isn't already
// running, returning a per-program result. Used at startup (best effort) and by
// the "Launch now" button in settings.
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
progs, _ := a.GetAutostartPrograms()
running := runningProcessNames()
out := make([]AutostartLaunchResult, 0, len(progs))
for _, p := range progs {
if !p.Enabled {
continue
}
out = append(out, launchProgram(p, running))
}
return out
}
// LaunchAutostartProgram launches a single program by id on demand (the per-row
// "launch now" action), regardless of its enabled flag.
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
progs, err := a.GetAutostartPrograms()
if err != nil {
return AutostartLaunchResult{}, err
}
for _, p := range progs {
if p.ID == id {
return launchProgram(p, runningProcessNames()), nil
}
}
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
}
// launchProgram starts one program unless its executable is already running.
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
path := strings.TrimSpace(p.Path)
if path == "" {
res.Status, res.Message = "error", "no path configured"
return res
}
if _, err := os.Stat(path); err != nil {
res.Status, res.Message = "missing", "executable not found: "+path
return res
}
// Skip if a process with the same executable name is already running, so we
// never spawn a second copy of WSJT-X / JTAlert / etc.
base := strings.ToLower(filepath.Base(path))
if running[base] {
res.Status, res.Message = "already_running", base+" already running"
return res
}
cmd := exec.Command(path, splitArgs(p.Args)...)
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
if err := cmd.Start(); err != nil {
res.Status, res.Message = "error", err.Error()
return res
}
// Don't wait on the child — it runs independently of OpsLog. Release the
// handle so we don't accumulate zombies.
go func() { _ = cmd.Wait() }()
res.Status, res.Message = "launched", "started "+base
return res
}
// splitArgs does a minimal shell-like split of an argument string, honouring
// double quotes so a path with spaces stays one argument.
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var args []string
var cur strings.Builder
inQuote := false
for _, r := range s {
switch {
case r == '"':
inQuote = !inQuote
case r == ' ' && !inQuote:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
+590 -264
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -31,11 +31,17 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string; url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string; type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[]; leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean; total: number; builtin?: boolean;
}; };
type AwardOrRule = {
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
leading_str?: string; trailing_str?: string; prefix?: string;
};
type AwardRef = { type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string; code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string; dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (!open) return; if (!open) return;
setErr(''); setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()]) Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); }) .then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e))); .catch((e) => setErr(String(e?.message ?? e)));
loadMeta(); loadMeta();
}, [open]); }, [open]);
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2> <Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2> <Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div> </div>
{/* Additional OR searches: a QSO earns a reference if the
primary rule OR any of these match. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> also match the reference if any of these hit</p>
<Button size="sm" variant="outline" className="h-7"
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
<Plus className="size-3.5" /> Add OR
</Button>
</div>
{(cur.or_rules ?? []).map((r, ri) => {
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
return (
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">OR search in</span>
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-2 text-[11px]">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1 cursor-pointer">
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
</div>
<div className="grid grid-cols-[1fr_120px] gap-2">
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
</div>
</div>
);
})}
</div>
</div> </div>
</TabsContent> </TabsContent>
+13 -6
View File
@@ -29,9 +29,13 @@ interface Props {
// of these and it's already filled (e.g. VE9CF → state NB), the award counts // of these and it's already filled (e.g. VE9CF → state NB), the award counts
// automatically — we surface that so the operator needn't pick it by hand. // automatically — we surface that so the operator needn't pick it by hand.
fieldValues?: Record<string, string>; fieldValues?: Record<string, string>;
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
// the live entry panel passes "flex-1 min-h-0" so it fills the available
// space instead of overflowing and forcing a scrollbar.
heightClass?: string;
} }
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) { export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]); const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({}); const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA'); const [awardCode, setAwardCode] = useState('POTA');
@@ -63,13 +67,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
// for a French call but not for others. // for a French call but not for others.
const awards = useMemo(() => { const awards = useMemo(() => {
return defs.filter((d) => { return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically. // Computed awards (field = dxcc/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false; if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? []; const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false; if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
// Offer the award even when its reference list isn't loaded yet: a custom
// award (e.g. "Worked All Provinces of China") has no built-in list, so
// requiring one would make it impossible to assign references by hand. The
// operator can pick from a loaded list when present, or add an unlisted
// reference (the right-hand panel). Dynamic / list-backed awards behave as
// before — they just always pass here now.
return true; return true;
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() })) }).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
.sort((a, b) => a.code.localeCompare(b.code)); .sort((a, b) => a.code.localeCompare(b.code));
@@ -169,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
} }
return ( return (
<div className="flex gap-2 h-[210px]"> <div className={`flex gap-2 ${heightClass}`}>
{/* Left panel */} {/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0"> <div className="flex flex-col gap-1.5 flex-1 min-w-0">
+206 -57
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App'; import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor'; import { AwardEditor } from '@/components/AwardEditor';
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
); );
} }
type AwardListItem = { code: string; name: string; valid?: boolean }; type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [awardList, setAwardList] = useState<AwardListItem[]>([]); const [awardList, setAwardList] = useState<AwardListItem[]>([]);
@@ -108,7 +109,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
try { try {
const defs = ((await GetAwardDefs()) ?? []) as any[]; const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid })) .map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code)); .sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list); setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0]; const first = list.find((a) => a.code === selected) ?? list[0];
@@ -121,6 +122,53 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const current = byCode[selected]; const current = byCode[selected];
// Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
// the bands the operator actually has contacts on (so DXCC, which has no band
// restriction, shows 160m6m instead of empty VHF/UHF columns). Empty set =
// no basis yet → callers fall back to all bands.
const awardBands = useMemo(() => {
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
if (vb.length > 0) return new Set(vb);
const worked = (current?.bands ?? [])
.filter((b) => (b.worked ?? 0) > 0)
.map((b) => String(b.band).toLowerCase());
return new Set(worked);
}, [awardList, selected, current]);
const gridBands = useMemo(() => {
if (awardBands.size === 0) return GRID_BANDS;
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
return filtered.length ? filtered : GRID_BANDS;
}, [awardBands]);
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
// shows only when the award has no emission restriction or lists that emission.
const statsRows = useMemo(() => {
if (!stats) return [];
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
if (em.size === 0) return stats.rows;
return stats.rows.filter((r) => {
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
return !m || em.has(m[1]);
});
}, [stats, awardList, selected]);
// Indices into stats.bands to display (and to slice each row's cells by), same
// band basis as the grid.
const statsBandIdx = useMemo(() => {
if (!stats) return [] as number[];
const all = stats.bands.map((_, i) => i);
if (awardBands.size === 0) return all;
const idx = stats.bands
.map((b, i) => ({ b: b.toLowerCase(), i }))
.filter((x) => awardBands.has(x.b))
.map((x) => x.i);
return idx.length ? idx : all;
}, [stats, awardBands]);
const filteredRefs = useMemo(() => { const filteredRefs = useMemo(() => {
if (!current) return []; if (!current) return [];
const q = refSearch.trim().toUpperCase(); const q = refSearch.trim().toUpperCase();
@@ -228,10 +276,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{/* Band breakdown */} {/* Band breakdown */}
{(current.bands ?? []).length > 0 && ( {(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60"> <div className="px-4 py-2 border-b border-border/60">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div> <div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => ( {(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs"> <div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '} <span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span> <span className="text-emerald-600 font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span> <span className="text-muted-foreground font-mono">/{b.worked}</span>
@@ -245,9 +293,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex items-center gap-2 px-4 py-2 flex-wrap"> <div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative"> <div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} /> <Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs"> <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => ( {([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)} <button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}> className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
@@ -255,10 +303,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</button> </button>
))} ))}
</div> </div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<button <button
onClick={() => setShowMissing(true)} onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1" className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it" title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
> >
<AlertTriangle className="size-3" /> Missing refs <AlertTriangle className="size-3" /> Missing refs
@@ -283,27 +331,27 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{statsLoading || !stats ? ( {statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div> <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
) : ( ) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}> <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10"> <thead className="sticky top-0 z-10">
<tr className="bg-card"> <tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th> <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)} {statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th> <th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th> <th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stats.rows.map((row, i) => { {statsRows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label); const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return ( return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}> <tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap', <td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td> isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => ( {statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td> <td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))} ); })}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr> </tr>
); );
})} })}
@@ -313,30 +361,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div> </div>
) : view === 'grid' ? ( ) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3"> <div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}> <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10"> <thead className="sticky top-0 z-10">
<tr className="bg-card"> <tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th> <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th> <th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => ( {gridBands.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th> <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRefs.map((r) => ( {filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}> <tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td> <td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30"> <td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''} {r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td> </td>
{GRID_BANDS.map((b) => { {gridBands.map((b) => {
const s = cellStatus(r, b); const s = cellStatus(r, b);
return ( return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center"> <td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : ( {s === 'none' ? <span className="block w-11 h-7" /> : (
<button <button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')} className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`} title={`${r.ref} · ${b} — click to view QSOs`}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })} onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button> >{CELL_LABEL[s]}</button>
@@ -351,22 +399,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto px-4 pb-3"> <div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs"> <table className="w-full text-sm">
<thead className="sticky top-0 bg-card"> <thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border"> <tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th> <th className="py-1.5 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th> <th className="py-1.5 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th> <th className="py-1.5 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th> <th className="py-1.5 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th> <th className="py-1.5 font-medium">Bands</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRefs.map((r) => ( {filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}> <tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td> <td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2"> <td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span> {!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span> : r.validated ? <span className="text-emerald-600">validated</span>
@@ -401,23 +449,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
// MissingQSOModal lists contacts within an award's scope that carry NO // MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add // reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM). // the missing reference (e.g. a department for DDFM).
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) { function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const [qsos, setQsos] = useState<any[]>([]); const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
const [assignRef, setAssignRef] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setSel(new Set());
AwardMissingQSOs(code) AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any)) .then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([])) .catch(() => setQsos([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(() => { load(); }, [code]); useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs). // The award's reference list drives the "assign" dropdown (e.g. China provinces).
useEffect(() => {
ListAwardReferences(code)
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
.catch(() => setRefs([]));
}, [code]);
const qthOf = (q: any) => String(q.qth || q.notes || '');
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
const val = (q: any): string => {
switch (sortKey) {
case 'qso_date': return String(q.qso_date ?? '');
case 'callsign': return String(q.callsign ?? '').toUpperCase();
case 'band': return String(q.band ?? '');
case 'mode': return String(q.mode ?? '');
case 'country': return String(q.country ?? '').toUpperCase();
case 'qth': return qthOf(q).toUpperCase();
}
};
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
}, [qsos, sortKey, sortDir]);
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const setSort = (k: MissingSortKey) => {
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(k); setSortDir('asc'); }
};
async function applyAssign() {
if (!assignRef || sel.size === 0) return;
setBusy(true); setMsg('');
try {
const assignedIds = Array.from(sel);
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
// Optimistic: the assigned contacts now carry a reference, so drop them
// from the list locally instead of re-running the slow whole-log scan.
const done = new Set(assignedIds);
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
setSel(new Set());
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]); const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
<span className="inline-flex items-center gap-0.5">{label}
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</span>
</th>
);
return ( return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}> <div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}> <div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b"> <div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" /> <AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span> <span className="font-semibold text-sm">{code} contacts missing a reference</span>
@@ -432,8 +548,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div> </div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50"> <div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'} Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
</div> </div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{sel.size} selected </span>
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
<SelectContent className="max-h-72">
{refs.map((r) => (
<SelectItem key={r.code} value={r.code}>
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
</Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
</div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? ( {loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div> <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
@@ -443,22 +579,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div> </div>
) : ( ) : (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border"> <thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr> <tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
<SortTh k="callsign" label="Callsign" />
<SortTh k="band" label="Band" />
<SortTh k="mode" label="Mode" />
<SortTh k="country" label="Country" />
<SortTh k="qth" label="QTH / Note" />
</tr>
</thead> </thead>
<tbody> <tbody>
{qsos.map((q, i) => ( {sorted.map((q, i) => {
<tr key={q.id ?? i} const id = q.id as number;
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')} return (
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}> <tr key={id ?? i}
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td> className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td> <td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
<td className="py-1 pr-2">{q.band}</td> <Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
<td className="py-1 pr-2">{q.mode}</td> </td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td> <td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td> <td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
</tr> <td className="py-1 pr-2">{q.band}</td>
))} <td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
)} )}
+34 -2
View File
@@ -12,6 +12,7 @@ interface Props {
currentBand: string; currentBand: string;
currentMode: string; currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
} }
// Compact column label for a band tag: keep the classic V/U for 2m/70cm, // Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -78,7 +79,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
// Columns from the operator's configured bands (so the matrix shows only the // Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set. // bands they actually use), falling back to the built-in default set.
const cols = useMemo( const cols = useMemo(
@@ -100,6 +101,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
return m; return m;
}, [wb]); }, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
// lists every real (band, mode) the entity was worked on.
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim();
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
// New Band = the band is new (the mode was worked on another band).
// New Mode = the mode is new (the band was worked in another mode).
// New Slot = both band and mode already worked — just not together.
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
const newBandMode = slotNew && !bandWorked && !modeWorked;
const newBand = slotNew && !bandWorked && modeWorked;
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
return ( return (
<section <section
className={cn( className={cn(
@@ -135,6 +159,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</> </>
)} )}
</span> </span>
{(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div>
)}
</> </>
) : busy ? ( ) : busy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground italic"> <span className="flex items-center gap-2 text-xs text-muted-foreground italic">
@@ -181,7 +213,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</th> </th>
{cols.map((b) => { {cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = b.tag === currentBand && classCurrent; const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return ( return (
<td <td
key={b.tag} key={b.tag}
+45
View File
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
]; ];
} }
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
}
}
const COL_CATALOG: ColEntry[] = [ const COL_CATALOG: ColEntry[] = [
{ {
group: 'Spot', label: 'Time', colId: 'time', group: 'Spot', label: 'Time', colId: 'time',
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined; return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
}, },
}, },
{
group: 'Spot', label: 'Status', colId: 'status',
headerName: 'Status', width: 96, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell.
valueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : '';
},
cellRenderer: (p: any) => {
const b = statusBadge(statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
return (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
if (s?.status === 'new-band') return 'NEW BAND for this entity';
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
if (s?.worked_call) return 'Already worked this call';
return undefined;
},
},
{ {
group: 'Spot', label: 'POTA', colId: 'pota', group: 'Spot', label: 'POTA', colId: 'pota',
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono', headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
+63 -7
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -118,9 +119,43 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
); );
} }
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) { export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
try {
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
setDetected(all as any);
const cur = details.award_refs ?? '';
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
if (next !== cur) onChange({ award_refs: next });
} catch { /* leave detection empty on failure */ }
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
// Bearing/distance from operator's home grid to the remote station. // Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes. // Recomputed only when either grid actually changes.
const path = useMemo(() => { const path = useMemo(() => {
@@ -179,10 +214,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
))} ))}
</nav> </nav>
<div className="overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{open === 'stats' && ( {open === 'stats' && (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} /> <BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
</div> </div>
)} )}
@@ -205,8 +240,18 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—" <Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field> </Field>
{/* DXCC #, Continent and Azimuth SP live in the main entry strip / {/* DXCC # closes the top row (next to the zones); Continent and
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */} Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label="DXCC #">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={details.dxcc ?? ''}
placeholder="—"
/>
</Field>
<Field label="Azimuth LP"> <Field label="Azimuth LP">
<Input <Input
readOnly readOnly
@@ -247,13 +292,24 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
)} )}
{open === 'awards' && ( {open === 'awards' && (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5 h-full flex flex-col min-h-0">
<AwardRefSelector <AwardRefSelector
dxcc={details.dxcc} dxcc={details.dxcc}
value={details.award_refs ?? ''} value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })} onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }} fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/> />
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
<span className="font-medium text-foreground/70">Detected this contact will count for:</span>{' '}
{detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
</span>
))}
</div>
)}
</div> </div>
)} )}
+1 -1
View File
@@ -24,7 +24,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<Mic className="size-3.5 text-primary" /> <Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span> <span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} /> <span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting</span>} {status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}> <Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> Stop <Square className="size-3" /> Stop
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// FirstRunModal collects the mandatory station identity on the very first launch
// (no callsign configured yet). It writes straight into the active profile, so
// OpsLog has a valid station before any QSO is logged. Not dismissable.
export function FirstRunModal({ onDone }: { onDone: () => void }) {
const [p, setP] = useState<Profile | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
const [refsMsg, setRefsMsg] = useState('');
async function downloadRefs() {
setRefsState('loading');
try {
const summary = await DownloadAllReferenceLists();
setRefsMsg(summary);
setRefsState('done');
} catch (e: any) {
setRefsMsg(String(e?.message ?? e));
setRefsState('idle');
}
}
useEffect(() => {
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
}, []);
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
const callsign = (p?.callsign ?? '').trim().toUpperCase();
const grid = (p?.my_grid ?? '').trim().toUpperCase();
const operator = (p?.operator ?? '').trim().toUpperCase();
const canSave = callsign.length >= 3 && grid.length >= 4;
async function save() {
if (!p || !canSave) return;
setSaving(true);
setErr('');
try {
await SaveProfile({
...p,
callsign,
my_grid: grid,
operator: operator || callsign,
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
op_name: (p.op_name ?? '').trim(),
} as any);
onDone();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
<div className="flex items-center gap-2 mb-1">
<Radio className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences Station Information (and per profile).
</p>
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
<Label className="text-sm">Operator</Label>
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
<Label className="text-sm">Owner</Label>
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
<Label className="text-sm">Name</Label>
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
</div>
{/* Optional: grab the award reference lists now (also in Tools later). */}
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">Award reference lists</div>
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA names &amp; totals for those awards (optional, can take a minute).</div>
</div>
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
</Button>
</div>
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
</div>
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
<div className="mt-5 flex items-center justify-end gap-2">
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
</div>
</div>
</div>
);
}
+138 -135
View File
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() })); writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
} }
// MainMap — Log4OM-style dual map for the Main tab: // The Main tab is built from two independent map panes that the operator can
// • Left: a world map with the great-circle path drawn from the operator to // place on either side (Settings → Main view):
// the contacted station, plus distance + short/long-path azimuth. // • WorldMap ("map1"): a world map with the great-circle path from the
// • Right: a street map zoomed onto the contacted station's grid locator. // operator to the contacted station, distance, short/long-path azimuth and
// the antenna beam lobe.
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use // Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
// circleMarkers / divIcons so we don't depend on Leaflet's image assets. // circleMarkers / divIcons so we don't depend on Leaflet's image assets.
interface Props {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// unwrapLon makes a lat/lon ring continuous in longitude (each point within // unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap // 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine. // across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
@@ -62,14 +55,20 @@ function dot(color: string): L.DivIcon {
}); });
} }
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) { interface WorldProps {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null); const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null); const worldMap = useRef<L.Map | null>(null);
const locatorMap = useRef<L.Map | null>(null);
// Layers we add/remove as the QSO changes (kept separate from the basemap).
const worldOverlay = useRef<L.LayerGroup | null>(null); const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator // Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
// pans/zooms freely (e.g. a whole-world view) and the view is remembered // pans/zooms freely (e.g. a whole-world view) and the view is remembered
@@ -86,92 +85,70 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m); L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
// Restore the saved free-pan view when not auto-zooming.
const sv = loadMapView(); const sv = loadMapView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom); if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
// Remember the view as the user pans/zooms (only meaningful when free).
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
} }
if (locatorRef.current && !locatorMap.current) { const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
// The Main tab may have just become visible — fix tile sizing.
const t = window.setTimeout(() => {
worldMap.current?.invalidateSize();
locatorMap.current?.invalidateSize();
}, 80);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, []); }, []);
// Redraw overlays whenever the operator/DX grids change. // Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => { useEffect(() => {
const wm = worldMap.current, lm = locatorMap.current; const wm = worldMap.current, wo = worldOverlay.current;
const wo = worldOverlay.current, lo = locatorOverlay.current; if (!wm || !wo) return;
if (!wm || !lm || !wo || !lo) return;
wo.clearLayers(); wo.clearLayers();
lo.clearLayers();
const from = gridToLatLon(fromGrid); const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid); const to = gridToLatLon(toGrid);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 5500; // lobe length (km) — short enough to rarely reach a pole
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 };
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
const d = destinationPoint(from.lat, from.lon, b, D);
arc.push([d.lat, d.lon]);
}
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
// A geodesic lobe that reaches near a pole can't be filled on a
// Mercator map without the polygon snapping across the whole world —
// draw just the two edges in that case; otherwise the translucent lobe.
if (ring.some(([la]) => Math.abs(la) > 82)) {
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo);
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo);
} else {
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
}
// Boresight (dashed centre line) — always; great-circle polyline is safe.
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
}
if (from && to) { if (from && to) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96); const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo); L.polyline(unwrapLon(pts) as L.LatLngExpression[],
// Only re-frame the map when auto-zoom is on; otherwise keep the user's { color: '#2563eb', weight: 2, opacity: 0.8 }).addTo(wo);
// chosen (remembered) view so the beam heading stays visible.
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 5500; // lobe length (km)
// Great-circle radial out to distance D, stopping just short of the pole
// so a poleward line doesn't snap across the top of the Mercator map.
const radial = (b: number): [number, number][] => {
const out: [number, number][] = [];
const N = 64;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
out.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
}
return out;
};
for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a
// filled polygon: a polygon breaks badly near the poles on Mercator
// (its edges run off toward ±90° and the fill smears across the map),
// while each radial LINE stays clean. The overlapping lines read as a
// lobe — solid near the antenna, fanning out toward the front. Works
// for any azimuth, north/south included.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#dc2626', weight: 6, opacity: 0.07 }).addTo(wo);
}
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
if (autoZoom) { if (autoZoom) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} }
} else if (autoZoom && to) { } else if (autoZoom && to) {
@@ -179,8 +156,73 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
} else if (autoZoom && from) { } else if (autoZoom && from) {
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
// ── Right: street map on the DX locator ── const path = pathBetween(fromGrid, toGrid);
return (
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */}
<button
type="button"
onClick={() => {
const v = !autoZoom;
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m);
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Zoom DX
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
)}
</div>
);
}
interface LocatorProps {
toGrid: string; // contacted-station grid
toLabel?: string; // DX callsign
}
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
const locatorRef = useRef<HTMLDivElement>(null);
const locatorMap = useRef<L.Map | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
useEffect(() => {
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
const lm = locatorMap.current, lo = locatorOverlay.current;
if (!lm || !lo) return;
lo.clearLayers();
const to = gridToLatLon(toGrid);
if (to) { if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' }) L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
@@ -190,57 +232,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo); L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
} }
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7); lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
} else if (from) {
lm.setView([from.lat, from.lon], 5);
} }
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0); setTimeout(() => { lm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [toGrid, toLabel]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
const path = pathBetween(fromGrid, toGrid);
return ( return (
<div className="flex flex-col h-full min-h-0 gap-2 p-2"> <div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0"> <div ref={locatorRef} className="absolute inset-0" />
<div className="relative isolate rounded-lg overflow-hidden border border-border"> {!gridToLatLon(toGrid) && (
<div ref={worldRef} className="absolute inset-0" /> <div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom Enter a grid or look up the callsign to center the map.
(remembered across restarts), so the beam heading stays visible. */}
<button
type="button"
onClick={() => {
const v = !autoZoom;
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m); // entering free mode → remember current view
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Zoom DX
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
)}
</div> </div>
<div className="relative isolate rounded-lg overflow-hidden border border-border"> )}
<div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
Enter a grid or look up the callsign to center the map.
</div>
)}
</div>
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -90,7 +90,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
onClick={() => { onSendEQSL(menu.ids); onClose(); }} onClick={() => { onSendEQSL(menu.ids); onClose(); }}
> >
<Mail className="size-4 text-amber-600" /> <Mail className="size-4 text-amber-600" />
<span>Send eQSL by e-mail</span> <span>Send OpsLog QSL by e-mail</span>
</button> </button>
)} )}
{onSendRecording && ( {onSendRecording && (
+15 -3
View File
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
if (!d) return ''; if (!d) return '';
const date = new Date(d); const date = new Date(d);
if (isNaN(date.getTime())) return ''; if (isNaN(date.getTime())) return '';
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
// doesn't apply to a time struct), so a QSO with no end time arrives as a
// year-1 date. Treat anything that old as unset — otherwise the datetime
// field shows a garbage value and fights the user's typing.
if (date.getUTCFullYear() <= 1) return '';
const p = (n: number) => String(n).padStart(2, '0'); const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`; return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
} }
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz); const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz); const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date)); const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off)); const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off); const [dateOff, setDateOff] = useState(dateOffISO);
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [localErr, setLocalErr] = useState(''); const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div> <div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div> <div>
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC) <Checkbox checked={endEnabled} onCheckedChange={(c) => {
const on = !!c;
setEndEnabled(on);
// Prefill an empty end with the start time so the user
// only tweaks the minutes instead of typing a full date.
if (on && !dateOff) setDateOff(dateOn);
}} /> QSO End (UTC)
</Label> </Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} /> <Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div> </div>
+4 -2
View File
@@ -101,8 +101,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' }, { group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' }, { group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true }, { group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) }, { group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true }, { group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
@@ -150,6 +150,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 }, { group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_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) }, { 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.
{ 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 },
// ── 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,
+434 -36
View File
@@ -3,12 +3,12 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings, GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
@@ -25,12 +25,16 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir, GetDataDir,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -124,6 +128,7 @@ const emptyProfile = (): Profile => ({
tx_pwr: undefined, tx_pwr: undefined,
is_active: false, is_active: false,
sort_order: 0, sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any, created_at: '' as any,
updated_at: '' as any, updated_at: '' as any,
}); });
@@ -132,6 +137,7 @@ interface Props {
initialSection?: string; initialSection?: string;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
} }
// Pretty little card showing what OpsLog will stamp on each QSO based on // Pretty little card showing what OpsLog will stamp on each QSO based on
@@ -156,6 +162,7 @@ type SectionId =
| 'cluster' | 'cluster'
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart'
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
@@ -189,16 +196,16 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations', id: 'udp' }, { kind: 'item', label: 'UDP integrations', id: 'udp' },
{ kind: 'item', label: 'Database', id: 'database' }, { kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true }, { kind: 'item', label: 'Autostart', id: 'autostart' },
], ],
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Rotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' }, { kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' }, { kind: 'item', label: 'Antenna', id: 'antenna' },
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' }, { kind: 'item', label: 'Audio devices', id: 'audio' },
], ],
}, },
]; ];
@@ -216,11 +223,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cluster: 'DX Cluster', cluster: 'DX Cluster',
backup: 'Database backup', backup: 'Database backup',
database: 'Database', database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'Rotator', rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)', winkeyer: 'CW Keyer',
antenna: 'Antenna', antenna: 'Antenna',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -316,6 +324,220 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
); );
} }
// AutostartPanelComponent manages the per-profile list of external programs to
// launch when OpsLog starts. It's a self-contained component (its own state) so
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
function AutostartPanelComponent() {
const [progs, setProgs] = useState<AutostartProg[]>([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
async function load() {
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setLoaded(true); }
}
useEffect(() => { load(); }, []);
useEffect(() => {
const off = EventsOn('profile:changed', () => load());
return () => { if (typeof off === 'function') off(); };
}, []);
async function commit(next: AutostartProg[]) {
setProgs(next);
try { await SaveAutostartPrograms(next as any); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const patch = (id: string, p: Partial<AutostartProg>) =>
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
async function addProgram() {
try {
const path = await BrowseExecutable();
if (!path) return;
const base = path.split(/[\\/]/).pop() || path;
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
commit([...progs, { id, name, path, args: '', enabled: true }]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function rebrowse(id: string) {
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function launchNow(id: string) {
try {
const r: any = await LaunchAutostartProgram(id);
const txt = r?.status === 'launched' ? '✓ launched'
: r?.status === 'already_running' ? 'already running — not started again'
: r?.status === 'missing' ? '✗ executable not found'
: (r?.message || r?.status || 'done');
setLaunchMsg((m) => ({ ...m, [id]: txt }));
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
}
return (
<>
<SectionHeader
title="Autostart"
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
/>
<div className="space-y-2 max-w-3xl">
{loaded && progs.length === 0 && (
<p className="text-sm text-muted-foreground italic">No programs yet add one below.</p>
)}
{progs.map((p) => (
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center gap-2">
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
onChange={(e) => patch(p.id, { name: e.target.value })} />
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
<Play className="size-3.5" /> Launch
</Button>
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground">Program</Label>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
<FolderOpen className="size-3.5" /> Browse
</Button>
</div>
<Label className="text-xs text-muted-foreground">Arguments</Label>
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
onChange={(e) => patch(p.id, { args: e.target.value })} />
</div>
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
</div>
))}
<Button variant="outline" onClick={addProgram}>
<Plus className="size-4" /> Add program
</Button>
{err && <div className="text-xs text-destructive">{err}</div>}
</div>
</>
);
}
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
}, []);
return (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
</label>
);
}
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map1', label: 'Map — great-circle + beam' },
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
];
function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) {
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
useEffect(() => {
const valid = (v: string) => MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
const pick = (side: 'left' | 'right', v: string) => {
if (side === 'left') setLeft(v); else setRight(v);
// Persist (per-profile) AND tell the parent the new value directly, so the
// Main view updates from the chosen value — never a stale DB re-read.
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
onChanged?.(side, v);
};
return (
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
<div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Left pane</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Right pane</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
</div>
</div>
);
}
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
// (fills the IP/port). Self-contained so it can own its state (rendered inside
// the hook-less CATPanel).
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
const [busy, setBusy] = useState(false);
const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
const [msg, setMsg] = useState('');
async function scan() {
setBusy(true); setMsg('');
try {
const r = ((await DiscoverFlexRadios()) ?? []) as any[];
setFound(r as any);
if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
return (
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
</Button>
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
</div>
{found.map((r) => (
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
<span className="font-mono font-semibold">{r.ip}</span>
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
</button>
))}
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id; const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction; const IconCmp = Icon ?? Construction;
@@ -328,7 +550,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
export function SettingsModal({ onClose, onSaved, initialSection }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -363,7 +585,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [bandDraft, setBandDraft] = useState(''); const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0, enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
@@ -426,6 +648,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// General behaviour prefs (mirrored to the DB so they travel with data/). // General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
@@ -455,11 +678,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// E-mail / SMTP (send QSO recordings). // E-mail / SMTP (send QSO recordings).
type EmailCfg = { type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string; enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string; from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
}; };
const [emailCfg, setEmailCfg] = useState<EmailCfg>({ const [emailCfg, setEmailCfg] = useState<EmailCfg>({
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '', from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
}); });
const [emailMsg, setEmailMsg] = useState(''); const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch })); const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
@@ -539,6 +762,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
const [dbMsg, setDbMsg] = useState(''); const [dbMsg, setDbMsg] = useState('');
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string };
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
const [mysqlMsg, setMysqlMsg] = useState('');
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
const [dataDir, setDataDir] = useState(''); const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
@@ -628,6 +857,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any); setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setDataDir(await GetDataDir()); } catch {} try { setDataDir(await GetDataDir()); } catch {}
try { try {
const locs: any = await ListTQSLStationLocations(); const locs: any = await ListTQSLStationLocations();
@@ -648,6 +879,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})(); })();
}, []); }, []);
// Every setting is per-profile, so when the active profile changes WHILE this
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
// — otherwise they keep showing the previous profile's values until reopen.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
(async () => {
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {}
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
try {
const ls: any = await GetListsSettings();
setLists(ls);
setRstText({
phone: (ls.rst_phone ?? []).join(' '),
cw: (ls.rst_cw ?? []).join(' '),
digital: (ls.rst_digital ?? []).join(' '),
});
} catch {}
})();
});
return () => { off(); };
}, []);
// Auto-fill the active profile's MY_* DXCC metadata from the station // Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
// are derived values, so they always recompute when the callsign or grid // are derived values, so they always recompute when the callsign or grid
@@ -1462,10 +1727,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader <SectionHeader
title="CAT interface (OmniRig)" title="CAT interface"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it." hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
/> />
<div className="space-y-4 max-w-lg"> <div className="space-y-4 max-w-3xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT Enable CAT
@@ -1477,11 +1742,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}> <Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem> <SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem> <SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{catCfg.backend === 'omnirig' && (
<div className="space-y-1"> <div className="space-y-1">
<Label>OmniRig rig slot</Label> <Label>OmniRig rig slot</Label>
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}> <Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
@@ -1492,6 +1758,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{catCfg.backend === 'flex' && (
<>
<div className="space-y-1">
<Label>FlexRadio IP</Label>
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
</div>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
</label>
</>
)}
{catCfg.backend === 'omnirig' && (
<>
<div className="space-y-1"> <div className="space-y-1">
<Label>Poll interval (ms)</Label> <Label>Poll interval (ms)</Label>
<Input <Input
@@ -1508,6 +1798,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))} onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
/> />
</div> </div>
</>
)}
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Default digital mode (when rig reports DIG)</Label> <Label>Default digital mode (when rig reports DIG)</Label>
<Select <Select
@@ -1523,6 +1815,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Select> </Select>
</div> </div>
</div> </div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox <Checkbox
checked={catModeBeforeFreq} checked={catModeBeforeFreq}
@@ -1537,6 +1831,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong> OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong>
{' '}is the specific mode OpsLog will surface (and log). {' '}is the specific mode OpsLog will surface (and log).
</p> </p>
</>
)}
{catCfg.backend === 'flex' && (
<p className="text-xs text-muted-foreground">
Native SmartSDR API no OmniRig needed. Frequency, mode and split are read in
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
logs when the slice is in a digital mode (DIGU/DIGL).
</p>
)}
</div> </div>
</> </>
); );
@@ -1647,7 +1951,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader <SectionHeader
title="Rotator (PstRotator)" title="Rotator"
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup Communication UDP) before testing." hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup Communication UDP) before testing."
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
@@ -1720,7 +2024,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader <SectionHeader
title="CW Keyer (WinKeyer)" title="CW Keyer"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)." hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)."
/> />
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
@@ -1836,7 +2140,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3"> <div className="border-t border-border/60 pt-3">
<Label className="text-sm font-medium">CW message macros (F1…)</Label> <Label className="text-sm font-medium">CW message macros (F1…)</Label>
<p className="text-[11px] text-muted-foreground mb-2"> <p className="text-[11px] text-muted-foreground mb-2">
Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono">&lt;LOGQSO&gt;</span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU &lt;LOGQSO&gt;</span>).
</p> </p>
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1"> <div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
{Array.from({ length: 12 }).map((_, i) => { {Array.from({ length: 12 }).map((_, i) => {
@@ -2177,7 +2481,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<> <>
<SectionHeader <SectionHeader
title="Database backup" title="Database backup"
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones." hint={mysqlCfg.enabled
? "On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each."
: "OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2689,8 +2995,68 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<> <>
<SectionHeader <SectionHeader
title="Database" title="Database"
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
/> />
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
target its own database; choosing here and Save switches the live
logbook immediately (no restart). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">Backend</Label>
<Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases switching profile switches the logbook.
</p>
{/* Save (always visible) applies the active profile's DB target live. */}
<div className="max-w-2xl mb-4 flex items-center gap-3">
<Button size="sm" className="h-8"
onClick={() => {
SaveMySQLSettings(mysqlCfg as any)
.then(() => setRestartMsg(mysqlCfg.enabled
? 'Logbook switched to MySQL ✓'
: 'Logbook switched to local SQLite ✓'))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}>
Save &amp; switch logbook
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
<div className="max-w-2xl mb-4">
{backendStatus.fallback ? (
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
MySQL is enabled but the connection failed at startup OpsLog is running on the local <strong>SQLite</strong> database.
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : backendStatus.active === 'mysql' ? (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
<strong>Logbook:</strong> shared MySQL <span className="text-emerald-700">connected </span>
<span className="mx-1.5 text-muted-foreground">·</span>
<strong>Config:</strong> local SQLite
<div className="text-[10px] text-muted-foreground mt-1">
Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.
</div>
</div>
) : (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
Active backend: <strong className="uppercase">{backendStatus.active}</strong>
</div>
)}
</div>
)}
{!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="space-y-1"> <div className="space-y-1">
<Label>Current database</Label> <Label>Current database</Label>
@@ -2710,13 +3076,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>} {dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div> </div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
Any database change takes effect on the next launch.
</div>
{dbMsg && ( {dbMsg && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line"> <div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<span>{dbMsg}</span> <span>{dbMsg}</span>
@@ -2724,14 +3083,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
)} )}
</div> </div>
)}
{/* Shared MySQL database (multi-operator) */}
{mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed">
Several OpsLog instances pointed at one MySQL database see each other's QSOs live (refreshed every 2 s). <strong>Test &amp; create</strong> the database, then <strong>Save &amp; switch logbook</strong> above to start logging there.
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">Port</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<Label className="text-sm">Database</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">User</Label>
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
Test &amp; create database
</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div>
</div>
)}
{/* Data location */} {/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl"> <div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div> <div>
<div className="text-sm font-medium">Data location</div> <div className="text-sm font-medium">Data location</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
OpsLog is fully portable — all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything.
</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Current data directory</Label> <Label>Current data directory</Label>
@@ -2939,7 +3324,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader title="General" hint="App behaviour (saved instantly)." /> <SectionHeader title="General" hint="App behaviour (saved instantly)." />
<div className="space-y-3 max-w-lg"> <div className="space-y-3 max-w-3xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} /> <Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
Auto-focus "Worked before" for known stations Auto-focus "Worked before" for known stations
@@ -2956,6 +3341,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} /> <Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span> Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
</label>
<TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4> <h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
@@ -3076,6 +3468,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} /> <Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">From address</Label> <Label className="text-sm">From address</Label>
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} /> <Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
<Label className="text-sm">Reply-To address</Label>
<div>
<Input className="h-8" placeholder="(optional — where replies go)" value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
<div className="text-[10px] text-muted-foreground mt-1">Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.</div>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8" <Button variant="outline" size="sm" className="h-8"
@@ -3086,7 +3483,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
<div className="pt-2 mt-2 border-t border-border space-y-2"> <div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">eQSL card e-mail</Label> <Label className="text-sm font-semibold">OpsLog QSL card e-mail</Label>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}. Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div> </div>
@@ -3096,7 +3493,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onChange={(e) => setEqslField({ body: e.target.value })} /> onChange={(e) => setEqslField({ body: e.target.value })} />
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} /> <Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
Auto-send eQSL when a QSO is logged Auto-send OpsLog QSL when a QSO is logged
</label> </label>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
Sends automatically only when the contact has an e-mail address and a default QSL template exists. Sends automatically only when the contact has an e-mail address and a default QSL template exists.
@@ -3123,6 +3520,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel, backup: BackupPanel,
database: DatabasePanel, database: DatabasePanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
+24
View File
@@ -38,6 +38,10 @@ interface Props {
onToggleSendOnType: (on: boolean) => void; onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables) onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char onBackspace: () => void; // remove last not-yet-keyed char
autoCall: boolean; // repeat the clicked macro on a timer
autoCallSecs: number; // gap (s) after the message before repeating
onToggleAutoCall: (on: boolean) => void;
onSetAutoCallSecs: (n: number) => void;
} }
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the // WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed, onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose, onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace, sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) { }: Props) {
const [cwText, setCwText] = useState(''); const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm); const [speed, setSpeed] = useState(wpm);
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
</Button> </Button>
</div> </div>
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} />
Auto-call
</label>
<span className="text-[11px] text-muted-foreground">gap</span>
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
<input type="number" min={0} max={120}
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span>
</div>
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => ( {macros.map((m, i) => (
+18 -5
View File
@@ -28,6 +28,14 @@ export function AutoEQSL({ onSent, onError }: Props) {
const busy = useRef(false); const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null); const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null); const svgEl = useRef<SVGSVGElement | null>(null);
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
// Keep the callbacks in refs so they never change the effects' identity — a
// toast/grid re-render from onSent must NOT re-run the send effect (that
// re-sent the same eQSL many times in a row).
const onSentRef = useRef(onSent);
const onErrorRef = useRef(onError);
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
// Pull the next job, fetch its render model + assets, then mount it (the // Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it). // effect below rasterizes once the DOM has it).
@@ -41,14 +49,16 @@ export function AutoEQSL({ onSent, onError }: Props) {
const assets = await loadCardAssets(model.template, job.templateId); const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets }); setCurrent({ job, model, assets });
} catch (e) { } catch (e) {
onError?.(`Auto eQSL: ${e}`); onErrorRef.current?.(`Auto eQSL: ${e}`);
busy.current = false; busy.current = false;
void pump(); void pump();
} }
}, [onError]); }, []);
useEffect(() => { useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => { const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign }); queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump(); void pump();
}); });
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
}, [pump]); }, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send. // Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
// Sends exactly once per job (guarded by sentForRef), independent of renders.
useEffect(() => { useEffect(() => {
if (!current) return; if (!current) return;
if (sentForRef.current === current.job.qsoId) return; // already sent this one
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
await document.fonts.ready; await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return; if (cancelled || !svgEl.current) return;
sentForRef.current = current.job.qsoId;
const card = current.model.template.card; const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg'); const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg); await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSent?.(current.job.callsign); onSentRef.current?.(current.job.callsign);
} catch (e) { } catch (e) {
onError?.(`Auto eQSL: ${e}`); onErrorRef.current?.(`Auto eQSL: ${e}`);
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setCurrent(null); setCurrent(null);
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [current, pump, onSent, onError]); }, [current, pump]);
if (!current) return null; if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the // Off-screen at full card resolution so the rasterized output matches the
+5 -4
View File
@@ -72,10 +72,11 @@ function buildFxParams(e: CardElement): TextFxParams | null {
size: e.size, size: e.size,
space: e.size * (kind === 'western' ? 0.08 : 0.04), space: e.size * (kind === 'western' ? 0.08 : 0.04),
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0], cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
// Dark inter-letter edge — fixed near-black (the navy outline adaptStyle // Dark inter-letter edge + rim — from the chosen colour palette, else the
// sets is for the old SVG stack, not this look). // default near-black edge (the navy outline adaptStyle sets is for the old
cDark: kind === 'western' ? '#1c130a' : '#262630', // SVG stack, not this look).
cOuter: silver ? '#e8edf2' : '#ced3db', cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
// Per-call overrides from the editor (undefined → renderer default). // Per-call overrides from the editor (undefined → renderer default).
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss, plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b, glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
@@ -96,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
async function choosePhotos() { async function choosePhotos() {
try { try {
const paths = ((await QSLPickPhotos()) ?? []) as string[]; const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 3)); if (paths.length) setPhotoPaths(paths.slice(0, 5));
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
} }
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3> <h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pick 13 photos OpsLog analyzes them and proposes three card designs Pick 15 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically. with your callsign, name, zones and country placed automatically.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogContent className="max-w-[820px]"> <DialogContent className="max-w-[820px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send eQSL by e-mail <Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogFooter> <DialogFooter>
{sent ? ( {sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600"> <div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="size-4" /> eQSL sent. <CheckCircle2 className="size-4" /> OpsLog QSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button> <Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div> </div>
) : ( ) : (
@@ -16,6 +16,25 @@ interface Props {
onChange: (preset: string, params: StyleParams) => void; onChange: (preset: string, params: StyleParams) => void;
} }
// Quick colour palettes per FX family (mirrors the reference generators):
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
const GLOSSY_PALETTES: Palette[] = [
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
];
const WESTERN_PALETTES: Palette[] = [
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
];
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
</SelectContent> </SelectContent>
</Select> </Select>
{isFx && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Palette</Label>
<div className="flex flex-wrap gap-1">
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
<button
key={p.name} type="button" title={p.name}
className="h-6 w-6 rounded border border-border"
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
onClick={() => onChange(preset, {
...params,
gradient: [p.top, p.mid, p.bot],
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
})}
/>
))}
</div>
</div>
)}
{has('color') && ( {has('color') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} /> <ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)} )}
+2
View File
@@ -70,6 +70,8 @@ export interface FxParams {
grunge?: number; grunge?: number;
bevel?: number; bevel?: number;
seed?: number; seed?: number;
dark?: string;
outer?: string;
} }
export interface StyleParams { export interface StyleParams {
+13 -5
View File
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
// only an exact (case-insensitive) match — otherwise it reverts, so the field // only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list. // can't hold a typo'd value that isn't in the list.
export function Combobox({ export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -14,6 +14,10 @@ export function Combobox({
placeholder?: string; placeholder?: string;
className?: string; className?: string;
allowFreeText?: boolean; allowFreeText?: boolean;
// Commit each keystroke to the parent immediately (not just on blur). Use for
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@@ -41,9 +45,13 @@ export function Combobox({
// Defer so a click on an option registers first. // Defer so a click on an option registers first.
setTimeout(() => { setTimeout(() => {
setOpen(false); setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase()); const trimmed = query.trim();
if (exact) { onChange(exact); setQuery(exact); } const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
else if (allowFreeText) { onChange(query.trim()); } // Only fire onChange when the value actually changed — committing an
// unchanged value on a plain tab-through would wrongly flag the field as
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
else { setQuery(value); } // revert typo else { setQuery(value); } // revert typo
}, 120); }, 120);
} }
@@ -56,7 +64,7 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT // Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown). // open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }} onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); } if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+25 -6
View File
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out; return out;
} }
// applyAwardRefs writes picked references onto a QSO payload using each award's // MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// scanned field. fieldOf maps an award CODE (uppercase) to its field name. // the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
// reference can be assigned even for awards that scan a free-text field by
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
// the field wouldn't match.
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
// awards backed by a standard ADIF column also get that column written so the
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) { export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs); const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) }; const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) { for (const [code, ref] of Object.entries(byCode)) {
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
overrides.push(`${code}@${r}`);
}
const field = fieldOf[code] || code.toLowerCase(); const field = fieldOf[code] || code.toLowerCase();
switch (field) { switch (field) {
case 'iota': payload.iota = ref; break; case 'iota': payload.iota = ref; break;
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
extras['SIG'] = 'WWFF'; extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref; extras['SIG_INFO'] = ref;
break; break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM // QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
// scans the note for "D06"). Picking such a reference appends its code(s) // "D06" in the note): append the code so the in-field matcher finds it too.
// to that field so the matcher finds it.
case 'note': case 'notes': case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref); payload.notes = appendTokens(payload.notes, ref);
break; break;
case 'comment': case 'comment':
payload.comment = appendTokens(payload.comment, ref); payload.comment = appendTokens(payload.comment, ref);
break; break;
// address / qth / name / custom: the override above is authoritative; do
// not write a code into a free-text field the award matches by name.
default: default:
extras[field.toUpperCase()] = ref;
break; break;
} }
} }
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
else delete extras[MANUAL_REFS_KEY];
if (Object.keys(extras).length > 0) payload.extras = extras; if (Object.keys(extras).length > 0) payload.extras = extras;
else if (payload.extras) delete payload.extras;
} }
// awardRefValue reads a single award's stored reference from a QSO, inverse of // awardRefValue reads a single award's stored reference from a QSO, inverse of
+1
View File
@@ -22,6 +22,7 @@ const PORTABLE_KEYS = [
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
]; ];
// syncPortablePrefs reconciles the DB with the local cache at startup: // syncPortablePrefs reconciles the DB with the local cache at startup:
+6
View File
@@ -0,0 +1,6 @@
// 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).
export const APP_VERSION = '0.11.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+35 -1
View File
@@ -3,10 +3,10 @@
import {adif} from '../models'; import {adif} from '../models';
import {qso} from '../models'; import {qso} from '../models';
import {main} from '../models'; import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {cat} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
@@ -25,14 +25,20 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>; export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>; export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>; export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>; export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>; export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
@@ -87,6 +93,10 @@ export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>; export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>; export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>; export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
@@ -109,6 +119,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>; export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>; export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>; export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -135,6 +147,10 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>; export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>; export function GetDVKStatus():Promise<main.DVKStatus>;
@@ -151,8 +167,12 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>; export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -169,6 +189,8 @@ export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>; export function GetStationSettings():Promise<main.StationSettings>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>; export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -187,6 +209,10 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
export function ImportAwards():Promise<main.AwardImportResult>; export function ImportAwards():Promise<main.AwardImportResult>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>; export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>; export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -305,6 +331,8 @@ export function SaveADIFFile():Promise<string>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>; export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>; export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
@@ -323,6 +351,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>; export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>; export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
@@ -367,6 +397,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
@@ -383,6 +415,8 @@ export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
export function TestPTT(arg1:main.AudioSettings):Promise<void>; export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>; export function TestQRZUpload():Promise<string>;
+68
View File
@@ -22,6 +22,10 @@ export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
} }
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AwardCellQSOs(arg1, arg2, arg3) { export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
} }
@@ -34,10 +38,18 @@ export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1); return window['go']['main']['App']['AwardMissingQSOs'](arg1);
} }
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
export function BulkUpdateQSL(arg1, arg2) { export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CheckForUpdate() {
return window['go']['main']['App']['CheckForUpdate']();
}
export function ClearLookupCache() { export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache'](); return window['go']['main']['App']['ClearLookupCache']();
} }
@@ -146,6 +158,14 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1); return window['go']['main']['App']['DisconnectClusterServer'](arg1);
} }
export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadClublogCty() { export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty'](); return window['go']['main']['App']['DownloadClublogCty']();
} }
@@ -190,6 +210,10 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings'](); return window['go']['main']['App']['GetAudioSettings']();
} }
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) { export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1); return window['go']['main']['App']['GetAward'](arg1);
} }
@@ -242,6 +266,14 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
export function GetDBBackendStatus() {
return window['go']['main']['App']['GetDBBackendStatus']();
}
export function GetDBConnectionInfo() {
return window['go']['main']['App']['GetDBConnectionInfo']();
}
export function GetDVKMessages() { export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages'](); return window['go']['main']['App']['GetDVKMessages']();
} }
@@ -274,10 +306,18 @@ export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath'](); return window['go']['main']['App']['GetLogFilePath']();
} }
export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision']();
}
export function GetLookupSettings() { export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings'](); return window['go']['main']['App']['GetLookupSettings']();
} }
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
export function GetPOTAToken() { export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
@@ -310,6 +350,10 @@ export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings'](); return window['go']['main']['App']['GetStationSettings']();
} }
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetUIPref(arg1) { export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
@@ -346,6 +390,14 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards'](); return window['go']['main']['App']['ImportAwards']();
} }
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms']();
}
export function ListAudioInputDevices() { export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices'](); return window['go']['main']['App']['ListAudioInputDevices']();
} }
@@ -582,6 +634,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1); return window['go']['main']['App']['SaveAudioSettings'](arg1);
} }
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
export function SaveAwardDefs(arg1) { export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1); return window['go']['main']['App']['SaveAwardDefs'](arg1);
} }
@@ -618,6 +674,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1); return window['go']['main']['App']['SaveLookupSettings'](arg1);
} }
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
export function SaveOperatingAntenna(arg1) { export function SaveOperatingAntenna(arg1) {
return window['go']['main']['App']['SaveOperatingAntenna'](arg1); return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
} }
@@ -706,6 +766,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
export function SetUIPref(arg1, arg2) { export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2); return window['go']['main']['App']['SetUIPref'](arg1, arg2);
} }
@@ -738,6 +802,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
} }
export function TestMySQLConnection(arg1) {
return window['go']['main']['App']['TestMySQLConnection'](arg1);
}
export function TestPTT(arg1) { export function TestPTT(arg1) {
return window['go']['main']['App']['TestPTT'](arg1); return window['go']['main']['App']['TestPTT'](arg1);
} }
+211 -2
View File
@@ -104,6 +104,30 @@ export namespace award {
this.confirmed = source["confirmed"]; this.confirmed = source["confirmed"];
} }
} }
export class OrRule {
field: string;
match_by?: string;
exact_match?: boolean;
pattern?: string;
leading_str?: string;
trailing_str?: string;
prefix?: string;
static createFrom(source: any = {}) {
return new OrRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.field = source["field"];
this.match_by = source["match_by"];
this.exact_match = source["exact_match"];
this.pattern = source["pattern"];
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.prefix = source["prefix"];
}
}
export class Def { export class Def {
code: string; code: string;
name: string; name: string;
@@ -126,6 +150,7 @@ export namespace award {
multi?: boolean; multi?: boolean;
dynamic?: boolean; dynamic?: boolean;
add_prefixes?: string[]; add_prefixes?: string[];
or_rules?: OrRule[];
dxcc_filter: number[]; dxcc_filter: number[];
valid_bands?: string[]; valid_bands?: string[];
valid_modes?: string[]; valid_modes?: string[];
@@ -164,6 +189,7 @@ export namespace award {
this.multi = source["multi"]; this.multi = source["multi"];
this.dynamic = source["dynamic"]; this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"]; this.add_prefixes = source["add_prefixes"];
this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"]; this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"]; this.valid_bands = source["valid_bands"];
this.valid_modes = source["valid_modes"]; this.valid_modes = source["valid_modes"];
@@ -175,7 +201,26 @@ export namespace award {
this.total = source["total"]; this.total = source["total"];
this.builtin = source["builtin"]; this.builtin = source["builtin"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
} }
export class Ref { export class Ref {
ref: string; ref: string;
name?: string; name?: string;
@@ -340,6 +385,28 @@ export namespace awardref {
export namespace cat { export namespace cat {
export class FlexRadio {
ip: string;
port: number;
model: string;
nickname: string;
serial: string;
callsign: string;
static createFrom(source: any = {}) {
return new FlexRadio(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ip = source["ip"];
this.port = source["port"];
this.model = source["model"];
this.nickname = source["nickname"];
this.serial = source["serial"];
this.callsign = source["callsign"];
}
}
export class RigState { export class RigState {
enabled: boolean; enabled: boolean;
connected: boolean; connected: boolean;
@@ -682,6 +749,44 @@ export namespace main {
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
} }
} }
export class AutostartLaunchResult {
id: string;
name: string;
status: string;
message: string;
static createFrom(source: any = {}) {
return new AutostartLaunchResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.status = source["status"];
this.message = source["message"];
}
}
export class AutostartProgram {
id: string;
name: string;
path: string;
args: string;
enabled: boolean;
static createFrom(source: any = {}) {
return new AutostartProgram(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.args = source["args"];
this.enabled = source["enabled"];
}
}
export class AwardImportResult { export class AwardImportResult {
awards: number; awards: number;
references: number; references: number;
@@ -792,6 +897,9 @@ export namespace main {
enabled: boolean; enabled: boolean;
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
flex_host: string;
flex_port: number;
flex_spots: boolean;
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
@@ -805,6 +913,9 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.poll_ms = source["poll_ms"]; this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
@@ -846,6 +957,36 @@ export namespace main {
this.file_mod_time = source["file_mod_time"]; this.file_mod_time = source["file_mod_time"];
} }
} }
export class DBBackendStatus {
active: string;
fallback: boolean;
error: string;
static createFrom(source: any = {}) {
return new DBBackendStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.active = source["active"];
this.fallback = source["fallback"];
this.error = source["error"];
}
}
export class DBConnectionInfo {
backend: string;
label: string;
static createFrom(source: any = {}) {
return new DBConnectionInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.label = source["label"];
}
}
export class DVKMessage { export class DVKMessage {
slot: number; slot: number;
label: string; label: string;
@@ -903,6 +1044,7 @@ export namespace main {
smtp_user: string; smtp_user: string;
smtp_password: string; smtp_password: string;
from: string; from: string;
reply_to: string;
encryption: string; encryption: string;
auth: boolean; auth: boolean;
auto_send: boolean; auto_send: boolean;
@@ -921,6 +1063,7 @@ export namespace main {
this.smtp_user = source["smtp_user"]; this.smtp_user = source["smtp_user"];
this.smtp_password = source["smtp_password"]; this.smtp_password = source["smtp_password"];
this.from = source["from"]; this.from = source["from"];
this.reply_to = source["reply_to"];
this.encryption = source["encryption"]; this.encryption = source["encryption"];
this.auth = source["auth"]; this.auth = source["auth"];
this.auto_send = source["auto_send"]; this.auto_send = source["auto_send"];
@@ -1009,6 +1152,28 @@ export namespace main {
} }
} }
export class MySQLSettings {
enabled: boolean;
host: string;
port: number;
user: string;
password: string;
database: string;
static createFrom(source: any = {}) {
return new MySQLSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
}
}
export class POTAUnmatched { export class POTAUnmatched {
activator: string; activator: string;
date: string; date: string;
@@ -1328,7 +1493,6 @@ export namespace main {
ok: boolean; ok: boolean;
err: string; err: string;
db_path: string; db_path: string;
migrated_from_app_data: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new StartupStatus(source); return new StartupStatus(source);
@@ -1339,7 +1503,6 @@ export namespace main {
this.ok = source["ok"]; this.ok = source["ok"];
this.err = source["err"]; this.err = source["err"];
this.db_path = source["db_path"]; this.db_path = source["db_path"];
this.migrated_from_app_data = source["migrated_from_app_data"];
} }
} }
export class StationInfoComputed { export class StationInfoComputed {
@@ -1428,6 +1591,24 @@ export namespace main {
this.moving = source["moving"]; this.moving = source["moving"];
} }
} }
export class UpdateInfo {
current: string;
latest: string;
available: boolean;
url: string;
static createFrom(source: any = {}) {
return new UpdateInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.current = source["current"];
this.latest = source["latest"];
this.available = source["available"];
this.url = source["url"];
}
}
export class WKMacro { export class WKMacro {
label: string; label: string;
text: string; text: string;
@@ -1631,6 +1812,28 @@ export namespace operating {
export namespace profile { export namespace profile {
export class ProfileDB {
backend: string;
host: string;
port: number;
user: string;
password: string;
database: string;
static createFrom(source: any = {}) {
return new ProfileDB(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
}
}
export class Profile { export class Profile {
id: number; id: number;
name: string; name: string;
@@ -1657,6 +1860,7 @@ export namespace profile {
tx_pwr?: number; tx_pwr?: number;
is_active: boolean; is_active: boolean;
sort_order: number; sort_order: number;
db: ProfileDB;
// Go type: time // Go type: time
created_at: any; created_at: any;
// Go type: time // Go type: time
@@ -1693,6 +1897,7 @@ export namespace profile {
this.tx_pwr = source["tx_pwr"]; this.tx_pwr = source["tx_pwr"];
this.is_active = source["is_active"]; this.is_active = source["is_active"];
this.sort_order = source["sort_order"]; this.sort_order = source["sort_order"];
this.db = this.convertValues(source["db"], ProfileDB);
this.created_at = this.convertValues(source["created_at"], null); this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
} }
@@ -1752,6 +1957,8 @@ export namespace qslcard {
grunge?: number; grunge?: number;
bevel?: number; bevel?: number;
seed?: number; seed?: number;
dark?: string;
outer?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new FxParams(source); return new FxParams(source);
@@ -1772,6 +1979,8 @@ export namespace qslcard {
this.grunge = source["grunge"]; this.grunge = source["grunge"];
this.bevel = source["bevel"]; this.bevel = source["bevel"];
this.seed = source["seed"]; this.seed = source["seed"];
this.dark = source["dark"];
this.outer = source["outer"];
} }
} }
export class Halo { export class Halo {
+2
View File
@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/braheezy/shine-mp3 v0.1.0 github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0 github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0
github.com/moutend/go-wca v0.3.0 github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3 github.com/wneessen/go-mail v0.7.3
@@ -16,6 +17,7 @@ require (
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
+4
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8= github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
@@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+19
View File
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String() return b.String()
} }
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
var b strings.Builder
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
len(adifVersion), adifVersion, now)
for _, r := range records {
r = strings.TrimRight(r, "\r\n")
if r == "" {
continue
}
b.WriteString(r)
b.WriteString("\n")
}
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>. // writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
+40 -6
View File
@@ -5,11 +5,26 @@ package audio
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
if r := recover(); r != nil {
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
}
}
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO // Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign. // recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a // It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) { _ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) { _ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder mixer")
t := time.NewTicker(40 * time.Millisecond) t := time.NewTicker(40 * time.Millisecond)
defer t.Stop() defer t.Stop()
for { for {
@@ -221,27 +239,43 @@ func (r *Recorder) RestartQSO() {
r.active = true r.active = true
} }
// SaveQSO writes the accumulated recording to path as a WAV and stops // TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// accumulating. Returns an error if no recording was active. // stops accumulating — fast, no encoding. The next BeginQSO can safely start a
func (r *Recorder) SaveQSO(path string) error { // new take immediately. Pair with WritePCM to encode/write off the hot path so
// a long recording doesn't delay logging.
func (r *Recorder) TakeQSO() ([]byte, error) {
r.mu.Lock() r.mu.Lock()
if !r.active { if !r.active {
r.mu.Unlock() r.mu.Unlock()
return fmt.Errorf("no active recording") return nil, fmt.Errorf("no active recording")
} }
samples := r.acc samples := r.acc
r.acc, r.active = nil, false r.acc, r.active = nil, false
r.mu.Unlock() r.mu.Unlock()
if len(samples) == 0 { if len(samples) == 0 {
return fmt.Errorf("recording was empty") return nil, fmt.Errorf("recording was empty")
} }
data := int16sToBytes(samples) return int16sToBytes(samples), nil
}
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
// .mp3). Slow for MP3 — call off the logging path.
func WritePCM(path string, data []byte) error {
if strings.HasSuffix(strings.ToLower(path), ".mp3") { if strings.HasSuffix(strings.ToLower(path), ".mp3") {
return writeMP3(path, data) return writeMP3(path, data)
} }
return writeWAV(path, data) return writeWAV(path, data)
} }
// SaveQSO snapshots and writes the recording in one call (synchronous).
func (r *Recorder) SaveQSO(path string) error {
data, err := r.TakeQSO()
if err != nil {
return err
}
return WritePCM(path, data)
}
// DiscardQSO drops the active accumulation without saving (callsign cleared). // DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() { func (r *Recorder) DiscardQSO() {
r.mu.Lock() r.mu.Lock()
+120 -7
View File
@@ -68,6 +68,12 @@ type Def struct {
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
// earns a reference if the primary match OR any of these match. Lets a
// French department (DDFM) be found from "D74" in the note AND from a postal
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope --- // --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any) DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
@@ -84,6 +90,20 @@ type Def struct {
Builtin bool `json:"builtin"` // shipped default (informational) Builtin bool `json:"builtin"` // shipped default (informational)
} }
// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
Field string `json:"field"` // QSO field to scan
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
}
// Defaults are the built-in awards seeded on first run (then user-editable). // Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def { func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter // Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
@@ -414,7 +434,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
} }
} }
rl := NewRefList(metas) rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0) found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
} }
// Confirmed reports whether a QSO satisfies any of the given confirmation // Confirmed reports whether a QSO satisfies any of the given confirmation
@@ -445,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
// candidates extracts the reference(s) a QSO contributes to an award, enforcing // candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies. // a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { // searchOne runs one matching rule (the primary or an OR rule) over a QSO and
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr)) // returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
if raw == "" { if raw == "" {
return nil return nil
} }
predefined := hasList && !d.Dynamic byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
var found []string var found []string
switch { switch {
@@ -464,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// the field equals the name; otherwise the name is a substring of it. // the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw) up := strings.ToUpper(raw)
for _, nc := range rl.names { for _, nc := range rl.names {
if d.ExactMatch { if exact {
if up == nc.name { if up == nc.name {
found = append(found, nc.code) found = append(found, nc.code)
} }
@@ -472,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
found = append(found, nc.code) found = append(found, nc.code)
} }
} }
case predefined && !d.ExactMatch: case predefined && !exact:
// "Search reference inside the field": look up each token of the field in // "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few // the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex. // references that declare a regex.
@@ -492,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// counts each reference separately. // counts each reference separately.
found = splitRefs(raw) found = splitRefs(raw)
} }
if prefix != "" {
for i := range found {
found[i] = prefix + found[i]
}
}
return found
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
for i := range d.OrRules {
r := &d.OrRules[i]
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p)
if err != nil {
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
}
if !predefined { if !predefined {
return dedupe(found) return dedupe(found)
+2 -2
View File
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
return 0, fmt.Errorf("clear refs: %w", err) return 0, fmt.Errorf("clear refs: %w", err)
} }
stmt, err := tx.PrepareContext(ctx, stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references `REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias) (award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil { if err != nil {
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return fmt.Errorf("empty award or reference code") return fmt.Errorf("empty award or reference code")
} }
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references `REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias) (award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
+82 -10
View File
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
return dstPath, nil return dstPath, nil
} }
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
base := "opslog-log-" + time.Now().Format("2006-01-02")
tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("export adif: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".adi.zip")
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".adi")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because // copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file. // the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error { func copyFile(src, dst string) error {
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
return out.Close() return out.Close()
} }
// rotate keeps the most recent `keep` backups in folder and deletes the // rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern // opslog-*.db.zip) and deletes the rest.
// are touched — never user files that happen to live in the same folder.
func rotate(folder string, keep int) error { func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
entries, err := os.ReadDir(folder) entries, err := os.ReadDir(folder)
if err != nil { if err != nil {
return err return err
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
continue continue
} }
name := e.Name() name := e.Name()
if !strings.HasPrefix(name, "opslog-") { if !strings.HasPrefix(name, prefix) {
continue continue
} }
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) { ok := false
for _, sfx := range suffixes {
if strings.HasSuffix(name, sfx) {
ok = true
break
}
}
if !ok {
continue continue
} }
info, err := e.Info() info, err := e.Info()
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
return firstErr return firstErr
} }
// HasBackupToday returns true if a backup for today's date already exists // HasBackupToday returns true if a SQLite backup for today's date already
// in folder. Used by the startup auto-backup to skip when the user has // exists in folder. Used by the auto-backup to skip when the user has already
// already restarted the app once today. // restarted the app once today.
func HasBackupToday(folder string) bool { func HasBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}
func hasBackupToday(folder, prefix string, exts ...string) bool {
if folder == "" { if folder == "" {
return false return false
} }
stamp := time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} { for _, ext := range exts {
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil { if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
return true return true
} }
} }
+91 -24
View File
@@ -57,6 +57,7 @@ type RigState struct {
// Manager owns the active backend and runs the polling loop. // Manager owns the active backend and runs the polling loop.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
state RigState state RigState
emit func(RigState) emit func(RigState)
backend Backend backend Backend
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
// state.Error rather than returned, so the UI can keep retrying via the // state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt. // poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) { func (m *Manager) Start(b Backend) {
m.Stop() // Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock() m.mu.Lock()
m.stopCh = make(chan struct{}) m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{}) m.doneCh = make(chan struct{})
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
// Stop signals the CAT goroutine to disconnect and waits for it to exit. // Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() { func (m *Manager) Stop() {
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
m.mu.Lock() m.mu.Lock()
stop := m.stopCh stop := m.stopCh
done := m.doneCh done := m.doneCh
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
if done != nil { if done != nil {
<-done <-done
} }
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
} }
// SetFrequency dispatches a SetFreq call to the CAT goroutine. // SetFrequency dispatches a SetFreq call to the CAT goroutine.
@@ -169,6 +184,48 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) }) return m.exec(func(b Backend) error { return b.SetPTT(on) })
} }
// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
type Spotter interface {
SendSpot(SpotInfo) error
}
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
// missed spot on the panadapter is harmless.
func (m *Manager) SendSpot(s SpotInfo) {
m.mu.RLock()
cmds := m.cmdCh
b := m.backend
m.mu.RUnlock()
if cmds == nil || b == nil {
return
}
if _, ok := b.(Spotter); !ok {
return
}
select {
case cmds <- func() {
if sp, ok := b.(Spotter); ok {
_ = sp.SendSpot(s)
}
}:
default: // queue busy → drop this spot
}
}
// exec marshals a backend operation onto the CAT goroutine. Returns the // exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed. // operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error { func (m *Manager) exec(fn func(Backend) error) error {
@@ -195,23 +252,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
defer close(done) defer close(done)
if err := b.Connect(); err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Stay idle until Stop is called — let the user fix config and re-Start.
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
}
}
}
defer b.Disconnect() defer b.Disconnect()
// Connection is (re)established lazily and retried with a backoff, so a rig
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
// reconnects on its own instead of staying dead until the user toggles CAT.
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
connected = true
}
tryConnect()
ticker := time.NewTicker(pollEvery) ticker := time.NewTicker(pollEvery)
defer ticker.Stop() defer ticker.Stop()
@@ -223,12 +284,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
fn() fn()
m.applyCommandDelay() m.applyCommandDelay()
case <-ticker.C: case <-ticker.C:
if !connected {
tryConnect()
continue
}
ns, err := b.ReadState() ns, err := b.ReadState()
if err != nil { if err != nil {
m.update(RigState{ // Lost the rig — drop the backend so the next attempt reconnects
Enabled: true, Backend: b.Name(), Connected: false, // cleanly, then back off before retrying.
Error: err.Error(), UpdatedAt: time.Now(), connected = false
}) lastAttempt = time.Now()
b.Disconnect()
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
continue continue
} }
ns.Enabled = true ns.Enabled = true
+600
View File
@@ -0,0 +1,600 @@
//go:build windows
package cat
import (
"bufio"
"fmt"
"math"
"net"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
// API on port 4992 — a line-based text protocol — and tracks slice state pushed
// by the radio in REAL TIME, so frequency/mode/split are always current (unlike
// the polled, lagging OmniRig path that needed a second click to fix a mode).
// Pure Go, no CGO, and no OmniRig install required for Flex users.
type Flex struct {
host string
port int
mu sync.Mutex
conn net.Conn
wmu sync.Mutex // serialises writes to conn
seq int
handle string
model string
gotHandle bool
slices map[int]*flexSlice
lastStateSig string // last logged derived-state signature (log only on change)
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
// OnSpotClick is called (off the reader goroutine's hot path) when the user
// clicks one of our spots on the panadapter, with the spot's callsign and
// frequency. The host wires this to fill the entry form. Set before Connect.
OnSpotClick func(callsign string, freqHz int64)
}
type flexSlice struct {
freqHz int64
mode string // raw Flex mode (USB/LSB/CW/DIGU/…)
active bool
tx bool
inUse bool
}
// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
// when the user clicks one of our spots on the panadapter.
var flexTriggerRe = regexp.MustCompile(`spot (\d+) triggered`)
// NewFlex builds a Flex backend for the given radio IP (host) and port (4992).
// spotsEnabled turns on the panadapter spot overlay (subscribe + clear leftovers
// on connect + accept SendSpot).
func NewFlex(host string, port int, spotsEnabled bool) *Flex {
if port == 0 {
port = 4992
}
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
}
}
func (f *Flex) Name() string { return "flex" }
// Connect dials the radio and subscribes to slice/radio status. The reader
// goroutine then keeps our cached state current from the radio's push messages.
func (f *Flex) Connect() error {
f.mu.Lock()
already := f.conn != nil
host := f.host
port := f.port
f.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("flex: no radio IP configured")
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second)
if err != nil {
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
}
f.mu.Lock()
f.conn = conn
f.gotHandle = false
f.slices = map[int]*flexSlice{}
f.mu.Unlock()
debugLog.Printf("Flex: connected to %s:%d", host, port)
go f.reader(conn)
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
f.send("client program=OpsLog")
f.send("sub slice all")
f.send("sub transmit all")
f.send("sub radio all")
if f.spotsEnabled {
// Subscribe so the radio pushes existing spots (we learn their indices),
// then wipe the panadapter so stale spots from a previous session or
// another logger are cleared before we start adding our own.
f.send("sub spot all")
go f.clearSpotsOnConnect(conn)
}
return nil
}
func (f *Flex) Disconnect() {
f.mu.Lock()
c := f.conn
f.conn = nil
f.gotHandle = false
f.mu.Unlock()
if c != nil {
_ = c.Close()
debugLog.Printf("Flex: disconnected")
}
}
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
// new spot's index). Returns 0 when not connected. Best effort.
func (f *Flex) send(cmd string) int {
f.mu.Lock()
c := f.conn
f.seq++
seq := f.seq
f.mu.Unlock()
if c == nil {
return 0
}
f.wmu.Lock()
_, err := fmt.Fprintf(c, "C%d|%s\n", seq, cmd)
f.wmu.Unlock()
if err != nil {
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
return 0
}
debugLog.Printf("Flex: → %s", cmd)
return seq
}
// reader consumes the radio's line stream until the connection drops.
func (f *Flex) reader(conn net.Conn) {
sc := bufio.NewScanner(conn)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r\n")
if line == "" {
continue
}
// Panadapter spot click → "…spot <index> triggered…". Resolve the index
// back to the callsign we stored at spot-add time and notify the host.
if mm := flexTriggerRe.FindStringSubmatch(line); mm != nil {
if idx, err := strconv.Atoi(mm[1]); err == nil {
f.mu.Lock()
call := f.spotCall[idx]
handler := f.OnSpotClick
f.mu.Unlock()
if call != "" && handler != nil {
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
go handler(call, 0)
}
}
}
switch line[0] {
case 'V': // version banner, e.g. "V1.4.0.0"
debugLog.Printf("Flex: radio %s", line)
case 'H': // our client handle
f.mu.Lock()
f.handle = line[1:]
f.gotHandle = true
f.mu.Unlock()
debugLog.Printf("Flex: handshake ok, handle=%s", line[1:])
case 'S': // status push: S<handle>|<object ...>
if i := strings.IndexByte(line, '|'); i >= 0 {
f.handleStatus(line[i+1:])
}
case 'M': // message
debugLog.Printf("Flex: msg %s", line)
case 'R': // command response: R<seq>|<hex>|<message>
parts := strings.SplitN(line[1:], "|", 3)
if len(parts) < 2 {
break
}
seq, _ := strconv.Atoi(parts[0])
ok := parts[1] == "0" || parts[1] == "00000000"
if !ok {
debugLog.Printf("Flex: cmd error %s", line)
}
// A successful "spot add" returns the new spot's index in the message;
// pair it with the callsign we stashed under this seq.
f.mu.Lock()
call, pending := f.pendingSpot[seq]
if pending {
delete(f.pendingSpot, seq)
}
if pending && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call
f.spotIdx[idx] = true
}
}
f.mu.Unlock()
}
}
// Connection ended.
f.mu.Lock()
if f.conn == conn {
f.conn = nil
f.gotHandle = false
}
f.mu.Unlock()
}
// handleStatus parses one status payload, e.g.
// "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …"
func (f *Flex) handleStatus(payload string) {
fields := strings.Fields(payload)
if len(fields) < 2 || fields[0] != "slice" {
// radio … model=FLEX-6400 — grab the model when present.
if len(fields) >= 1 && fields[0] == "radio" {
for _, kv := range fields[1:] {
if strings.HasPrefix(kv, "model=") {
f.mu.Lock()
f.model = strings.TrimPrefix(kv, "model=")
f.mu.Unlock()
}
}
}
if len(fields) >= 1 && fields[0] == "transmit" {
debugLog.Printf("Flex: status %s", payload)
}
// Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
// spot status, which we'll use to fill the callsign once we see its shape.
if len(fields) >= 2 && fields[0] == "spot" {
// The click ("spot N triggered") is handled in the reader; here we
// just keep the set of live spot indices for ClearSpots.
if idx, err := strconv.Atoi(fields[1]); err == nil {
removed := false
for _, kv := range fields[2:] {
if kv == "removed" || kv == "in_use=0" {
removed = true
}
}
f.mu.Lock()
if removed {
delete(f.spotIdx, idx)
delete(f.spotCall, idx)
} else {
f.spotIdx[idx] = true
}
f.mu.Unlock()
}
debugLog.Printf("Flex: status %s", payload)
}
return
}
// Slice status — log it so split/freq/mode issues are diagnosable.
debugLog.Printf("Flex: status %s", payload)
idx, err := strconv.Atoi(fields[1])
if err != nil {
return
}
f.mu.Lock()
s := f.slices[idx]
if s == nil {
s = &flexSlice{}
f.slices[idx] = s
}
for _, kv := range fields[2:] {
eq := strings.IndexByte(kv, '=')
if eq <= 0 {
continue
}
key, val := kv[:eq], kv[eq+1:]
switch key {
case "RF_frequency":
if mhz, e := strconv.ParseFloat(val, 64); e == nil {
s.freqHz = int64(math.Round(mhz * 1e6))
}
case "mode":
s.mode = val
case "active":
s.active = val == "1"
case "tx":
s.tx = val == "1"
case "in_use":
s.inUse = val == "1"
}
}
f.mu.Unlock()
}
// ReadState returns the cached state derived from the radio's push messages —
// no round-trip, so it's always current.
func (f *Flex) ReadState() (RigState, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.conn == nil {
return RigState{}, fmt.Errorf("flex: not connected")
}
st := RigState{Connected: f.gotHandle, Rig: f.model}
if !f.gotHandle {
return st, nil // connected TCP but radio hasn't handshaked yet
}
rx, tx := f.pickSlicesLocked()
if rx == nil && tx == nil {
return st, nil
}
if tx == nil {
tx = rx
}
if rx == nil {
rx = tx
}
st.FreqHz = tx.freqHz
st.Mode = flexModeToADIF(tx.mode)
if rx.freqHz != tx.freqHz {
st.Split = true
st.RxFreqHz = rx.freqHz
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != f.lastStateSig {
f.lastStateSig = sig
debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
// (preferring the active/focused one), NOT simply the active slice: tuning the
// TX slice makes it the active/focused slice, which would otherwise collapse RX
// onto TX and hide the split. Caller holds f.mu.
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
idxs := make([]int, 0, len(f.slices))
for i, s := range f.slices {
if s.inUse {
idxs = append(idxs, i)
}
}
sort.Ints(idxs)
var active, txS, nonTx, first *flexSlice
for _, i := range idxs {
s := f.slices[i]
if first == nil {
first = s
}
if s.active {
active = s
}
if s.tx {
txS = s
} else if nonTx == nil {
nonTx = s
}
}
tx = txS
if tx == nil {
if active != nil {
tx = active
} else {
tx = first
}
}
// RX = the receive slice: the active one if it isn't the TX slice, else the
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
switch {
case active != nil && active != tx:
rx = active
case nonTx != nil:
rx = nonTx
default:
rx = tx
}
return rx, tx
}
// activeSliceIndexLocked returns the slice index to send commands to (the active
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
best, found := 1<<30, false
for idx, s := range f.slices {
if !s.inUse {
continue
}
if s.active {
return idx
}
if idx < best {
best, found = idx, true
}
}
if found {
return best
}
return 0
}
func (f *Flex) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("flex: invalid frequency")
}
f.mu.Lock()
idx := f.activeSliceIndexLocked()
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
// "slice t <rx> <freq_MHz>" — tune command per the SmartSDR API (MHz, 6 dp).
f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
return nil
}
func (f *Flex) SetMode(mode string) error {
f.mu.Lock()
idx := f.activeSliceIndexLocked()
var freq int64
if s := f.slices[idx]; s != nil {
freq = s.freqHz
}
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
fm := adifModeToFlex(mode, freq)
if fm == "" {
return fmt.Errorf("flex: unsupported mode %q", mode)
}
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
return nil
}
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
// a lifetime so the radio expires them on its own (the API has no "spot clear").
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
func (f *Flex) SendSpot(s SpotInfo) error {
f.mu.Lock()
connected := f.conn != nil && f.gotHandle
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
call := flexEncode(s.Callsign)
if call == "" || s.FreqHz <= 0 {
return nil
}
color := s.Color
if color == "" {
color = "#FFFFA500" // opaque orange default
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
if m := flexEncode(s.Mode); m != "" {
cmd += " mode=" + m
}
if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c
}
seq := f.send(cmd)
if seq > 0 {
// Remember which call this add was for; the R<seq> response carries the
// radio-assigned spot index, which we map to the call so a later click
// (trigger) can be resolved back to the callsign.
f.mu.Lock()
f.pendingSpot[seq] = s.Callsign
f.mu.Unlock()
}
return nil
}
// clearSpotsOnConnect waits until the radio handshake completes (we're truly
// connected), then sends "spot clear" so launching OpsLog — or enabling the
// option — starts from a clean panadapter, including spots left by another
// logger or a previous session.
func (f *Flex) clearSpotsOnConnect(conn net.Conn) {
for i := 0; i < 50; i++ { // up to ~5s for the handshake
f.mu.Lock()
ready := f.gotHandle && f.conn == conn
gone := f.conn != conn
f.mu.Unlock()
if gone {
return // reconnected/closed in the meantime
}
if ready {
f.ClearSpots()
return
}
time.Sleep(100 * time.Millisecond)
}
}
// ClearSpots wipes ALL panadapter spots in one command ("spot clear") — removes
// stale spots from a previous session or another logger, not just our own.
func (f *Flex) ClearSpots() error {
f.mu.Lock()
f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{}
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
f.send("spot clear")
debugLog.Printf("Flex: spot clear sent")
return nil
}
// flexEncode prepares a value for the Flex command line: trimmed, with any
// internal spaces replaced by 0x7F as the SmartSDR API requires.
func flexEncode(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return strings.ReplaceAll(s, " ", "\x7f")
}
func (f *Flex) SetPTT(on bool) error {
f.mu.Lock()
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if on {
f.send("xmit 1")
} else {
f.send("xmit 0")
}
return nil
}
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
func flexModeToADIF(m string) string {
switch strings.ToUpper(strings.TrimSpace(m)) {
case "USB", "LSB":
return "SSB"
case "CW":
return "CW"
case "AM", "SAM":
return "AM"
case "FM", "NFM", "DFM":
return "FM"
case "DIGU", "DIGL":
return "DATA"
case "RTTY":
return "RTTY"
case "FDV":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifModeToFlex maps an ADIF mode to a Flex slice mode. SSB picks USB/LSB from
// the frequency (LSB below 10 MHz, USB above) — the standard convention.
func adifModeToFlex(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "LSB"
}
return "USB"
case "USB":
return "USB"
case "LSB":
return "LSB"
case "CW":
return "CW"
case "AM":
return "AM"
case "FM":
return "FM"
case "RTTY", "FSK":
return "RTTY"
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
return "DIGU"
default:
return ""
}
}
+112
View File
@@ -0,0 +1,112 @@
//go:build windows
package cat
import (
"context"
"net"
"regexp"
"strconv"
"syscall"
"time"
"golang.org/x/sys/windows"
)
// FlexRadio is one radio found by discovery.
type FlexRadio struct {
IP string `json:"ip"`
Port int `json:"port"`
Model string `json:"model"`
Nickname string `json:"nickname"`
Serial string `json:"serial"`
Callsign string `json:"callsign"`
}
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
// second. DiscoverFlex listens for that broadcast for the given duration and
// returns the unique radios seen. Best effort: if the port can't be bound
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
// user falls back to entering the IP by hand.
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
if timeout <= 0 {
timeout = 2 * time.Second
}
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
// listens for the same broadcast.
lc := net.ListenConfig{
Control: func(_, _ string, c syscall.RawConn) error {
var serr error
_ = c.Control(func(fd uintptr) {
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
})
return serr
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
if err != nil {
return nil, err
}
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(timeout))
found := map[string]FlexRadio{}
buf := make([]byte, 2048)
for {
n, _, err := pc.ReadFrom(buf)
if err != nil {
break // deadline reached or socket closed
}
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
if _, dup := found[r.IP]; !dup {
found[r.IP] = r
}
}
}
out := make([]FlexRadio, 0, len(found))
for _, r := range found {
out = append(out, r)
}
return out, nil
}
var (
reFlexModel = regexp.MustCompile(`model=(\S+)`)
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
reFlexPort = regexp.MustCompile(`port=(\d+)`)
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
)
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
// The payload carries a space-separated key=value ASCII blob after a binary
// header, so we scan the whole packet text for the keys we need.
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
s := string(pkt)
m := reFlexIP.FindStringSubmatch(s)
if m == nil {
return FlexRadio{}, false
}
r := FlexRadio{IP: m[1], Port: 4992}
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
r.Port = p
}
}
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
r.Model = mm[1]
}
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
r.Serial = mm[1]
}
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
r.Nickname = mm[1]
}
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
r.Callsign = mm[1]
}
return r, true
}
+99 -42
View File
@@ -3,11 +3,21 @@ package cat
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-ole/go-ole" "github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil" "github.com/go-ole/go-ole/oleutil"
) )
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM. // OmniRig talks to the user's installed OmniRig server over COM.
// //
// All methods MUST be called from the same OS thread (the one Manager.run // All methods MUST be called from the same OS thread (the one Manager.run
@@ -21,6 +31,15 @@ type OmniRig struct {
omnirig *ole.IDispatch omnirig *ole.IDispatch
rig *ole.IDispatch rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
} }
// NewOmniRig creates a non-connected backend. Call Connect before use. // NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil { if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val) s.Mode = omniRigMode(modeVar.Val)
} }
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil { if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val) s.Vfo = omniRigVfo(vfoVar.Val)
} }
// Read both VFO frequencies separately so we can expose split TX/RX. // Read the active/displayed frequency (generic Freq) AND both VFOs. The
// Fall back to generic Freq if the rig only exposes the merged property. // generic Freq is what the rig is operating on — the reliable source for the
freqA, freqB := int64(0), int64(0) // main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil { if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val freqA = v.Val
} }
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
freqB = v.Val freqB = v.Val
} }
// Split detection: trust the explicit Split property when it's set, // Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// BUT only call it a real split if both VFO frequencies are non-zero // compared to PM_SPLITON, not "!= 0".
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by splitRaw := int64(0)
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
// slice model doesn't map to VFO A/B — that would yield a useless splitRaw = v.Val
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
} }
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B). // Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split). // normal operation stays quiet but toggling split on the radio is captured —
switch s.Vfo { // needed to pin down this rig's PM_SPLITON value.
case "AB": if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
s.FreqHz = freqB // TX o.lastSig = sig
s.RxFreqHz = freqA // RX debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
case "BA": o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
s.FreqHz = freqA // TX if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
s.RxFreqHz = freqB // RX return v.Val
case "B", "BB": }
s.FreqHz = freqB return -1
default: // "A", "AA", "" — single VFO on A or unknown }())
s.FreqHz = freqA
} }
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq. // A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { // distinct and non-zero, AND they're in the same band. The same-band test
s.FreqHz = v.Val // kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// ADIF: FreqHz = TX, RxFreqHz = RX.
s.Split = true
switch s.Vfo {
case "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
default: // "AB" and the usual "TX on the other VFO" case
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
} else {
s.FreqHz = freqA
}
} }
} }
return s, nil return s, nil
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return fmt.Errorf("frequency out of OmniRig int32 range") return fmt.Errorf("frequency out of OmniRig int32 range")
} }
hz32 := int32(hz) hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a // Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig. // friend's session shows exactly what OmniRig reports for their rig.
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
case "CW": case "CW":
bit, bitName = pmCWU, "PM_CW_U" bit, bitName = pmCWU, "PM_CW_U"
case "SSB": case "SSB":
// Read current freq to decide USB vs LSB. // Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64 var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val freq = freqVar.Val
} }
if freq > 0 && freq < 10_000_000 { if freq > 0 && freq < 10_000_000 {
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
return "" return ""
} }
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
func omniRigVfo(v int64) string { func omniRigVfo(v int64) string {
switch { switch {
case v&1024 != 0: case v&0x40 != 0: // PM_VFOAA
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
return "AA" return "AA"
case v&128 != 0: case v&0x80 != 0: // PM_VFOAB
return "AB" return "AB"
case v&256 != 0: case v&0x100 != 0: // PM_VFOBA
return "BA" return "BA"
case v&512 != 0: case v&0x200 != 0: // PM_VFOBB
return "BB" return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
} }
return "" return ""
} }
+164 -8
View File
@@ -7,13 +7,143 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// MySQLConfig targets a shared MySQL database for multi-operator logging
// (multiple OpsLog instances on one logbook, à la Log4OM).
type MySQLConfig struct {
Host string
Port int
User string
Password string
Database string
}
func (c MySQLConfig) dsn() string {
port := c.Port
if port == 0 {
port = 3306
}
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
// or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database)
}
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
// use a placeholder). Only plain identifiers allowed.
func validDBIdent(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
return false
}
}
return true
}
// PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required")
}
server := c
server.Database = "" // connect to the server, not a specific DB
conn, err := sql.Open("mysql", server.dsn())
if err != nil {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
}
name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err)
}
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
}
return nil
}
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
var Dialect = "sqlite"
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
// server. Config tables always live in local SQLite, so this only governs
// qso-table SQL (see columnExpr).
func IsMySQL() bool { return Dialect == "mysql" }
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
// startup after the logbook backend is chosen, so it doesn't depend on the
// order in which the local and logbook connections were opened.
func SetDialect(d string) {
if d == "mysql" {
Dialect = "mysql"
} else {
Dialect = "sqlite"
}
}
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
// *_at / date columns — millisecond precision, matching the old SQLite
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path, // Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations. // enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) { func Open(path string) (*sql.DB, error) {
@@ -30,7 +160,8 @@ func Open(path string) (*sql.DB, error) {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
if err := migrate(conn); err != nil { Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, err return nil, err
} }
@@ -39,12 +170,18 @@ func Open(path string) (*sql.DB, error) {
// migrate applies all embedded *.sql migrations in alphabetical order, // migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system // skipping those already applied. Intentionally minimal in-house system
// (no external dependency). // (no external dependency). translate, when non-nil, rewrites each statement
func migrate(conn *sql.DB) error { // for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( func migrate(conn *sql.DB, translate func(string) string) error {
name TEXT PRIMARY KEY, // A non-nil translator means this is the MySQL connection (use the
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) // per-statement, FK-aware path); nil means a SQLite connection. This is
)`); err != nil { // determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil {
translate = func(s string) string { return s }
}
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err) return fmt.Errorf("create schema_migrations: %w", err)
} }
@@ -74,11 +211,30 @@ func migrate(conn *sql.DB) error {
if err != nil { if err != nil {
return fmt.Errorf("read migration %s: %w", name, err) return fmt.Errorf("read migration %s: %w", name, err)
} }
sqlText := translate(string(content))
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
// "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database).
if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
continue
}
// SQLite supports DDL inside a transaction, so keep it atomic there.
tx, err := conn.Begin() tx, err := conn.Begin()
if err != nil { if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err) return fmt.Errorf("begin tx for %s: %w", name, err)
} }
if _, err := tx.Exec(string(content)); err != nil { if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback() _ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err) return fmt.Errorf("apply migration %s: %w", name, err)
} }
@@ -0,0 +1,6 @@
-- Per-profile logbook database. Each profile can target its own logbook:
-- the local SQLite file, or a specific shared MySQL database. Switching the
-- active profile switches the logbook accordingly. Stored as a small JSON
-- document {backend, host, port, user, password, database}; empty = inherit
-- the default (local SQLite).
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
+459
View File
@@ -0,0 +1,459 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ── SQLite → MySQL schema translation ──────────────────────────────────
//
// OpsLog's migrations are authored in SQLite dialect (the default backend).
// For the shared-MySQL backend we translate that DDL on the fly rather than
// maintaining a second set of migration files, so the two backends can never
// drift apart. The translation is deliberately narrow — it only rewrites the
// SQLite-isms that actually appear in our migrations:
//
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
// - bare INTEGER → BIGINT (so FK child/parent types match)
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
// - TEXT → VARCHAR(255), except a few free-text
// columns that stay TEXT/LONGTEXT
// - reserved column `key` → backticked
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
//
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
// are all valid MySQL as written.
var (
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
// actually needs in-row storage: it's indexed, a primary key, or carries a
// DEFAULT (TEXT can't hold a literal default in MySQL).
// varcharColumns must be VARCHAR because they're indexed or part of a primary
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
// below can't catch them). MySQL can't index a TEXT column without a prefix
// length. Keyed by column name (a name may repeat across tables — harmless).
var varcharColumns = map[string]bool{
// qso indexes (0001, 0003, 0019)
"callsign": true, "qso_date": true, "band": true, "mode": true,
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
// integrations_udp index (0011)
"direction": true,
// award_references composite primary key (0017)
"award_code": true, "ref_code": true,
}
// longTextColumns override the default TEXT with a bigger type where the
// content can be large (a full QSL-template document).
var longTextColumns = map[string]string{
"json": "LONGTEXT",
}
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
// inserts that omit the id still get one.
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
s = reStrftimeDefault.ReplaceAllString(s, "''")
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
s = reBareInteger.ReplaceAllString(s, "BIGINT")
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
// a primary key, or default-bearing — keeping the row under MySQL's limit.
s = translateTextColumns(s)
// `key` is a MySQL reserved word — backtick the settings column declaration.
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
return s
}
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
// right MySQL type, deciding per line so it can see whether the line carries a
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
func translateTextColumns(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
m := reColText.FindStringSubmatchIndex(line)
if m == nil {
continue
}
col := line[m[2]:m[3]]
var typ string
switch {
case longTextColumns[col] != "":
typ = longTextColumns[col]
case varcharColumns[col],
strings.Contains(line, "DEFAULT"),
strings.Contains(line, "PRIMARY KEY"):
typ = "VARCHAR(255)"
default:
typ = "TEXT"
}
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
}
return strings.Join(lines, "\n")
}
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
}
name := strings.TrimSpace(c.Database)
if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
return conn, nil
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
// keeps migrations idempotent and lets a half-applied database self-heal.
//
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
// connection returns to the pool, so runtime queries keep FK enforcement.
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
}
}
return nil
}
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping comments (full-line and inline "-- …") and blank fragments. Our
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
var out []string
for _, part := range strings.Split(b.String(), ";") {
if strings.TrimSpace(part) != "" {
out = append(out, part)
}
}
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.
func isIgnorableDDLError(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
return true
}
return false
}
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"regexp"
"strings"
"testing"
)
func TestMySQLDDL_Translations(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
// The column-name/type whitespace collapses to a single space — harmless.
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
}
for _, c := range cases {
if got := mysqlDDL(c.in); got != c.want {
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
}
}
}
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
got := mysqlDDL(in)
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
t.Errorf("key column not backticked/translated:\n%s", got)
}
}
func TestSplitStatements(t *testing.T) {
in := "-- a comment\n" +
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
"\n" +
"CREATE INDEX idx ON qso(a);\n"
got := splitStatements(in)
if len(got) != 3 {
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
}
if !strings.Contains(got[0], "ADD COLUMN a") ||
!strings.Contains(got[1], "ADD COLUMN b") ||
!strings.Contains(got[2], "CREATE INDEX") {
t.Errorf("unexpected split: %#v", got)
}
// No fragment should be a comment-only or blank statement.
for _, s := range got {
if strings.TrimSpace(s) == "" {
t.Errorf("empty statement in result: %#v", got)
}
}
}
// Every embedded migration must split into at least one runnable statement
// and never produce an empty fragment.
func TestSplitStatements_AllMigrations(t *testing.T) {
entries, _ := migrationsFS.ReadDir("migrations")
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
stmts := splitStatements(mysqlDDL(string(raw)))
if len(stmts) == 0 {
t.Errorf("%s: produced no statements", e.Name())
}
for _, s := range stmts {
if strings.TrimSpace(s) == "" {
t.Errorf("%s: empty statement fragment", e.Name())
}
}
}
}
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
// clear of it. TEXT columns are off-page and don't count here.
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
varchars := 0
for _, name := range qsoMigrations {
raw, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatal(err)
}
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
}
const bytesPerVarchar = 255 * 4 // utf8mb4
rowBytes := varchars * bytesPerVarchar
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
}
}
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
// fails if any SQLite-only construct survives — a fast guard against a new
// migration sneaking in a dialect-ism the translator doesn't cover.
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
t.Fatal(err)
}
reInteger := regexp.MustCompile(`\bINTEGER\b`)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
t.Fatal(err)
}
out := mysqlDDL(string(raw))
if strings.Contains(out, "AUTOINCREMENT") {
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
}
if strings.Contains(out, "strftime") {
t.Errorf("%s: strftime survived", e.Name())
}
if strings.Contains(out, "IF NOT EXISTS idx") {
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
}
// Strip comment lines before checking for bare INTEGER (comments are
// prose and may legitimately mention the word).
var code strings.Builder
for _, ln := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
continue
}
code.WriteString(ln)
code.WriteByte('\n')
}
if reInteger.MatchString(code.String()) {
t.Errorf("%s: bare INTEGER survived in code", e.Name())
}
}
}
+16 -14
View File
@@ -278,36 +278,38 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before // suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the // matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or // operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING // handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign. // (the Scotland prefix) isn't mistaken for it.
var suffixModifiers = map[string]bool{ var suffixModifiers = map[string]bool{
"P": true, "M": true, "QRP": true, "A": true, "P": true, "M": true, "QRP": true, "A": true,
"PM": true, "LH": true, "PM": true, "LH": true,
} }
// normalizeCallsign uppercases, trims, and resolves the "active" call when // normalizeCallsign uppercases, trims, and resolves the "active" call when the
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for // operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC. // (maritime/aeronautical mobile) is stripped so the home entity still resolves
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
// should still show the operator's country.
func normalizeCallsign(s string) string { func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') { if !strings.ContainsRune(s, '/') {
return s return s
} }
parts := strings.Split(s, "/") parts := strings.Split(s, "/")
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
for i, p := range parts {
if i > 0 && (p == "MM" || p == "AM") {
return ""
}
}
keep := parts[:0] keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for _, p := range parts { for i, p := range parts {
if p == "" { if p == "" {
continue continue
} }
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
// the operator's home entity is kept, so the contact still resolves to a
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
if i > 0 && (p == "MM" || p == "AM") {
continue
}
if suffixModifiers[p] { if suffixModifiers[p] {
continue continue
} }
+2 -2
View File
@@ -214,8 +214,8 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO", "f4bpo": "F4BPO",
" F4BPO ": "F4BPO", " F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO", "F4BPO/P": "F4BPO",
"F4BPO/MM": "", // maritime mobile → no DXCC entity "F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity "F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity "F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5 "F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8) "HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
+6
View File
@@ -16,6 +16,7 @@ type Config struct {
User string User string
Password string Password string
From string From string
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
Encryption string // "ssl" | "starttls" | "none" Encryption string // "ssl" | "starttls" | "none"
Auth bool // SMTP requires authorization (send username/password) Auth bool // SMTP requires authorization (send username/password)
} }
@@ -57,6 +58,11 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
if err := m.To(to); err != nil { if err := m.To(to); err != nil {
return fmt.Errorf("bad recipient %q: %w", to, err) return fmt.Errorf("bad recipient %q: %w", to, err)
} }
if cfg.ReplyTo != "" {
if err := m.ReplyTo(cfg.ReplyTo); err != nil {
return fmt.Errorf("bad reply-to %q: %w", cfg.ReplyTo, err)
}
}
m.Subject(subject) m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body) m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" { if attachPath != "" {
+76 -2
View File
@@ -1,19 +1,26 @@
package extsvc package extsvc
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
) )
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint. // clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.) // when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php" const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log // clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the // requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in // user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
return res, nil return res, nil
} }
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
}
// TestClublog validates the configured credentials by attempting a no-op // TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the // style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload. // fields look complete; a real failure surfaces on the first upload.
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"hamlog/internal/db"
) )
// Direction is "inbound" (we listen) or "outbound" (we emit). // Direction is "inbound" (we listen) or "outbound" (we emit).
@@ -112,10 +114,10 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
direction = ?, name = ?, port = ?, service_type = ?, direction = ?, name = ?, port = ?, service_type = ?,
multicast = ?, multicast_group = ?, destination_ip = ?, multicast = ?, multicast_group = ?, destination_ip = ?,
enabled = ?, sort_order = ?, enabled = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, WHERE id = ?`,
c.Direction, c.Name, c.Port, c.ServiceType, c.Direction, c.Name, c.Port, c.ServiceType,
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID) mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
if err != nil { if err != nil {
return fmt.Errorf("update udp: %w", err) return fmt.Errorf("update udp: %w", err)
} }
+21 -14
View File
@@ -12,6 +12,8 @@ import (
"sync" "sync"
"time" "time"
"unicode" "unicode"
"hamlog/internal/db"
) )
// ErrNotFound is returned by providers when a callsign is unknown. // ErrNotFound is returned by providers when a callsign is unknown.
@@ -357,24 +359,29 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
return r, true return r, true
} }
// Put upserts a lookup result. // Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error { func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, ` updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
"source", "fetched_at",
}
// The lookup cache always lives in the local SQLite database, so SQLite
// upsert syntax is used unconditionally.
sets := make([]string, len(updateCols))
for i, c := range updateCols {
sets[i] = c + " = excluded." + c
}
q := `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty, INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon, country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, image_url, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
source, fetched_at) source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?, VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
ON CONFLICT(callsign) DO UPDATE SET _, err := c.db.ExecContext(ctx, q,
name = excluded.name, qth = excluded.qth, address = excluded.address,
state = excluded.state, cnty = excluded.cnty,
country = excluded.country, grid = excluded.grid,
lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
image_url = excluded.image_url,
source = excluded.source, fetched_at = excluded.fetched_at`,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address), r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County), nullable(r.State), nullable(r.County),
nullable(r.Country), nullable(r.Grid), nullable(r.Country), nullable(r.Grid),
@@ -382,7 +389,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ), nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia), nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
nullable(r.ImageURL), nullable(r.ImageURL),
r.Source, r.Source, db.NowISO(),
) )
return err return err
} }
+59 -4
View File
@@ -8,6 +8,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"hamlog/internal/db"
) )
// Station is a radio / TRX line. The display Name is also what gets // Station is a radio / TRX line. The display Name is also what gets
@@ -56,6 +58,59 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// CopyProfile duplicates the entire stations→antennas→bands tree from one
// profile to another (used when a profile is duplicated). New ids are assigned;
// the band defaults and ordering are preserved.
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
tree, err := r.ListTree(ctx, srcProfileID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := db.NowISO()
for _, st := range tree {
var pwr any
if st.TXPower != nil {
pwr = *st.TXPower
}
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy station: %w", err)
}
newStationID, _ := res.LastInsertId()
for _, ant := range st.Antennas {
ares, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy antenna: %w", err)
}
newAntID, _ := ares.LastInsertId()
for _, b := range ant.Bands {
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
return fmt.Errorf("copy band: %w", err)
}
}
}
}
return tx.Commit()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// ListTree returns every station for the profile with its nested antennas // ListTree returns every station for the profile with its nested antennas
// and bands. One round-trip per level — three queries total regardless of // and bands. One round-trip per level — three queries total regardless of
// tree size, so the Settings panel stays snappy on big setups. // tree size, so the Settings panel stays snappy on big setups.
@@ -190,8 +245,8 @@ func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE operating_stations `UPDATE operating_stations
SET name = ?, tx_pwr = ?, sort_order = ?, SET name = ?, tx_pwr = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID) WHERE id = ?`, s.Name, pwr, s.SortOrder, db.NowISO(), s.ID)
if err != nil { if err != nil {
return fmt.Errorf("update station: %w", err) return fmt.Errorf("update station: %w", err)
} }
@@ -230,8 +285,8 @@ func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE operating_antennas `UPDATE operating_antennas
SET name = ?, sort_order = ?, SET name = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') updated_at = ?
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil { WHERE id = ?`, a.Name, a.SortOrder, db.NowISO(), a.ID); err != nil {
return fmt.Errorf("update antenna: %w", err) return fmt.Errorf("update antenna: %w", err)
} }
} }
+41 -2
View File
@@ -11,10 +11,23 @@ package profile
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
) )
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
// the local SQLite file; "mysql" = the shared MySQL server described by the
// rest. Switching the active profile switches the live logbook to this.
type ProfileDB struct {
Backend string `json:"backend"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// Profile is one operating configuration. A user typically keeps a few: // Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"… // "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct { type Profile struct {
@@ -43,6 +56,7 @@ type Profile struct {
TxPower *float64 `json:"tx_pwr,omitempty"` TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -56,7 +70,7 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty, const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr, my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at` is_active, sort_order, db_config, created_at, updated_at`
// List returns every profile, active first then by sort_order/id. // List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) { func (r *Repo) List(ctx context.Context) ([]Profile, error) {
@@ -136,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
return err return err
} }
// SetDB updates only a profile's logbook database target, leaving the rest of
// the profile untouched (the general Save deliberately doesn't write db_config).
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
_, err = r.db.ExecContext(ctx,
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
string(b), now, id)
return err
}
// SetActive atomically switches the active profile. Clears the flag on all // SetActive atomically switches the active profile. Clears the flag on all
// rows first to keep the "only one active" invariant from the schema doc. // rows first to keep the "only one active" invariant from the schema doc.
func (r *Repo) SetActive(ctx context.Context, id int64) error { func (r *Repo) SetActive(ctx context.Context, id int64) error {
@@ -202,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err != nil { if err != nil {
return Profile{}, err return Profile{}, err
} }
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
src.ID = 0 src.ID = 0
src.Name = newName src.Name = newName
src.IsActive = false src.IsActive = false
@@ -209,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err := r.Save(ctx, &src); err != nil { if err := r.Save(ctx, &src); err != nil {
return Profile{}, err return Profile{}, err
} }
if dbCfg.Backend != "" {
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
return Profile{}, err
}
src.DB = dbCfg
}
return src, nil return src, nil
} }
@@ -227,15 +262,19 @@ func scan(row scannable) (Profile, error) {
myDXCC, myCQZ, myITUZ sql.NullInt64 myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64 myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int isActive, sortOrder int
dbConfig sql.NullString
createdAt, updatedAt string createdAt, updatedAt string
) )
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty, err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, &myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr, &myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &createdAt, &updatedAt) &isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil { if err != nil {
return p, err return p, err
} }
if s := dbConfig.String; s != "" {
_ = json.Unmarshal([]byte(s), &p.DB)
}
p.Callsign = callsign.String p.Callsign = callsign.String
p.Operator = operator.String p.Operator = operator.String
p.OpName = opName.String p.OpName = opName.String
+14 -4
View File
@@ -255,7 +255,7 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
cool: sorted[0].Warmth < 0.02, cool: sorted[0].Warmth < 0.02,
} }
if len(sorted) > 1 { if len(sorted) > 1 {
plan.inserts = sorted[1:min(len(sorted), 3)] // hero + up to 2 inserts plan.inserts = sorted[1:min(len(sorted), 5)] // hero + up to 4 inserts
} }
// Only side-column inserts (or none): a bottom strip collides with the QSO // Only side-column inserts (or none): a bottom strip collides with the QSO
@@ -671,9 +671,19 @@ func placeInserts(inserts []PhotoAnalysis, hero PhotoAnalysis, archetype string)
return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin} return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
} }
// Side column: pick the calmer half of the hero photo. // Side column: pick the calmer half of the hero photo. The insert width
n := min(len(inserts), 3) // shrinks as needed so up to 4 framed photos stack down the side.
w, gap, margin := 400.0, 20.0, 70.0 n := min(len(inserts), 4)
gap, margin := 20.0, 70.0
availH := float64(cardH) - 2*margin - float64(n-1)*gap
var arSum float64
for i := 0; i < n; i++ {
arSum += float64(inserts[i].H) / float64(inserts[i].W)
}
w := 400.0
if arSum > 0 {
w = clamp(availH/arSum, 240, 400) // fit all n vertically, within sane bounds
}
left := halfDetail(hero, true) < halfDetail(hero, false) left := halfDetail(hero, true) < halfDetail(hero, false)
x := cardW - w - margin x := cardW - w - margin
if left { if left {
+2
View File
@@ -106,6 +106,8 @@ type FxParams struct {
Grunge *float64 `json:"grunge,omitempty"` Grunge *float64 `json:"grunge,omitempty"`
Bevel *float64 `json:"bevel,omitempty"` Bevel *float64 `json:"bevel,omitempty"`
Seed *float64 `json:"seed,omitempty"` Seed *float64 `json:"seed,omitempty"`
Dark string `json:"dark,omitempty"` // dark inter-letter edge (per colour palette)
Outer string `json:"outer,omitempty"` // silver/rim colour (glossy)
} }
// setKeys lists the JSON names of the params that are actually set, for // setKeys lists the JSON names of the params that are actually set, for
+213 -37
View File
@@ -10,6 +10,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"hamlog/internal/db"
) )
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src // MergeNonZero copies every non-zero field of src onto dst. Zero-value src
@@ -255,6 +257,19 @@ var columnCount = countColumns(columnList)
// insertPlaceholders returns "?,?,?,..." matching columnCount. // insertPlaceholders returns "?,?,?,..." matching columnCount.
var insertPlaceholders = buildInsertPlaceholders() var insertPlaceholders = buildInsertPlaceholders()
// insertCols/insertVals append created_at + updated_at so they're set from Go
// (NowISO) on every backend — MySQL has no strftime default, and binding the
// timestamp keeps a single backend-agnostic INSERT. insertArgs pairs with them.
const insertCols = columnList + `, created_at, updated_at`
var insertVals = insertPlaceholders + ",?,?"
// insertArgs returns the column values plus the two timestamps for an INSERT.
func (q *QSO) insertArgs() []any {
now := db.NowISO()
return append(q.args(), now, now)
}
func countColumns(s string) int { func countColumns(s string) int {
n := 1 n := 1
for i := 0; i < len(s); i++ { for i := 0; i < len(s); i++ {
@@ -349,34 +364,30 @@ func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
res, err := r.db.ExecContext(ctx, res, err := r.db.ExecContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`, `INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`,
q.args()...) q.insertArgs()...)
if err != nil { if err != nil {
return 0, fmt.Errorf("insert qso: %w", err) return 0, fmt.Errorf("insert qso: %w", err)
} }
return res.LastInsertId() return res.LastInsertId()
} }
// AddBatch inserts many QSOs inside a single transaction using a prepared // batchInsertRows is how many QSOs go into one multi-row INSERT on MySQL. Each
// statement. Empty-callsign records are skipped. Returns rows inserted. // row carries ~135 columns, so 200 rows ≈ 27k bound parameters — well under
// MySQL's 65535-placeholder limit and a modest packet — while cutting network
// round-trips ~200× versus one INSERT per row (critical for a remote server).
const batchInsertRows = 200
// AddBatch inserts many QSOs inside a single transaction. Empty-callsign records
// are skipped. On MySQL it uses chunked multi-row INSERTs so a 27k-record import
// over a remote link takes seconds, not many minutes; on local SQLite a prepared
// statement per row is already fast.
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) { func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
if len(qsos) == 0 { if len(qsos) == 0 {
return 0, nil return 0, nil
} }
tx, err := r.db.BeginTx(ctx, nil) // Normalise and drop empty-callsign records up front.
if err != nil { rows := make([]QSO, 0, len(qsos))
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`)
if err != nil {
return 0, fmt.Errorf("prepare batch insert: %w", err)
}
defer stmt.Close()
var inserted int64
for _, q := range qsos { for _, q := range qsos {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign)) q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
if q.Callsign == "" { if q.Callsign == "" {
@@ -385,10 +396,57 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
if q.QSODate.IsZero() { if q.QSODate.IsZero() {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
if _, err := stmt.ExecContext(ctx, q.args()...); err != nil { rows = append(rows, q)
return inserted, fmt.Errorf("insert qso %q: %w", q.Callsign, err) }
if len(rows) == 0 {
return 0, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var inserted int64
if db.IsMySQL() {
rowPlaceholder := "(" + insertVals + ")"
for start := 0; start < len(rows); start += batchInsertRows {
end := start + batchInsertRows
if end > len(rows) {
end = len(rows)
}
chunk := rows[start:end]
var sb strings.Builder
sb.WriteString("INSERT INTO qso (")
sb.WriteString(insertCols)
sb.WriteString(") VALUES ")
args := make([]any, 0, len(chunk)*(columnCount+2))
for j := range chunk {
if j > 0 {
sb.WriteByte(',')
}
sb.WriteString(rowPlaceholder)
args = append(args, chunk[j].insertArgs()...)
}
if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
return inserted, fmt.Errorf("batch insert: %w", err)
}
inserted += int64(len(chunk))
}
} else {
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`)
if err != nil {
return 0, fmt.Errorf("prepare batch insert: %w", err)
}
defer stmt.Close()
for i := range rows {
if _, err := stmt.ExecContext(ctx, rows[i].insertArgs()...); err != nil {
return inserted, fmt.Errorf("insert qso %q: %w", rows[i].Callsign, err)
}
inserted++
} }
inserted++
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit batch: %w", err) return 0, fmt.Errorf("commit batch: %w", err)
@@ -396,6 +454,19 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
return inserted, nil return inserted, nil
} }
// Revision returns a cheap fingerprint of the logbook — row count and the
// highest id — so a client can poll for changes made by OTHER instances on a
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
// deletes change the count.
func (r *Repo) Revision(ctx context.Context) (string, error) {
var count, maxID int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", count, maxID), nil
}
// GetByID fetches a single QSO by primary key. // GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) { func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
@@ -455,8 +526,8 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?, `UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark qrz uploaded %d: %w", id, err) return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
} }
@@ -468,8 +539,8 @@ func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?, `UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark clublog uploaded %d: %w", id, err) return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
} }
@@ -481,8 +552,8 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?, `UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark lotw uploaded %d: %w", id, err) return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
} }
@@ -494,8 +565,8 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error { func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?, `UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark eqsl sent %d: %w", id, err) return fmt.Errorf("mark eqsl sent %d: %w", id, err)
} }
@@ -515,9 +586,9 @@ func (r *Repo) Update(ctx context.Context, q QSO) error {
q.QSODate = time.Now().UTC() q.QSODate = time.Now().UTC()
} }
setClause := buildUpdateSetClause() setClause := buildUpdateSetClause()
args := append(q.args(), q.ID) args := append(q.args(), db.NowISO(), q.ID)
res, err := r.db.ExecContext(ctx, res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+setClause+`, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, `UPDATE qso SET `+setClause+`, updated_at = ? WHERE id = ?`,
args...) args...)
if err != nil { if err != nil {
return fmt.Errorf("update qso %d: %w", q.ID, err) return fmt.Errorf("update qso %d: %w", q.ID, err)
@@ -714,6 +785,11 @@ func columnExpr(field string) (string, bool) {
return f, true return f, true
} }
if key, ok := filterableExtras[f]; ok { if key, ok := filterableExtras[f]; ok {
if db.IsMySQL() {
// JSON_EXTRACT errors on an invalid/empty document, so guard with
// NULLIF; JSON_UNQUOTE strips the quotes MySQL adds around strings.
return "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + key + "'))", true
}
return "json_extract(extras_json, '$." + key + "')", true return "json_extract(extras_json, '$." + key + "')", true
} }
return "", false return "", false
@@ -1245,6 +1321,106 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
return rows.Err() return rows.Err()
} }
// awardCols is the column set the award engine can read — every field reachable
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
// This drops ~125 unused columns so award computation over a remote MySQL
// backend ships a fraction of each row instead of the whole record.
//
// IMPORTANT: when a new award references a QSO field not listed here, add the
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
// IterateForAwards streams a lightweight projection of every QSO — only the
// fields award computation reads (see awardCols). All other QSO fields are left
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
// IterateAll for deterministic results.
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
rows, err := r.db.QueryContext(ctx,
`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return fmt.Errorf("query qso (awards): %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanAwardQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
// the award-relevant fields. Column order MUST match awardCols.
func scanAwardQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
comment, notes sql.NullString
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
extrasJSON sql.NullString
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
}
q.QSODate = parseTimeLoose(qsoDateStr)
if freqHz.Valid {
v := freqHz.Int64
q.FreqHz = &v
}
q.Grid = grid.String
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
q.CQZ = &v
}
if ituz.Valid {
v := int(ituz.Int64)
q.ITUZ = &v
}
if dxcc.Valid {
v := int(dxcc.Int64)
q.DXCC = &v
}
q.IOTA = iotaRef.String
q.SOTARef = sota.String
q.POTARef = pota.String
q.Name = name.String
q.QTH = qth.String
q.Address = address.String
q.Comment = comment.String
q.Notes = notes.String
q.QSLRcvd = qslRcvd.String
q.LOTWRcvd = lotwRcvd.String
q.EQSLRcvd = eqslRcvd.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}
// EntitySlot bundles every (band, mode) tuple ever worked for a given // EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide // DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query. // NEW / NEW SLOT / WORKED in constant time after one batched query.
@@ -1348,7 +1524,7 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
// far cheaper than N exists-queries during the import loop. // far cheaper than N exists-queries during the import loop.
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) { func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode SELECT callsign, substr(qso_date, 1, 16), band, mode
FROM qso`) FROM qso`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1376,7 +1552,7 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
// confirmations back to local QSOs. // confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`) FROM qso`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1463,8 +1639,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?, `UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark qrz confirmed %d: %w", id, err) return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
} }
@@ -1476,8 +1652,8 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?, `UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, updated_at = ? WHERE id = ?`,
date, id) date, db.NowISO(), id)
if err != nil { if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err) return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
} }
+183 -41
View File
@@ -11,8 +11,10 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"sync" "sync"
"hamlog/internal/db"
"hamlog/internal/secret" "hamlog/internal/secret"
) )
@@ -27,10 +29,111 @@ type Store struct {
mu sync.RWMutex mu sync.RWMutex
cipher Cipher // non-nil when secrets are unlocked cipher Cipher // non-nil when secrets are unlocked
sensitive func(key string) bool // which keys are encrypted at rest sensitive func(key string) bool // which keys are encrypted at rest
// prefix scopes every key to the active profile (e.g. "p3."), so each
// station profile keeps its own complete set of settings. Empty = unscoped
// (used briefly at startup before the active profile is known).
prefix string
// cache holds every setting's RAW (as-stored) value, loaded once. Reads are
// served from memory so the Preferences dialog (dozens of keys) doesn't pay
// a network round-trip per key against a remote MySQL. Decryption still
// happens on read, so a later Unlock takes effect without reloading.
cache map[string]string
cached bool
} }
func NewStore(db *sql.DB) *Store { return &Store{db: db} } func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// CopyProfile duplicates every setting from one profile to another (used when
// a profile is duplicated), preserving raw/encrypted values verbatim.
func (s *Store) CopyProfile(ctx context.Context, srcID, dstID int64) error {
if err := s.ensureCache(ctx); err != nil {
return err
}
srcPrefix := fmt.Sprintf("p%d.", srcID)
dstPrefix := fmt.Sprintf("p%d.", dstID)
s.mu.RLock()
pairs := make(map[string]string)
for k, v := range s.cache {
if strings.HasPrefix(k, srcPrefix) {
pairs[dstPrefix+strings.TrimPrefix(k, srcPrefix)] = v
}
}
s.mu.RUnlock()
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
for fullKey, val := range pairs {
if _, err := s.db.ExecContext(ctx, q, fullKey, val, db.NowISO()); err != nil {
return err
}
s.cachePut(fullKey, val)
}
return nil
}
// SetProfile scopes all subsequent reads/writes to the given profile id, so
// each profile has its own settings. Called at startup and whenever the active
// profile changes.
func (s *Store) SetProfile(id int64) {
s.mu.Lock()
s.prefix = fmt.Sprintf("p%d.", id)
s.mu.Unlock()
}
// profileKey returns the storage key for the active profile.
func (s *Store) profileKey(key string) string {
s.mu.RLock()
p := s.prefix
s.mu.RUnlock()
return p + key
}
// ensureCache lazily loads all settings into memory on first read. A concurrent
// double-load is harmless (the result is identical), so it's done without a
// long-held lock.
func (s *Store) ensureCache(ctx context.Context) error {
s.mu.RLock()
ok := s.cached
s.mu.RUnlock()
if ok {
return nil
}
rows, err := s.db.QueryContext(ctx, "SELECT `key`, value FROM settings")
if err != nil {
return err
}
defer rows.Close()
m := make(map[string]string, 256)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return err
}
m[k] = v
}
if err := rows.Err(); err != nil {
return err
}
s.mu.Lock()
if !s.cached {
s.cache = m
s.cached = true
}
s.mu.Unlock()
return nil
}
// cachePut updates the in-memory copy after a write so reads stay coherent.
func (s *Store) cachePut(key, raw string) {
s.mu.Lock()
if s.cache == nil {
s.cache = map[string]string{}
}
s.cache[key] = raw
s.mu.Unlock()
}
// SetSensitivePredicate registers which keys hold secrets. Set once at startup. // SetSensitivePredicate registers which keys hold secrets. Set once at startup.
func (s *Store) SetSensitivePredicate(fn func(key string) bool) { func (s *Store) SetSensitivePredicate(fn func(key string) bool) {
s.mu.Lock() s.mu.Lock()
@@ -101,39 +204,38 @@ func (s *Store) encodeWrite(key, val string) string {
// Get returns the value for key, or "" if not set. // Get returns the value for key, or "" if not set.
func (s *Store) Get(ctx context.Context, key string) (string, error) { func (s *Store) Get(ctx context.Context, key string) (string, error) {
var v string raw, err := s.GetRaw(ctx, key)
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil { if err != nil {
return "", err return "", err
} }
return s.decodeRead(key, v), nil return s.decodeRead(key, raw), nil
} }
// GetRaw returns the stored value WITHOUT decryption — used by the passphrase // GetRaw returns the stored value WITHOUT decryption — used by the passphrase
// migration which must read/re-write the raw ciphertext or plaintext. // migration which must read/re-write the raw ciphertext or plaintext.
func (s *Store) GetRaw(ctx context.Context, key string) (string, error) { func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
var v string if err := s.ensureCache(ctx); err != nil {
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) return "", err
if err == sql.ErrNoRows {
return "", nil
} }
return v, err full := s.profileKey(key)
s.mu.RLock()
v := s.cache[full] // "" when absent
s.mu.RUnlock()
return v, nil
} }
// SetRaw stores a value verbatim (no encryption) — used by the migration. // SetRaw stores a value verbatim (no encryption) — used by the migration.
// The settings table always lives in the local SQLite database (config is
// per-operator, never on the shared MySQL logbook), so SQLite syntax is used
// unconditionally. The backticks around `key` are accepted by SQLite too.
func (s *Store) SetRaw(ctx context.Context, key, value string) error { func (s *Store) SetRaw(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, ` full := s.profileKey(key)
INSERT INTO settings(key, value) VALUES(?, ?) q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
ON CONFLICT(key) DO UPDATE SET "ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
value = excluded.value, if _, err := s.db.ExecContext(ctx, q, full, value, db.NowISO()); err != nil {
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
key, value)
if err != nil {
return fmt.Errorf("set %s: %w", key, err) return fmt.Errorf("set %s: %w", key, err)
} }
s.cachePut(full, value)
return nil return nil
} }
@@ -142,33 +244,73 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
return s.SetRaw(ctx, key, s.encodeWrite(key, value)) return s.SetRaw(ctx, key, s.encodeWrite(key, value))
} }
// All returns every stored setting (sensitive values decrypted when unlocked). // GetGlobal reads a value stored WITHOUT the profile prefix — for settings that
func (s *Store) All(ctx context.Context) (map[string]string, error) { // are shared across every profile (e.g. award definitions, which are the
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`) // operator's own work and shouldn't be re-created per profile). Sensitive
if err != nil { // decryption still applies.
return nil, err func (s *Store) GetGlobal(ctx context.Context, key string) (string, error) {
if err := s.ensureCache(ctx); err != nil {
return "", err
} }
defer rows.Close() s.mu.RLock()
out := map[string]string{} v := s.cache[key]
for rows.Next() { s.mu.RUnlock()
var k, v string return s.decodeRead(key, v), nil
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = s.decodeRead(k, v)
}
return out, rows.Err()
} }
// GetMany fetches several keys in a single round-trip. // SetGlobal upserts a value WITHOUT the profile prefix (shared across profiles).
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) { func (s *Store) SetGlobal(ctx context.Context, key, value string) error {
out := make(map[string]string, len(keys)) value = s.encodeWrite(key, value)
for _, k := range keys { q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
v, err := s.Get(ctx, k) "ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
if err != nil { if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
return nil, err return fmt.Errorf("set global %s: %w", key, err)
}
s.cachePut(key, value)
return nil
}
// All returns every stored setting (sensitive values decrypted when unlocked).
func (s *Store) All(ctx context.Context) (map[string]string, error) {
if err := s.ensureCache(ctx); err != nil {
return nil, err
}
prefix := s.profileKey("")
s.mu.RLock()
raw := make(map[string]string, len(s.cache))
for k, v := range s.cache {
if strings.HasPrefix(k, prefix) {
raw[strings.TrimPrefix(k, prefix)] = v
} }
out[k] = v }
s.mu.RUnlock()
out := make(map[string]string, len(raw))
for k, v := range raw {
out[k] = s.decodeRead(k, v)
}
return out, nil
}
// GetMany fetches several keys, all served from the in-memory cache (one DB
// round-trip total, on first access). Every requested key is present in the
// result (absent settings map to "").
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
out := make(map[string]string, len(keys))
if len(keys) == 0 {
return out, nil
}
if err := s.ensureCache(ctx); err != nil {
return nil, err
}
prefix := s.profileKey("")
s.mu.RLock()
raw := make([]string, len(keys))
for i, k := range keys {
raw[i] = s.cache[prefix+k]
}
s.mu.RUnlock()
for i, k := range keys {
out[k] = s.decodeRead(k, raw[i])
} }
return out, nil return out, nil
} }
+30 -5
View File
@@ -5,6 +5,7 @@ package ultrabeam
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -12,6 +13,17 @@ import (
"time" "time"
) )
// Connection tuning. Remote operation (the antenna controller reached over the
// internet, not the LAN) sees real latency and jitter, so the read timeout is
// generous and a few transient timeouts are tolerated before the link is torn
// down — otherwise a single slow reply dropped the whole connection and the
// client churned reconnect/disconnect.
const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
)
// Protocol constants // Protocol constants
const ( const (
STX byte = 0xF5 // 245 decimal STX byte = 0xF5 // 245 decimal
@@ -110,6 +122,7 @@ func (c *Client) pollLoop() {
defer ticker.Stop() defer ticker.Stop()
pollCount := 0 pollCount := 0
pollFails := 0 // consecutive failed status polls (transient timeouts tolerated)
for { for {
select { select {
@@ -120,7 +133,8 @@ func (c *Client) pollLoop() {
c.connMu.Lock() c.connMu.Lock()
if c.conn == nil { if c.conn == nil {
log.Printf("Ultrabeam: Not connected, attempting connection...") log.Printf("Ultrabeam: Not connected, attempting connection...")
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
if err != nil { if err != nil {
log.Printf("Ultrabeam: Connection failed: %v", err) log.Printf("Ultrabeam: Connection failed: %v", err)
c.connMu.Unlock() c.connMu.Unlock()
@@ -133,6 +147,7 @@ func (c *Client) pollLoop() {
} }
c.conn = conn c.conn = conn
c.reader = bufio.NewReader(c.conn) c.reader = bufio.NewReader(c.conn)
pollFails = 0
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port) log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
} }
c.connMu.Unlock() c.connMu.Unlock()
@@ -140,8 +155,17 @@ func (c *Client) pollLoop() {
// Query status // Query status
status, err := c.queryStatus() status, err := c.queryStatus()
if err != nil { if err != nil {
log.Printf("Ultrabeam: Failed to query status: %v", err) // A single slow/lost reply over a remote link is normal — keep
// Close connection and retry // the connection (and the last status) for a few tries before
// tearing it down, so we don't churn reconnect/disconnect.
var ne net.Error
transient := errors.As(err, &ne) && ne.Timeout()
pollFails++
if transient && pollFails < ubMaxPollTimeout {
log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
continue
}
log.Printf("Ultrabeam: Failed to query status, reconnecting: %v", err)
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -156,6 +180,7 @@ func (c *Client) pollLoop() {
c.statusMu.Unlock() c.statusMu.Unlock()
continue continue
} }
pollFails = 0
// Mark as connected // Mark as connected
status.Connected = true status.Connected = true
@@ -318,8 +343,8 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("failed to write: %w", err) return nil, fmt.Errorf("failed to write: %w", err)
} }
// Read reply with timeout // Read reply with timeout (generous — tolerates remote-link latency).
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
// Read until we get a complete packet // Read until we get a complete packet
var buffer []byte var buffer []byte
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"hamlog/internal/applog"
)
// Anonymous usage telemetry - a once-a-day "app_opened" heartbeat to PostHog so
// the OpsLog author can see how many people actively use it. Privacy by design:
// only a random install ID + app version + OS are sent (no callsign, no QSO
// data, no IP beyond what any HTTP request reveals). Users can disable it in
// Preferences -> General. See [[user-analytics-posthog]] notes in MEMORY.
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.11.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
posthogHost = "https://eu.i.posthog.com"
// posthogAPIKey is the PostHog PROJECT API key ("phc_..."). It's a public,
// write-only ingestion key (safe to ship in the binary, like the ClubLog app
// key). Until it's filled in, telemetry is a no-op.
posthogAPIKey = "phc_vumvN7XTERNhmRzMZHNgY5DncZfFibTbomiE9epZvUJ4"
keyTelemetryEnabled = "telemetry.enabled" // "0" disables; default on
keyTelemetryInstallID = "telemetry.install_id" // random, stable per install
keyTelemetryLastSent = "telemetry.last_sent" // YYYY-MM-DD of last heartbeat
)
// GetTelemetryEnabled reports whether anonymous usage stats are on (default on).
func (a *App) GetTelemetryEnabled() bool {
if a.settings == nil {
return true
}
v, _ := a.settings.GetGlobal(a.ctx, keyTelemetryEnabled)
return strings.TrimSpace(v) != "0"
}
// SetTelemetryEnabled turns anonymous usage stats on or off.
func (a *App) SetTelemetryEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
val := "1"
if !on {
val = "0"
}
return a.settings.SetGlobal(a.ctx, keyTelemetryEnabled, val)
}
// telemetryInstallID returns this install's stable anonymous ID, generating and
// persisting one on first use.
func (a *App) telemetryInstallID() string {
id, _ := a.settings.GetGlobal(a.ctx, keyTelemetryInstallID)
if id = strings.TrimSpace(id); id != "" {
return id
}
id = randomUUID()
_ = a.settings.SetGlobal(a.ctx, keyTelemetryInstallID, id)
return id
}
// sendTelemetryHeartbeat sends at most one "app_opened" event per calendar day
// (UTC). Best effort: any failure is logged and ignored. Runs in a goroutine at
// startup.
func (a *App) sendTelemetryHeartbeat() {
if a.settings == nil || !a.GetTelemetryEnabled() {
return
}
if strings.Contains(posthogAPIKey, "REPLACE") || strings.TrimSpace(posthogAPIKey) == "" {
return // not configured yet
}
today := time.Now().UTC().Format("2006-01-02")
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
return // already counted today
}
payload := map[string]any{
"api_key": posthogAPIKey,
"event": "app_opened",
"distinct_id": a.telemetryInstallID(),
"properties": map[string]any{
"version": appVersion,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"$lib": "opslog",
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
body, err := json.Marshal(payload)
if err != nil {
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(posthogHost+"/capture/", "application/json", bytes.NewReader(body))
if err != nil {
applog.Printf("telemetry: heartbeat failed: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = a.settings.SetGlobal(a.ctx, keyTelemetryLastSent, today)
applog.Printf("telemetry: heartbeat ok (%s)", today)
} else {
applog.Printf("telemetry: heartbeat HTTP %d", resp.StatusCode)
}
}
// randomUUID returns a random RFC-4122 v4 UUID string.
func randomUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Extremely unlikely; fall back to a time-based id so we still get one.
return fmt.Sprintf("ts-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"hamlog/internal/applog"
)
// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
// UpdateInfo is the result of the startup version check.
type UpdateInfo struct {
Current string `json:"current"` // this build's version (appVersion)
Latest string `json:"latest"` // newest published release, "" if unknown
Available bool `json:"available"` // Latest > Current
URL string `json:"url"` // release page to open
}
// CheckForUpdate asks GitHub for the latest release and compares it to this
// build. Best effort — on any failure it reports "no update" so the app never
// nags about a check it couldn't complete.
func (a *App) CheckForUpdate() UpdateInfo {
out := UpdateInfo{Current: appVersion}
client := &http.Client{Timeout: 8 * time.Second}
req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil)
if err != nil {
return out
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
applog.Printf("update: check failed: %v", err)
return out
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out // no release yet (404) or rate-limited — treat as up to date
}
var r struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return out
}
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
out.URL = r.HTMLURL
out.Available = versionLess(appVersion, out.Latest)
if out.Available {
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
}
return out
}
// versionLess reports whether version a is older than b. Compares dot-separated
// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0.
func versionLess(a, b string) bool {
pa := strings.Split(a, ".")
pb := strings.Split(b, ".")
n := len(pa)
if len(pb) > n {
n = len(pb)
}
for i := 0; i < n; i++ {
ai, bi := 0, 0
if i < len(pa) {
ai = leadingInt(pa[i])
}
if i < len(pb) {
bi = leadingInt(pb[i])
}
if ai != bi {
return ai < bi
}
}
return false
}
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
func leadingInt(s string) int {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0
}
n, _ := strconv.Atoi(s[:end])
return n
}