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(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)"
]
}
}
+26 -9
View File
@@ -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) }()
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
+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
)
// 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 (13)",
Title: "Choose card photos (15)",
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)
+1 -1
View File
@@ -3201,7 +3201,7 @@ export default function App() {
)}
<AutoEQSL
onSent={(call) => showToast(`eQSL sent to ${call}`)}
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
/>
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
+19
View File
@@ -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 (
<section
className={cn(
@@ -136,6 +147,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</>
)}
</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 ? (
<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(); }}
>
<Mail className="size-4 text-amber-600" />
<span>Send eQSL by e-mail</span>
<span>Send OpsLog QSL by e-mail</span>
</button>
)}
{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: '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,
+9 -5
View File
@@ -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<EmailCfg>({
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<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 })} />
<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 })} />
<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 className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
@@ -3086,7 +3090,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div>
<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">
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div>
@@ -3096,7 +3100,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onChange={(e) => setEqslField({ body: e.target.value })} />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<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>
<div className="text-[11px] text-muted-foreground">
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 [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | 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
// 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);
setCurrent({ job, model, assets });
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
onErrorRef.current?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, [onError]);
}, []);
useEffect(() => {
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 });
void pump();
});
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
}, [pump]);
// 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(() => {
if (!current) return;
if (sentForRef.current === current.job.qsoId) return; // already sent this one
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
sentForRef.current = current.job.qsoId;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSent?.(current.job.callsign);
onSentRef.current?.(current.job.callsign);
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
onErrorRef.current?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
}
})();
return () => { cancelled = true; };
}, [current, pump, onSent, onError]);
}, [current, pump]);
if (!current) return null;
// 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,
space: e.size * (kind === 'western' ? 0.08 : 0.04),
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
// sets is for the old SVG stack, not this look).
cDark: kind === 'western' ? '#1c130a' : '#262630',
cOuter: silver ? '#e8edf2' : '#ced3db',
// Dark inter-letter edge + rim — from the chosen colour palette, else the
// default near-black edge (the navy outline adaptStyle sets is for the old
// SVG stack, not this look).
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
// Per-call overrides from the editor (undefined → renderer default).
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
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() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 3));
if (paths.length) setPhotoPaths(paths.slice(0, 5));
} catch (e) {
setError(String(e));
}
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<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.
</p>
<div className="flex items-center gap-2">
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogContent className="max-w-[820px]">
<DialogHeader>
<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>
</DialogHeader>
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
<DialogFooter>
{sent ? (
<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>
</div>
) : (
@@ -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 (
<div className="flex items-center justify-between gap-2">
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
</SelectContent>
</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') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)}
+2
View File
@@ -70,6 +70,8 @@ export interface FxParams {
grunge?: number;
bevel?: number;
seed?: number;
dark?: string;
outer?: string;
}
export interface StyleParams {
+6
View File
@@ -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 {
+22 -6
View File
@@ -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()
+6
View File
@@ -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 != "" {
+14 -4
View File
@@ -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 {
+2
View File
@@ -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