feat(qsl): OpsLog QSL received marker + PSE/TNX card stamp + default QSL message

Received flag: new APP_OPSLOG_QSL_RCVD extra, toggled on the QSO edit window
next to QSL Message (immediate targeted write via SetOpsLogQSLReceived so it sets
AND clears reliably), plus a live PSE QSL / TNX indicator and a Recent-QSOs
column mirroring the sent one.

PSE/TNX card stamp: automatic — received → TNX, otherwise PSE QSL — exposed as
the {qso.pse_tnx} token (added to qslVars, so preview and send agree) and placed
in the default QSO-box footer; it can be moved to its own element in the designer.

Default QSL message: new qsl.default_message (QSLEmailTemplates.DefaultMessage),
edited under Settings → E-mail → QSL card e-mail. qslVars falls back to it when
the QSO's own QSLMSG is empty, so a per-QSO message always wins. Single choke
point covers both live preview and send.
This commit is contained in:
2026-08-07 10:36:57 +02:00
parent 9cbfd39da0
commit 7d7d1042c0
7 changed files with 114 additions and 21 deletions
+58 -8
View File
@@ -31,13 +31,20 @@ import (
const ( const (
keyQSLEmailSubject = "qsl.email_subject" keyQSLEmailSubject = "qsl.email_subject"
keyQSLEmailBody = "qsl.email_body" keyQSLEmailBody = "qsl.email_body"
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
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
) )
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its // 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). // own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT" const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
// the card: received → "TNX" (thanks for your card), not received → "PSE QSL"
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
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}"
@@ -453,11 +460,37 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
return nil return nil
} }
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle. // qslDefaultMessage returns the operator's default QSL message (Settings), used
// on the card when a QSO has no QSLMSG of its own.
func (a *App) qslDefaultMessage() string {
if a.settings == nil {
return ""
}
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
return s
}
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
v := ""
if on {
v = time.Now().UTC().Format(time.RFC3339)
}
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
}
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
// the default QSL message printed on the card when a QSO has none of its own.
type QSLEmailTemplates struct { type QSLEmailTemplates struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
AutoSend bool `json:"auto_send"` AutoSend bool `json:"auto_send"`
DefaultMessage string `json:"default_message"`
} }
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults). // QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
@@ -466,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
if a.settings == nil { if a.settings == nil {
return out, nil return out, nil
} }
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend) m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -477,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out.Body = b out.Body = b
} }
out.AutoSend = m[keyQSLAutoSend] == "1" out.AutoSend = m[keyQSLAutoSend] == "1"
out.DefaultMessage = m[keyQSLDefaultMsg]
return out, nil return out, nil
} }
@@ -495,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if t.AutoSend { if t.AutoSend {
v = "1" v = "1"
} }
return a.settings.Set(a.ctx, keyQSLAutoSend, v) if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
return err
}
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
} }
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the // maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
@@ -634,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
} }
return strconv.Itoa(z) return strconv.Itoa(z)
} }
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
// operator's default message (Settings) is used instead.
msg := q.QSLMsg
if strings.TrimSpace(msg) == "" {
msg = a.qslDefaultMessage()
}
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
pseTnx := "PSE QSL"
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
pseTnx = "TNX"
}
vars := map[string]string{ vars := map[string]string{
"profile.callsign": info.Callsign, "profile.callsign": info.Callsign,
"profile.operator_name": info.Operator, "profile.operator_name": info.Operator,
@@ -650,7 +699,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
"qso.mode": q.Mode, "qso.mode": q.Mode,
"qso.submode": q.Submode, "qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent, "qso.rst_sent": q.RSTSent,
"qso.qsl_msg": q.QSLMsg, "qso.qsl_msg": msg,
"qso.pse_tnx": pseTnx,
"qso.name": q.Name, "qso.name": q.Name,
} }
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q) vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
+6 -2
View File
@@ -4,11 +4,15 @@
"date": "", "date": "",
"en": [ "en": [
"Backup: new \"Back up on every exit\" option (Settings → Backup). With it on, OpsLog backs up the database each time you quit instead of only the first time of the day — so a second session's QSOs are always captured.", "Backup: new \"Back up on every exit\" option (Settings → Backup). With it on, OpsLog backs up the database each time you quit instead of only the first time of the day — so a second session's QSOs are always captured.",
"Cluster details (My station): removed the example placeholders in My rig / My antenna (and the satellite name/mode) — they looked like real values, so an empty field read as already filled." "Cluster details (My station): removed the example placeholders in My rig / My antenna (and the satellite name/mode) — they looked like real values, so an empty field read as already filled.",
"QSL: new \"OpsLog QSL received\" marker on the QSO edit window (next to QSL Message), with a live PSE QSL / TNX indicator. When a QSL was received the card prints TNX (thanks), otherwise PSE QSL (please send one) — automatic. The stamp uses the {qso.pse_tnx} token (in the default card footer; placeable anywhere in the designer). A \"QSL received\" column was added to Recent QSOs.",
"QSL: a default QSL message (Settings → E-mail, under the QSL card e-mail) now prints on the card when a QSO has no QSL Message of its own — a QSO's own QSL Message always takes precedence."
], ],
"fr": [ "fr": [
"Sauvegarde : nouvelle option \"Sauvegarder à chaque fermeture\" (Réglages → Sauvegarde). Activée, OpsLog sauvegarde la base à chaque fois que tu quittes au lieu d'une seule fois par jour — les QSO d'une seconde session sont ainsi toujours pris.", "Sauvegarde : nouvelle option \"Sauvegarder à chaque fermeture\" (Réglages → Sauvegarde). Activée, OpsLog sauvegarde la base à chaque fois que tu quittes au lieu d'une seule fois par jour — les QSO d'une seconde session sont ainsi toujours pris.",
"Détails cluster (Ma station) : suppression des exemples en filigrane dans Mon rig / Mon antenne (et nom/mode satellite) — ils ressemblaient à de vraies valeurs, faisant croire qu'un champ vide était déjà rempli." "Détails cluster (Ma station) : suppression des exemples en filigrane dans Mon rig / Mon antenne (et nom/mode satellite) — ils ressemblaient à de vraies valeurs, faisant croire qu'un champ vide était déjà rempli.",
"QSL : nouveau marqueur \"QSL OpsLog reçue\" dans la fenêtre d'édition du QSO (à côté du Message QSL), avec un indicateur PSE QSL / TNX en direct. Si une QSL a été reçue, la carte imprime TNX (merci), sinon PSE QSL (envoie-moi une carte) — automatique. Le tampon utilise le token {qso.pse_tnx} (dans le pied de carte par défaut ; plaçable où tu veux dans le designer). Une colonne \"QSL reçue\" a été ajoutée aux QSO récents.",
"QSL : un message QSL par défaut (Réglages → E-mail, sous l'e-mail de carte QSL) s'imprime désormais sur la carte quand un QSO n'a pas son propre Message QSL — le Message QSL du QSO l'emporte toujours."
] ]
}, },
{ {
+30 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -428,6 +428,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
[draft.extras], [draft.extras],
); );
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
// received → TNX (thanks for your card), otherwise PSE QSL (please send one).
// Saved with the normal Save via draft.extras.
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
const toggleQslReceived = (on: boolean) => {
// Reflect immediately in the draft (for the PSE/TNX indicator)…
const next = { ...(draft.extras ?? {}) };
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
else delete next[OPSLOG_QSL_RCVD];
set('extras', next as any);
// …and persist right away with a targeted write that reliably sets OR clears
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
};
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
@@ -596,7 +612,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div> <div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div> <div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
</div> </div>
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div> <div>
<Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
<div className="mt-1 flex items-center gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
{t('qedit.qslReceived')}
</label>
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground" title={t('qedit.pseTnxHint')}>
{qslReceived ? 'TNX' : 'PSE QSL'}
</span>
</div>
</div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div> <div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
</div> </div>
</div> </div>
@@ -195,6 +195,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_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. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, 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 }, { group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, 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 },
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
// App-specific: when the QSO's audio recording was e-mailed to the station. // App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
+8 -2
View File
@@ -1373,8 +1373,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
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 }));
// eQSL card e-mail (subject/body templates + auto-send on log). // eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean }; type EQSLCfg = { subject: string; body: string; auto_send: boolean; default_message: string };
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false }); const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false, default_message: '' });
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch })); const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status. // ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number }; type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
@@ -5836,6 +5836,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{t('em.autoSendHint')} {t('em.autoSendHint')}
</div> </div>
<div className="pt-2 space-y-1">
<Label className="text-sm">{t('em.qslDefaultMsg')}</Label>
<Input className="h-8" placeholder={t('em.qslDefaultMsgPh')} value={eqslCfg.default_message ?? ''}
onChange={(e) => setEqslField({ default_message: e.target.value })} />
<div className="text-[11px] text-muted-foreground">{t('em.qslDefaultMsgHint')}</div>
</div>
</div> </div>
</div> </div>
</> </>
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -734,7 +734,10 @@ func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
BG: "#ffffff", BGOpacity: 0.88, Radius: 12, BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
Title: "Confirming QSO with {qso.callsign}", Title: "Confirming QSO with {qso.callsign}",
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"}, Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"},
Footer: "{qso.qsl_msg}", // PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the
// message. {qso.pse_tnx} is a normal token — it can be moved to its own
// element anywhere on the card in the designer.
Footer: "{qso.pse_tnx} {qso.qsl_msg}",
} }
box.Y = cardH - box.H - 110 box.Y = cardH - box.H - 110
if zone.y+zone.h/2 > cardH/2 { if zone.y+zone.h/2 > cardH/2 {