up
This commit is contained in:
@@ -16,7 +16,8 @@
|
|||||||
"Bash(which git-credential-manager *)",
|
"Bash(which git-credential-manager *)",
|
||||||
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
|
"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)",
|
"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"
|
keyEmailUser = "email.smtp_user"
|
||||||
keyEmailPassword = "email.smtp_password"
|
keyEmailPassword = "email.smtp_password"
|
||||||
keyEmailFrom = "email.from"
|
keyEmailFrom = "email.from"
|
||||||
|
keyEmailReplyTo = "email.reply_to" // optional Reply-To: replies go here, not the From sender
|
||||||
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
|
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
|
||||||
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
|
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
|
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"))
|
parts = append(parts, time.Now().UTC().Format("20060102_150405"))
|
||||||
name := strings.Join(parts, "_") + "." + ext
|
name := strings.Join(parts, "_") + "." + ext
|
||||||
path := filepath.Join(a.qsoRecDir(), name)
|
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
|
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.ID != 0 {
|
||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
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.
|
qc := *q
|
||||||
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" {
|
go func() {
|
||||||
qc := *q
|
if err := audio.WritePCM(path, pcm); err != nil {
|
||||||
go func() { _ = a.sendRecordingEmail(qc, path) }()
|
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.).
|
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
|
||||||
@@ -3527,6 +3541,7 @@ type EmailSettings struct {
|
|||||||
User string `json:"smtp_user"`
|
User string `json:"smtp_user"`
|
||||||
Password string `json:"smtp_password"`
|
Password string `json:"smtp_password"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
|
ReplyTo string `json:"reply_to"` // optional — where correspondents' replies go
|
||||||
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
|
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
|
||||||
Auth bool `json:"auth"` // SMTP requires authorization
|
Auth bool `json:"auth"` // SMTP requires authorization
|
||||||
AutoSend bool `json:"auto_send"`
|
AutoSend bool `json:"auto_send"`
|
||||||
@@ -3542,7 +3557,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) {
|
|||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
|
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
|
||||||
keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
|
keyEmailFrom, keyEmailReplyTo, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -3554,6 +3569,7 @@ func (a *App) GetEmailSettings() (EmailSettings, error) {
|
|||||||
out.User = m[keyEmailUser]
|
out.User = m[keyEmailUser]
|
||||||
out.Password = m[keyEmailPassword]
|
out.Password = m[keyEmailPassword]
|
||||||
out.From = m[keyEmailFrom]
|
out.From = m[keyEmailFrom]
|
||||||
|
out.ReplyTo = m[keyEmailReplyTo]
|
||||||
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
|
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
|
||||||
out.Encryption = e
|
out.Encryption = e
|
||||||
}
|
}
|
||||||
@@ -3593,6 +3609,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error {
|
|||||||
keyEmailUser: strings.TrimSpace(s.User),
|
keyEmailUser: strings.TrimSpace(s.User),
|
||||||
keyEmailPassword: s.Password,
|
keyEmailPassword: s.Password,
|
||||||
keyEmailFrom: strings.TrimSpace(s.From),
|
keyEmailFrom: strings.TrimSpace(s.From),
|
||||||
|
keyEmailReplyTo: strings.TrimSpace(s.ReplyTo),
|
||||||
keyEmailEncryption: enc,
|
keyEmailEncryption: enc,
|
||||||
keyEmailAuth: b2s(s.Auth),
|
keyEmailAuth: b2s(s.Auth),
|
||||||
keyEmailAutoSend: b2s(s.AutoSend),
|
keyEmailAutoSend: b2s(s.AutoSend),
|
||||||
@@ -3607,7 +3624,7 @@ func (a *App) SaveEmailSettings(s EmailSettings) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) emailConfig(s EmailSettings) email.Config {
|
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
|
// 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
|
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 (
|
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}"
|
||||||
@@ -74,7 +78,7 @@ type QSLPresetInfo struct {
|
|||||||
// so picking through the native dialog is the reliable route to real paths).
|
// so picking through the native dialog is the reliable route to real paths).
|
||||||
func (a *App) QSLPickPhotos() ([]string, error) {
|
func (a *App) QSLPickPhotos() ([]string, error) {
|
||||||
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Choose card photos (1–3)",
|
Title: "Choose card photos (1–5)",
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: []wruntime.FileFilter{
|
||||||
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
{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 {
|
if len(photoPaths) == 0 {
|
||||||
return nil, fmt.Errorf("no photos selected")
|
return nil, fmt.Errorf("no photos selected")
|
||||||
}
|
}
|
||||||
if len(photoPaths) > 3 {
|
if len(photoPaths) > 5 {
|
||||||
return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths))
|
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
|
||||||
}
|
}
|
||||||
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
||||||
for _, p := range 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)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||||
// the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which
|
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||||
// an eQSL.cc upload may also set. q came straight from GetByID, so a full
|
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||||
// Update rewrites the row unchanged apart from these fields.
|
// row unchanged apart from this field.
|
||||||
now := time.Now().UTC()
|
|
||||||
q.EQSLSent = "Y"
|
|
||||||
q.EQSLSentDate = now.Format("20060102")
|
|
||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
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 {
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
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)
|
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" {
|
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") {
|
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := a.profiles.Active(a.ctx)
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
|||||||
@@ -3201,7 +3201,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AutoEQSL
|
<AutoEQSL
|
||||||
onSent={(call) => showToast(`eQSL sent to ${call}`)}
|
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
||||||
onError={(msg) => showToast(msg)}
|
onError={(msg) => showToast(msg)}
|
||||||
/>
|
/>
|
||||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -136,6 +147,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</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 ? (
|
) : busy ? (
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
<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(); }}
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-amber-600" />
|
<Mail className="size-4 text-amber-600" />
|
||||||
<span>Send eQSL by e-mail</span>
|
<span>Send OpsLog QSL by e-mail</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSendRecording && (
|
{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: '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: '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: '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: '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: '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 (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 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: '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' },
|
{ 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 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 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) },
|
{ 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) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// 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: 'DX Cluster', id: 'cluster' },
|
||||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database', id: 'database' },
|
{ 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).
|
// E-mail / SMTP (send QSO recordings).
|
||||||
type EmailCfg = {
|
type EmailCfg = {
|
||||||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
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>({
|
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||||||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
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 [emailMsg, setEmailMsg] = useState('');
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
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 })} />
|
<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>
|
<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 })} />
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" className="h-8"
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
@@ -3086,7 +3090,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
<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">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||||||
</div>
|
</div>
|
||||||
@@ -3096,7 +3100,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
onChange={(e) => setEqslField({ body: e.target.value })} />
|
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
<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>
|
</label>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
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 busy = useRef(false);
|
||||||
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||||
const svgEl = useRef<SVGSVGElement | 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
|
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||||
// effect below rasterizes once the DOM has it).
|
// 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);
|
const assets = await loadCardAssets(model.template, job.templateId);
|
||||||
setCurrent({ job, model, assets });
|
setCurrent({ job, model, assets });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.(`Auto eQSL: ${e}`);
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
busy.current = false;
|
busy.current = false;
|
||||||
void pump();
|
void pump();
|
||||||
}
|
}
|
||||||
}, [onError]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
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 });
|
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||||
void pump();
|
void pump();
|
||||||
});
|
});
|
||||||
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
}, [pump]);
|
}, [pump]);
|
||||||
|
|
||||||
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
if (sentForRef.current === current.job.qsoId) return; // already sent this one
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||||
if (cancelled || !svgEl.current) return;
|
if (cancelled || !svgEl.current) return;
|
||||||
|
sentForRef.current = current.job.qsoId;
|
||||||
const card = current.model.template.card;
|
const card = current.model.template.card;
|
||||||
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||||
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||||
onSent?.(current.job.callsign);
|
onSentRef.current?.(current.job.callsign);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.(`Auto eQSL: ${e}`);
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCurrent(null);
|
setCurrent(null);
|
||||||
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [current, pump, onSent, onError]);
|
}, [current, pump]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
// Off-screen at full card resolution so the rasterized output matches the
|
// 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,
|
size: e.size,
|
||||||
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
||||||
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
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
|
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
||||||
// sets is for the old SVG stack, not this look).
|
// default near-black edge (the navy outline adaptStyle sets is for the old
|
||||||
cDark: kind === 'western' ? '#1c130a' : '#262630',
|
// SVG stack, not this look).
|
||||||
cOuter: silver ? '#e8edf2' : '#ced3db',
|
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
||||||
|
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
||||||
// Per-call overrides from the editor (undefined → renderer default).
|
// Per-call overrides from the editor (undefined → renderer default).
|
||||||
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
||||||
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
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() {
|
async function choosePhotos() {
|
||||||
try {
|
try {
|
||||||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||||
if (paths.length) setPhotoPaths(paths.slice(0, 3));
|
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">New design</h3>
|
<h3 className="text-sm font-semibold">New design</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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.
|
with your callsign, name, zones and country placed automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
<DialogContent className="max-w-[820px]">
|
<DialogContent className="max-w-[820px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
<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>
|
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ interface Props {
|
|||||||
onChange: (preset: string, params: StyleParams) => void;
|
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 }) {
|
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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') && (
|
{has('color') && (
|
||||||
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface FxParams {
|
|||||||
grunge?: number;
|
grunge?: number;
|
||||||
bevel?: number;
|
bevel?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StyleParams {
|
export interface StyleParams {
|
||||||
|
|||||||
@@ -903,6 +903,7 @@ export namespace main {
|
|||||||
smtp_user: string;
|
smtp_user: string;
|
||||||
smtp_password: string;
|
smtp_password: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
reply_to: string;
|
||||||
encryption: string;
|
encryption: string;
|
||||||
auth: boolean;
|
auth: boolean;
|
||||||
auto_send: boolean;
|
auto_send: boolean;
|
||||||
@@ -921,6 +922,7 @@ export namespace main {
|
|||||||
this.smtp_user = source["smtp_user"];
|
this.smtp_user = source["smtp_user"];
|
||||||
this.smtp_password = source["smtp_password"];
|
this.smtp_password = source["smtp_password"];
|
||||||
this.from = source["from"];
|
this.from = source["from"];
|
||||||
|
this.reply_to = source["reply_to"];
|
||||||
this.encryption = source["encryption"];
|
this.encryption = source["encryption"];
|
||||||
this.auth = source["auth"];
|
this.auth = source["auth"];
|
||||||
this.auto_send = source["auto_send"];
|
this.auto_send = source["auto_send"];
|
||||||
@@ -1752,6 +1754,8 @@ export namespace qslcard {
|
|||||||
grunge?: number;
|
grunge?: number;
|
||||||
bevel?: number;
|
bevel?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new FxParams(source);
|
return new FxParams(source);
|
||||||
@@ -1772,6 +1776,8 @@ export namespace qslcard {
|
|||||||
this.grunge = source["grunge"];
|
this.grunge = source["grunge"];
|
||||||
this.bevel = source["bevel"];
|
this.bevel = source["bevel"];
|
||||||
this.seed = source["seed"];
|
this.seed = source["seed"];
|
||||||
|
this.dark = source["dark"];
|
||||||
|
this.outer = source["outer"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Halo {
|
export class Halo {
|
||||||
|
|||||||
@@ -221,27 +221,43 @@ func (r *Recorder) RestartQSO() {
|
|||||||
r.active = true
|
r.active = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveQSO writes the accumulated recording to path as a WAV and stops
|
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||||
// accumulating. Returns an error if no recording was active.
|
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||||
func (r *Recorder) SaveQSO(path string) error {
|
// 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()
|
r.mu.Lock()
|
||||||
if !r.active {
|
if !r.active {
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
return fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active = nil, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
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") {
|
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
||||||
return writeMP3(path, data)
|
return writeMP3(path, data)
|
||||||
}
|
}
|
||||||
return writeWAV(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).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
User string
|
User string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
|
||||||
Encryption string // "ssl" | "starttls" | "none"
|
Encryption string // "ssl" | "starttls" | "none"
|
||||||
Auth bool // SMTP requires authorization (send username/password)
|
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 {
|
if err := m.To(to); err != nil {
|
||||||
return fmt.Errorf("bad recipient %q: %w", to, err)
|
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.Subject(subject)
|
||||||
m.SetBodyString(mail.TypeTextPlain, body)
|
m.SetBodyString(mail.TypeTextPlain, body)
|
||||||
if attachPath != "" {
|
if attachPath != "" {
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
|
|||||||
cool: sorted[0].Warmth < 0.02,
|
cool: sorted[0].Warmth < 0.02,
|
||||||
}
|
}
|
||||||
if len(sorted) > 1 {
|
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
|
// 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}
|
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.
|
// Side column: pick the calmer half of the hero photo. The insert width
|
||||||
n := min(len(inserts), 3)
|
// shrinks as needed so up to 4 framed photos stack down the side.
|
||||||
w, gap, margin := 400.0, 20.0, 70.0
|
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)
|
left := halfDetail(hero, true) < halfDetail(hero, false)
|
||||||
x := cardW - w - margin
|
x := cardW - w - margin
|
||||||
if left {
|
if left {
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ type FxParams struct {
|
|||||||
Grunge *float64 `json:"grunge,omitempty"`
|
Grunge *float64 `json:"grunge,omitempty"`
|
||||||
Bevel *float64 `json:"bevel,omitempty"`
|
Bevel *float64 `json:"bevel,omitempty"`
|
||||||
Seed *float64 `json:"seed,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
|
// setKeys lists the JSON names of the params that are actually set, for
|
||||||
|
|||||||
Reference in New Issue
Block a user