Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c757e5175 | ||
|
|
626edca8ba | ||
|
|
b202d98ae5 | ||
|
|
949cc17ed1 | ||
|
|
e9feeffdc3 | ||
|
|
7d7d1042c0 | ||
|
|
9cbfd39da0 | ||
|
|
5338ad0b29 | ||
|
|
970127cc16 |
@@ -295,6 +295,8 @@ const (
|
||||
keyBackupFolder = "backup.folder"
|
||||
keyBackupRotation = "backup.rotation"
|
||||
keyBackupZip = "backup.zip"
|
||||
keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day
|
||||
keyBackupKeepAll = "backup.keep_all" // "1" → each backup is a distinct time-stamped file (no same-day overwrite)
|
||||
keyBackupLast = "backup.last_at"
|
||||
|
||||
keyQSLDefaultQSLSent = "qsl.qsl_sent"
|
||||
@@ -1450,7 +1452,8 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
if !backup.HasBackupToday(folder) {
|
||||
// "Back up on every exit" bypasses the once-a-day gate.
|
||||
if s.EveryExit || !backup.HasBackupToday(folder) {
|
||||
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
|
||||
}
|
||||
}
|
||||
@@ -1530,10 +1533,10 @@ func (a *App) runBackupForShutdown() error {
|
||||
if mysql {
|
||||
done = backup.HasADIFBackupToday(folder)
|
||||
}
|
||||
if done {
|
||||
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
|
||||
return nil
|
||||
}
|
||||
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
|
||||
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||
@@ -11581,6 +11584,12 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
a.maybeAutoSendEQSL(qc)
|
||||
// Forward to the outbound UDP integrations, exactly like the manual log
|
||||
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
|
||||
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
|
||||
if a.udp != nil {
|
||||
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
|
||||
}
|
||||
@@ -11775,6 +11784,8 @@ type BackupSettings struct {
|
||||
Folder string `json:"folder"`
|
||||
Rotation int `json:"rotation"`
|
||||
Zip bool `json:"zip"`
|
||||
EveryExit bool `json:"every_exit"` // back up on every quit, not just the first of the day
|
||||
KeepAll bool `json:"keep_all"` // time-stamped file per backup instead of one-per-day (kept up to Rotation)
|
||||
LastBackupAt string `json:"last_backup_at"`
|
||||
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
|
||||
}
|
||||
@@ -11789,7 +11800,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast)
|
||||
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupKeepAll, keyBackupLast)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -11799,6 +11810,8 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
|
||||
out.Rotation = n
|
||||
}
|
||||
out.Zip = m[keyBackupZip] == "1"
|
||||
out.EveryExit = m[keyBackupEveryExit] == "1"
|
||||
out.KeepAll = m[keyBackupKeepAll] == "1"
|
||||
out.LastBackupAt = m[keyBackupLast]
|
||||
return out, nil
|
||||
}
|
||||
@@ -11825,6 +11838,8 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
|
||||
keyBackupFolder: strings.TrimSpace(s.Folder),
|
||||
keyBackupRotation: strconv.Itoa(s.Rotation),
|
||||
keyBackupZip: doZip,
|
||||
keyBackupEveryExit: boolStr(s.EveryExit),
|
||||
keyBackupKeepAll: boolStr(s.KeepAll),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -11841,15 +11856,15 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
|
||||
// backing up the QSOs once the logbook was split out. On MySQL the contacts
|
||||
// aren't in a local file, so they're exported to ADIF instead. Returns the path
|
||||
// of the contacts backup.
|
||||
func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string, error) {
|
||||
func (a *App) runConfiguredBackup(folder string, rotation int, zip, unique bool) (string, error) {
|
||||
// Config snapshot (profiles, hardware, awards). Best-effort — a config-backup
|
||||
// failure must never stop the contacts from being protected.
|
||||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg"); err != nil {
|
||||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg", unique); err != nil {
|
||||
applog.Printf("backup: config snapshot failed: %v", err)
|
||||
}
|
||||
if a.dbBackend == "mysql" {
|
||||
// The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF.
|
||||
return a.backupLogADIF(folder, rotation, zip)
|
||||
return a.backupLogADIF(folder, rotation, zip, unique)
|
||||
}
|
||||
// SQLite: snapshot the logbook file that holds the contacts. Fall back to the
|
||||
// settings db only when it doubles as the logbook (rare split-failure case).
|
||||
@@ -11857,7 +11872,7 @@ func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string
|
||||
if conn == nil || path == "" {
|
||||
conn, path = a.db, a.dbPath
|
||||
}
|
||||
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog")
|
||||
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog", unique)
|
||||
}
|
||||
|
||||
// RunBackupNow forces an immediate backup using the persisted settings.
|
||||
@@ -11871,7 +11886,7 @@ func (a *App) RunBackupNow() (string, error) {
|
||||
if folder == "" {
|
||||
folder = s.DefaultFolder
|
||||
}
|
||||
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip)
|
||||
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll)
|
||||
if err != nil {
|
||||
return path, err
|
||||
}
|
||||
@@ -11882,11 +11897,11 @@ func (a *App) RunBackupNow() (string, error) {
|
||||
// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the
|
||||
// backup folder. The full set of ADIF + app fields is included so the backup is
|
||||
// a complete, re-importable copy of the log.
|
||||
func (a *App) backupLogADIF(folder string, rotation int, zip bool) (string, error) {
|
||||
func (a *App) backupLogADIF(folder string, rotation int, zip, unique bool) (string, error) {
|
||||
if a.qso == nil {
|
||||
return "", fmt.Errorf("logbook not initialized")
|
||||
}
|
||||
return backup.RunADIF(folder, rotation, zip, func(p string) error {
|
||||
return backup.RunADIF(folder, rotation, zip, unique, func(p string) error {
|
||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true}
|
||||
_, e := ex.ExportFile(a.ctx, p)
|
||||
return e
|
||||
@@ -11917,10 +11932,10 @@ func (a *App) maybeShutdownBackup() {
|
||||
if mysql {
|
||||
done = backup.HasADIFBackupToday(folder)
|
||||
}
|
||||
if done {
|
||||
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
|
||||
return
|
||||
}
|
||||
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
|
||||
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
|
||||
fmt.Println("OpsLog: shutdown backup failed:", err)
|
||||
return
|
||||
}
|
||||
@@ -13125,6 +13140,21 @@ func (a *App) reloadCAT() {
|
||||
kw.SetDataMode(s.KenwoodDataMode)
|
||||
a.cat.Start(kw)
|
||||
}
|
||||
case "elecraft":
|
||||
// Elecraft K3/K4: the Kenwood-dialect client with the Elecraft specifics on
|
||||
// (digital modes → DATA A via MD6+DT0). Reuses the Kenwood port/baud/host
|
||||
// settings — the K3 emulates the Kenwood command set, so a separate transport
|
||||
// would be a near-total duplicate.
|
||||
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
|
||||
kw := cat.NewKenwoodTCP(h, s.DigitalDefault)
|
||||
kw.SetElecraft(true)
|
||||
a.cat.Start(kw)
|
||||
} else {
|
||||
kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
|
||||
kw.SetLowerLines(s.KenwoodLowLines)
|
||||
kw.SetElecraft(true)
|
||||
a.cat.Start(kw)
|
||||
}
|
||||
case "icom":
|
||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||
// Same civ protocol the network backend reuses for remote.
|
||||
|
||||
+54
-4
@@ -32,12 +32,19 @@ 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
|
||||
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"`
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"version": "0.23.9",
|
||||
"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.",
|
||||
"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.",
|
||||
"UDP outbound: a QSO logged from WSJT-X / JTDX / MSHV (via the inbound UDP listener) is now forwarded to the outbound ADIF integrations too — previously only QSOs typed directly into OpsLog were re-emitted.",
|
||||
"Backup: with \"back up on every exit\", a new \"Keep every backup\" sub-option writes a distinct time-stamped file per exit (opslog-YYYY-MM-DD-HHMMSS.db) instead of overwriting the day's file. Rotation still keeps the newest N — raise it for more history.",
|
||||
"Awards editor: the PRIMARY QSOFIELDS search now has a Prefix field too (prepended to each found reference, e.g. captured 01 → D01) — previously only the OR fallback searches could add a prefix, so a DOK-style award couldn't match on its main rule.",
|
||||
"CAT: new Elecraft K3/K4 backend in the rig list. Select it (it reuses the Kenwood USB/network settings) and digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs, so the K3 no longer keys the transmitter without modulating."
|
||||
],
|
||||
"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.",
|
||||
"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.",
|
||||
"UDP sortant : un QSO enregistré depuis WSJT-X / JTDX / MSHV (via l'écouteur UDP entrant) est désormais aussi transmis aux intégrations ADIF sortantes — avant, seuls les QSO saisis directement dans OpsLog étaient réémis.",
|
||||
"Sauvegarde : avec \"sauvegarder à chaque fermeture\", une nouvelle sous-option \"Garder chaque sauvegarde\" écrit un fichier horodaté distinct par fermeture (opslog-AAAA-MM-JJ-HHMMSS.db) au lieu d'écraser le fichier du jour. La rotation garde les N plus récents — augmente-la pour plus d'historique.",
|
||||
"Éditeur de diplômes : la recherche PRINCIPALE QSOFIELDS a aussi un champ Préfixe (ajouté devant chaque référence trouvée, ex. 01 capturé → D01) — avant, seules les recherches OR de repli pouvaient ajouter un préfixe, empêchant un diplôme type DOK de matcher sur sa règle principale.",
|
||||
"CAT : nouveau backend Elecraft K3/K4 dans la liste des rigs. Sélectionne-le (il réutilise les réglages USB/réseau Kenwood) et les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l'audio FT8 a besoin, donc le K3 ne passe plus en émission sans moduler."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.8",
|
||||
"date": "",
|
||||
|
||||
@@ -34,7 +34,7 @@ export type AwardDef = {
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
@@ -556,9 +556,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Fallback searches: tried in order, only while nothing
|
||||
|
||||
@@ -465,18 +465,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
@@ -2527,6 +2527,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
|
||||
<SelectItem value="elecraft">{t('cat.optElecraft')}</SelectItem>
|
||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||
@@ -2688,7 +2689,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t('cat.civTrace')}
|
||||
</label> </div>
|
||||
)}
|
||||
{catCfg.backend === 'kenwood' && (
|
||||
{(catCfg.backend === 'kenwood' || catCfg.backend === 'elecraft') && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.kenwoodPort')}</Label>
|
||||
@@ -2725,6 +2726,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||
</div>
|
||||
{catCfg.backend === 'kenwood' ? (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.kwDataMode')}</Label>
|
||||
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
|
||||
@@ -2734,7 +2736,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
|
||||
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select> </div>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('cat.elecraftHint')}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
@@ -4361,13 +4367,33 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!backupCfg.zip}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
|
||||
/>
|
||||
<span>{t('bk.zip')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!(backupCfg as any).every_exit}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
|
||||
/>
|
||||
<span>{t('bk.everyExit')}</span>
|
||||
</label>
|
||||
{!!(backupCfg as any).every_exit && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer pl-6 pb-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={!!(backupCfg as any).keep_all}
|
||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, keep_all: !!c } as any))}
|
||||
/>
|
||||
<span>
|
||||
{t('bk.keepAll')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{t('bk.keepAllHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||
@@ -5829,6 +5855,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>
|
||||
</>
|
||||
|
||||
+12
-12
File diff suppressed because one or more lines are too long
@@ -1894,6 +1894,7 @@ export namespace main {
|
||||
folder: string;
|
||||
rotation: number;
|
||||
zip: boolean;
|
||||
every_exit: boolean;
|
||||
last_backup_at: string;
|
||||
default_folder: string;
|
||||
|
||||
@@ -1907,6 +1908,7 @@ export namespace main {
|
||||
this.folder = source["folder"];
|
||||
this.rotation = source["rotation"];
|
||||
this.zip = source["zip"];
|
||||
this.every_exit = source["every_exit"];
|
||||
this.last_backup_at = source["last_backup_at"];
|
||||
this.default_folder = source["default_folder"];
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ type Def struct {
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference (e.g. captured "01" + "D" → "D01"), same as OrRule.Prefix
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||
//
|
||||
@@ -960,7 +961,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||
// next rule ("find the code inside the QTH") would have found BG.
|
||||
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, d.Prefix)
|
||||
for i := range d.OrRules {
|
||||
r := &d.OrRules[i]
|
||||
label := fmt.Sprintf("OR %d", i+1)
|
||||
|
||||
@@ -26,7 +26,7 @@ type Settings struct {
|
||||
Folder string `json:"folder"` // empty → DefaultFolder
|
||||
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
|
||||
Zip bool `json:"zip"` // compress with deflate
|
||||
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never
|
||||
LastBackupAt string `json:"last_backup_at"` // RFC3339; empty if never
|
||||
}
|
||||
|
||||
// DefaultFolder returns the folder used when Settings.Folder is empty.
|
||||
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
|
||||
// statement (no torn-copy window while the app keeps writing), and compacts
|
||||
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
|
||||
// which could capture a half-written page during a concurrent write.
|
||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string) (string, error) {
|
||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string, unique bool) (string, error) {
|
||||
if dbConn == nil {
|
||||
return "", fmt.Errorf("nil db connection")
|
||||
}
|
||||
@@ -61,7 +61,12 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
return "", fmt.Errorf("create backup folder: %w", err)
|
||||
}
|
||||
|
||||
// One file per DAY by default (same-day runs overwrite). With `unique`, add the
|
||||
// time so every run is a distinct file — rotation still keeps the newest N.
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
if unique {
|
||||
stamp = time.Now().Format("2006-01-02-150405")
|
||||
}
|
||||
base := fmt.Sprintf("%s-%s", prefix, stamp)
|
||||
|
||||
// VACUUM INTO requires a non-existent target → use a temp file, then
|
||||
@@ -106,7 +111,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
|
||||
// ADIF export is a portable, re-importable backup of the actual contacts.
|
||||
// writeADIF must write the full ADIF to the path it's handed.
|
||||
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
|
||||
func RunADIF(folder string, rotation int, doZip bool, unique bool, writeADIF func(path string) error) (string, error) {
|
||||
if rotation <= 0 {
|
||||
rotation = 5
|
||||
}
|
||||
@@ -116,7 +121,11 @@ func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create backup folder: %w", err)
|
||||
}
|
||||
base := "opslog-log-" + time.Now().Format("2006-01-02")
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
if unique {
|
||||
stamp = time.Now().Format("2006-01-02-150405")
|
||||
}
|
||||
base := "opslog-log-" + stamp
|
||||
tmp := filepath.Join(folder, base+".adi.tmp")
|
||||
_ = os.Remove(tmp)
|
||||
if err := writeADIF(tmp); err != nil {
|
||||
|
||||
+32
-1
@@ -62,6 +62,10 @@ type Kenwood struct {
|
||||
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
|
||||
// data mode is a USB modifier the operator sets on the rig). See SetMode.
|
||||
dataMode string
|
||||
// elecraft marks a K3/K4 (the "Elecraft" backend). It reuses this Kenwood-dialect
|
||||
// client but, being an Elecraft, always drives digital modes to DATA A (MD6+DT0)
|
||||
// — the sub-mode FT8 audio needs — instead of going through the dataMode option.
|
||||
elecraft bool
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
@@ -391,11 +395,30 @@ func (k *Kenwood) SetMode(mode string) error {
|
||||
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
|
||||
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
|
||||
if isKenwoodDataMode(mode) {
|
||||
// An Elecraft always uses DATA A for a soundcard digital mode — that's what
|
||||
// the "Elecraft" backend means, so the dataMode option doesn't apply.
|
||||
if k.elecraft {
|
||||
if err := k.write("MD6;"); err != nil {
|
||||
return err
|
||||
}
|
||||
return k.write("DT0;")
|
||||
}
|
||||
switch k.dataMode {
|
||||
case "keep":
|
||||
return nil // leave whatever data mode the operator set on the rig
|
||||
case "data":
|
||||
return k.write("MD6;") // Elecraft K3/K4 DATA mode
|
||||
// Elecraft K3/K4 DATA mode (MD6) PLUS the DATA-A submode (DT0). MD6 alone
|
||||
// can leave the rig in an FSK/PSK data submode (from a prior RTTY/PSK
|
||||
// session), where FT8 keys the transmitter but the rear sound-card audio
|
||||
// never modulates — "transmits but nothing comes out". DT0 forces DATA A,
|
||||
// the audio submode FT8 needs. This is the Elecraft path (the option is
|
||||
// labelled K3/K4); a real K3/K4 answers DT with nothing. A plain Kenwood
|
||||
// has no DT command and would "?;" it — but that's a misconfiguration
|
||||
// (pick USB there), and IF is never latched so the link self-recovers.
|
||||
if err := k.write("MD6;"); err != nil {
|
||||
return err
|
||||
}
|
||||
return k.write("DT0;")
|
||||
}
|
||||
}
|
||||
d := kenwoodModeDigit(mode, k.curFreq)
|
||||
@@ -413,6 +436,14 @@ func (k *Kenwood) SetDataMode(m string) {
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetElecraft marks this client as driving a K3/K4 (the "Elecraft" backend), so
|
||||
// digital modes always land in DATA A (MD6+DT0). Set before Start.
|
||||
func (k *Kenwood) SetElecraft(v bool) {
|
||||
k.mu.Lock()
|
||||
k.elecraft = v
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
|
||||
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
|
||||
func isKenwoodDataMode(mode string) bool {
|
||||
|
||||
@@ -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