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:
+58
-8
@@ -31,13 +31,20 @@ import (
|
||||
const (
|
||||
keyQSLEmailSubject = "qsl.email_subject"
|
||||
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
|
||||
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||
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 (
|
||||
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}"
|
||||
@@ -453,11 +460,37 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
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 {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
DefaultMessage string `json:"default_message"`
|
||||
}
|
||||
|
||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||
@@ -466,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
if a.settings == 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 {
|
||||
return out, err
|
||||
}
|
||||
@@ -477,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
out.Body = b
|
||||
}
|
||||
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||
out.DefaultMessage = m[keyQSLDefaultMsg]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -495,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||
if t.AutoSend {
|
||||
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
|
||||
@@ -634,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
||||
}
|
||||
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{
|
||||
"profile.callsign": info.Callsign,
|
||||
"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.submode": q.Submode,
|
||||
"qso.rst_sent": q.RSTSent,
|
||||
"qso.qsl_msg": q.QSLMsg,
|
||||
"qso.qsl_msg": msg,
|
||||
"qso.pse_tnx": pseTnx,
|
||||
"qso.name": q.Name,
|
||||
}
|
||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||
|
||||
+6
-2
@@ -4,11 +4,15 @@
|
||||
"date": "",
|
||||
"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.",
|
||||
"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": [
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from '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 { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
@@ -428,6 +428,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
[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 (
|
||||
<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">
|
||||
@@ -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>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><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>
|
||||
</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) },
|
||||
// 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 },
|
||||
// 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.
|
||||
{ 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 },
|
||||
|
||||
|
||||
@@ -1373,8 +1373,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean; default_message: string };
|
||||
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false, default_message: '' });
|
||||
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
|
||||
// ClubLog Country File (cty.xml) exception status.
|
||||
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">
|
||||
{t('em.autoSendHint')}
|
||||
</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>
|
||||
</>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -734,7 +734,10 @@ func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
|
||||
BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
|
||||
Title: "Confirming QSO with {qso.callsign}",
|
||||
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
|
||||
if zone.y+zone.h/2 > cardH/2 {
|
||||
|
||||
Reference in New Issue
Block a user