This commit is contained in:
2026-06-13 19:14:24 +02:00
parent 0b3e22c97e
commit 81e505e040
19 changed files with 194 additions and 56 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"Bash(which git-credential-manager *)", "Bash(which git-credential-manager *)",
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)", "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)", "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/**)" "Read(//c/Perso/Seafile/Programmation/Golang/**)",
"Bash(gofmt -w internal/qslcard/*.go)"
] ]
} }
} }
+28 -11
View File
@@ -110,6 +110,7 @@ const (
keyEmailUser = "email.smtp_user" keyEmailUser = "email.smtp_user"
keyEmailPassword = "email.smtp_password" keyEmailPassword = "email.smtp_password"
keyEmailFrom = "email.from" keyEmailFrom = "email.from"
keyEmailReplyTo = "email.reply_to" // optional Reply-To: replies go here, not the From sender
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none" keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password) keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known
@@ -3438,13 +3439,19 @@ func (a *App) saveQSORecording(q *qso.QSO) {
parts = append(parts, time.Now().UTC().Format("20060102_150405")) parts = append(parts, time.Now().UTC().Format("20060102_150405"))
name := strings.Join(parts, "_") + "." + ext name := strings.Join(parts, "_") + "." + ext
path := filepath.Join(a.qsoRecDir(), name) path := filepath.Join(a.qsoRecDir(), name)
if err := a.qsoRec.SaveQSO(path); err != nil {
applog.Printf("qso-rec: save failed: %v", err) // Snapshot the audio synchronously (fast — frees the recorder for the next
// QSO). The slow part is encoding the file (a long MP3), which we defer to a
// goroutine so logging stays snappy.
pcm, err := a.qsoRec.TakeQSO()
if err != nil {
applog.Printf("qso-rec: snapshot failed: %v", err)
return return
} }
applog.Printf("qso-rec: saved %s", path)
// Remember the recording on the QSO so it can be e-mailed later. // Stamp the recording's path on the QSO now, synchronously, so it's set
// before the eQSL auto-send reads the QSO (their full-row Updates would
// otherwise race and clobber each other's extras).
if q.ID != 0 { if q.ID != 0 {
if q.Extras == nil { if q.Extras == nil {
q.Extras = map[string]string{} q.Extras = map[string]string{}
@@ -3455,11 +3462,18 @@ func (a *App) saveQSORecording(q *qso.QSO) {
} }
} }
// Auto-send to the correspondent when enabled and an e-mail is known. qc := *q
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" { go func() {
qc := *q if err := audio.WritePCM(path, pcm); err != nil {
go func() { _ = a.sendRecordingEmail(qc, path) }() applog.Printf("qso-rec: save failed: %v", err)
} return
}
applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists.
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" {
_ = a.sendRecordingEmail(qc, path)
}
}()
} }
// sanitizeFilename makes a callsign safe for a filename (slashes etc.). // sanitizeFilename makes a callsign safe for a filename (slashes etc.).
@@ -3527,6 +3541,7 @@ type EmailSettings struct {
User string `json:"smtp_user"` User string `json:"smtp_user"`
Password string `json:"smtp_password"` Password string `json:"smtp_password"`
From string `json:"from"` From string `json:"from"`
ReplyTo string `json:"reply_to"` // optional — where correspondents' replies go
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none" Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
Auth bool `json:"auth"` // SMTP requires authorization Auth bool `json:"auth"` // SMTP requires authorization
AutoSend bool `json:"auto_send"` AutoSend bool `json:"auto_send"`
@@ -3542,7 +3557,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) {
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword, keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody) keyEmailFrom, keyEmailReplyTo, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -3554,6 +3569,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) {
out.User = m[keyEmailUser] out.User = m[keyEmailUser]
out.Password = m[keyEmailPassword] out.Password = m[keyEmailPassword]
out.From = m[keyEmailFrom] out.From = m[keyEmailFrom]
out.ReplyTo = m[keyEmailReplyTo]
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" { if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
out.Encryption = e out.Encryption = e
} }
@@ -3593,6 +3609,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error {
keyEmailUser: strings.TrimSpace(s.User), keyEmailUser: strings.TrimSpace(s.User),
keyEmailPassword: s.Password, keyEmailPassword: s.Password,
keyEmailFrom: strings.TrimSpace(s.From), keyEmailFrom: strings.TrimSpace(s.From),
keyEmailReplyTo: strings.TrimSpace(s.ReplyTo),
keyEmailEncryption: enc, keyEmailEncryption: enc,
keyEmailAuth: b2s(s.Auth), keyEmailAuth: b2s(s.Auth),
keyEmailAutoSend: b2s(s.AutoSend), keyEmailAutoSend: b2s(s.AutoSend),
@@ -3607,7 +3624,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error {
} }
func (a *App) emailConfig(s EmailSettings) email.Config { func (a *App) emailConfig(s EmailSettings) email.Config {
return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth} return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, ReplyTo: s.ReplyTo, Encryption: s.Encryption, Auth: s.Auth}
} }
// TestEmail sends a test message to `to` (defaults to the From address) to // TestEmail sends a test message to `to` (defaults to the From address) to
+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)
+1 -1
View File
@@ -3201,7 +3201,7 @@ export default function App() {
)} )}
<AutoEQSL <AutoEQSL
onSent={(call) => showToast(`eQSL sent to ${call}`)} onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)} onError={(msg) => showToast(msg)}
/> />
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} /> <QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
+19
View File
@@ -101,6 +101,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return m; return m;
}, [wb]); }, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode));
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
const bandWorked = CLASSES.some((c) => statusMap.get(`${currentBand}|${c}`));
const modeWorked = !!curClass && cols.some((b) => statusMap.get(`${b.tag}|${curClass}`));
const newBand = hasDxcc && !newOne && !bandWorked;
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
// New slot for THIS call: worked the op before, but not on this band+mode.
const newSlot = !newOne && callCount > 0 && !!curClass && slotStatus !== 'call_c' && slotStatus !== 'call_w';
return ( return (
<section <section
className={cn( className={cn(
@@ -136,6 +147,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</> </>
)} )}
</span> </span>
{(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1">
{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>}
{!newBand && !newMode && newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; 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">
+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 && (
+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,
+9 -5
View File
@@ -189,7 +189,6 @@ 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 },
], ],
}, },
{ {
@@ -455,11 +454,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 }));
@@ -3076,6 +3075,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="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} /> <Input className="h-8" placeholder="you@example.com" 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 +3090,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 +3100,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.
+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 {
+6
View File
@@ -903,6 +903,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 +922,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"];
@@ -1752,6 +1754,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 +1776,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 {
+22 -6
View File
@@ -221,27 +221,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()
+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 != "" {
+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