From 81e505e040bac05222e8dd4cc52adda1fafaa60d Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 13 Jun 2026 19:14:24 +0200 Subject: [PATCH] up --- .claude/settings.json | 3 +- app.go | 39 +++++++++++++------ app_qsl_designer.go | 25 ++++++------ frontend/src/App.tsx | 2 +- frontend/src/components/BandSlotGrid.tsx | 19 +++++++++ frontend/src/components/QSOContextMenu.tsx | 2 +- frontend/src/components/RecentQSOsGrid.tsx | 6 ++- frontend/src/components/SettingsModal.tsx | 14 ++++--- frontend/src/components/qsl/AutoEQSL.tsx | 23 ++++++++--- frontend/src/components/qsl/CardPreview.tsx | 9 +++-- .../src/components/qsl/QslDesignerModal.tsx | 4 +- frontend/src/components/qsl/SendEQSLModal.tsx | 4 +- .../src/components/qsl/StylePresetPicker.tsx | 38 ++++++++++++++++++ frontend/src/components/qsl/qslTypes.ts | 2 + frontend/wailsjs/go/models.ts | 6 +++ internal/audio/recorder.go | 28 ++++++++++--- internal/email/email.go | 6 +++ internal/qslcard/placement.go | 18 +++++++-- internal/qslcard/template.go | 2 + 19 files changed, 194 insertions(+), 56 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index c714a6a..363b454 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,8 @@ "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/**)" + "Read(//c/Perso/Seafile/Programmation/Golang/**)", + "Bash(gofmt -w internal/qslcard/*.go)" ] } } diff --git a/app.go b/app.go index 644538b..977e5a5 100644 --- a/app.go +++ b/app.go @@ -110,6 +110,7 @@ const ( keyEmailUser = "email.smtp_user" keyEmailPassword = "email.smtp_password" keyEmailFrom = "email.from" + keyEmailReplyTo = "email.reply_to" // optional Reply-To: replies go here, not the From sender keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none" 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 @@ -3438,13 +3439,19 @@ func (a *App) saveQSORecording(q *qso.QSO) { parts = append(parts, time.Now().UTC().Format("20060102_150405")) name := strings.Join(parts, "_") + "." + ext 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 } - 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.Extras == nil { 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. - if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" { - qc := *q - go func() { _ = a.sendRecordingEmail(qc, path) }() - } + qc := *q + go func() { + if err := audio.WritePCM(path, pcm); err != nil { + 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.). @@ -3527,6 +3541,7 @@ type EmailSettings struct { User string `json:"smtp_user"` Password string `json:"smtp_password"` From string `json:"from"` + ReplyTo string `json:"reply_to"` // optional — where correspondents' replies go Encryption string `json:"encryption"` // "ssl" | "starttls" | "none" Auth bool `json:"auth"` // SMTP requires authorization AutoSend bool `json:"auto_send"` @@ -3542,7 +3557,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) { } m, err := a.settings.GetMany(a.ctx, keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword, - keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody) + keyEmailFrom, keyEmailReplyTo, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody) if err != nil { return out, err } @@ -3554,6 +3569,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) { out.User = m[keyEmailUser] out.Password = m[keyEmailPassword] out.From = m[keyEmailFrom] + out.ReplyTo = m[keyEmailReplyTo] if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" { out.Encryption = e } @@ -3593,6 +3609,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error { keyEmailUser: strings.TrimSpace(s.User), keyEmailPassword: s.Password, keyEmailFrom: strings.TrimSpace(s.From), + keyEmailReplyTo: strings.TrimSpace(s.ReplyTo), keyEmailEncryption: enc, keyEmailAuth: b2s(s.Auth), keyEmailAutoSend: b2s(s.AutoSend), @@ -3607,7 +3624,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error { } 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 diff --git a/app_qsl_designer.go b/app_qsl_designer.go index bc995a0..c033dea 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -34,6 +34,10 @@ const ( 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 ( 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}" @@ -74,7 +78,7 @@ type QSLPresetInfo struct { // so picking through the native dialog is the reliable route to real paths). func (a *App) QSLPickPhotos() ([]string, error) { return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{ - Title: "Choose card photos (1–3)", + Title: "Choose card photos (1–5)", Filters: []wruntime.FileFilter{ {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 { return nil, fmt.Errorf("no photos selected") } - if len(photoPaths) > 3 { - return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths)) + if len(photoPaths) > 5 { + return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths)) } photos := make([]qslcard.PhotoAnalysis, 0, len(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) return err } - // Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of - // the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which - // an eQSL.cc upload may also set. q came straight from GetByID, so a full - // Update rewrites the row unchanged apart from these fields. - now := time.Now().UTC() - q.EQSLSent = "Y" - q.EQSLSentDate = now.Format("20060102") + // Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — + // NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay + // independent. q came straight from GetByID, so a full Update rewrites the + // row unchanged apart from this field. if q.Extras == nil { q.Extras = map[string]string{} } - q.Extras["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 { 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) @@ -502,7 +503,7 @@ func (a *App) maybeAutoSendEQSL(q qso.QSO) { if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" { return } - if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") { + if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" { return } p, err := a.profiles.Active(a.ctx) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59d6448..7891e24 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3201,7 +3201,7 @@ export default function App() { )} showToast(`eQSL sent to ${call}`)} + onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)} onError={(msg) => showToast(msg)} /> setQslDesignerOpen(false)} /> diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 227a15c..76b906a 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -101,6 +101,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal return m; }, [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 (
)} + {(newBand || newMode || newBandMode || newSlot) && ( +
+ {newBand && New Band} + {newMode && New Mode} + {!newBand && !newMode && newBandMode && New Band & Mode} + {newSlot && New Slot} +
+ )} ) : busy ? ( diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 3008d34..e46bbe5 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -90,7 +90,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ onClick={() => { onSendEQSL(menu.ids); onClose(); }} > - Send eQSL by e-mail + Send OpsLog QSL by e-mail )} {onSendRecording && ( diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 1643378..f273a3e 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -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: '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: '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: '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 (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: '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 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' }, @@ -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 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) }, + // 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) ── // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index edfb13a..8cb969f 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -189,7 +189,6 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'UDP integrations', id: 'udp' }, { 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). type EmailCfg = { 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({ 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 setEmailField = (patch: Partial) => setEmailCfg((s) => ({ ...s, ...patch })); @@ -3076,6 +3075,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setEmailField({ smtp_password: e.target.value })} /> setEmailField({ from: e.target.value })} /> + +
+ setEmailField({ reply_to: e.target.value })} /> +
Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.
+
) : ( diff --git a/frontend/src/components/qsl/StylePresetPicker.tsx b/frontend/src/components/qsl/StylePresetPicker.tsx index adbd285..f1ea669 100644 --- a/frontend/src/components/qsl/StylePresetPicker.tsx +++ b/frontend/src/components/qsl/StylePresetPicker.tsx @@ -16,6 +16,25 @@ interface Props { 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 }) { return (
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props) + {isFx && ( +
+ +
+ {(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => ( +
+
+ )} {has('color') && ( set({ color: v })} /> )} diff --git a/frontend/src/components/qsl/qslTypes.ts b/frontend/src/components/qsl/qslTypes.ts index 16e99d3..06a8542 100644 --- a/frontend/src/components/qsl/qslTypes.ts +++ b/frontend/src/components/qsl/qslTypes.ts @@ -70,6 +70,8 @@ export interface FxParams { grunge?: number; bevel?: number; seed?: number; + dark?: string; + outer?: string; } export interface StyleParams { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3ee7c0c..ed3c8cd 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -903,6 +903,7 @@ export namespace main { smtp_user: string; smtp_password: string; from: string; + reply_to: string; encryption: string; auth: boolean; auto_send: boolean; @@ -921,6 +922,7 @@ export namespace main { this.smtp_user = source["smtp_user"]; this.smtp_password = source["smtp_password"]; this.from = source["from"]; + this.reply_to = source["reply_to"]; this.encryption = source["encryption"]; this.auth = source["auth"]; this.auto_send = source["auto_send"]; @@ -1752,6 +1754,8 @@ export namespace qslcard { grunge?: number; bevel?: number; seed?: number; + dark?: string; + outer?: string; static createFrom(source: any = {}) { return new FxParams(source); @@ -1772,6 +1776,8 @@ export namespace qslcard { this.grunge = source["grunge"]; this.bevel = source["bevel"]; this.seed = source["seed"]; + this.dark = source["dark"]; + this.outer = source["outer"]; } } export class Halo { diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index bbdd1eb..06b3812 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -221,27 +221,43 @@ func (r *Recorder) RestartQSO() { r.active = true } -// SaveQSO writes the accumulated recording to path as a WAV and stops -// accumulating. Returns an error if no recording was active. -func (r *Recorder) SaveQSO(path string) error { +// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and +// stops accumulating — fast, no encoding. The next BeginQSO can safely start a +// 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() if !r.active { r.mu.Unlock() - return fmt.Errorf("no active recording") + return nil, fmt.Errorf("no active recording") } samples := r.acc r.acc, r.active = nil, false r.mu.Unlock() 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") { return writeMP3(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). func (r *Recorder) DiscardQSO() { r.mu.Lock() diff --git a/internal/email/email.go b/internal/email/email.go index 0291616..a214e7e 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -16,6 +16,7 @@ type Config struct { User string Password string From string + ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox) Encryption string // "ssl" | "starttls" | "none" 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 { 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.SetBodyString(mail.TypeTextPlain, body) if attachPath != "" { diff --git a/internal/qslcard/placement.go b/internal/qslcard/placement.go index a6dafb3..6e27326 100644 --- a/internal/qslcard/placement.go +++ b/internal/qslcard/placement.go @@ -255,7 +255,7 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T cool: sorted[0].Warmth < 0.02, } 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 @@ -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} } - // Side column: pick the calmer half of the hero photo. - n := min(len(inserts), 3) - w, gap, margin := 400.0, 20.0, 70.0 + // Side column: pick the calmer half of the hero photo. The insert width + // shrinks as needed so up to 4 framed photos stack down the side. + 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) x := cardW - w - margin if left { diff --git a/internal/qslcard/template.go b/internal/qslcard/template.go index b456a5d..17e13e1 100644 --- a/internal/qslcard/template.go +++ b/internal/qslcard/template.go @@ -106,6 +106,8 @@ type FxParams struct { Grunge *float64 `json:"grunge,omitempty"` Bevel *float64 `json:"bevel,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