up
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–3 photos — OpsLog analyzes them and proposes three card designs
|
||||
Pick 1–5 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 })} />
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface FxParams {
|
||||
grunge?: number;
|
||||
bevel?: number;
|
||||
seed?: number;
|
||||
dark?: string;
|
||||
outer?: string;
|
||||
}
|
||||
|
||||
export interface StyleParams {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user