Compare commits

..
48 Commits
Author SHA1 Message Date
rouggy a9f2e515e1 chore: release v0.12 2026-06-20 20:18:28 +02:00
rouggy 260172cd6d feat: Added --profile argument to start OpsLog on specific profile 2026-06-20 19:47:22 +02:00
rouggy 4b5e2e0b72 fix: Binding to Gui Client to get proper values for cw delay and pitch 2026-06-20 19:34:10 +02:00
rouggy 4a6ea45665 fix: bandmap opened or closed is now persistent over restart 2026-06-20 18:59:12 +02:00
rouggy 95d37da3bb feat: While importing ADIF, update MY fields 2026-06-20 15:48:21 +02:00
rouggy e1b3f0faf3 fix: CW decoder was loosing first caracters 2026-06-20 02:38:02 +02:00
rouggy 6379e2cd1f fix: persistence on columns awards 2026-06-20 02:32:32 +02:00
rouggy 2228816057 fix: improve cw decoding with qrm 2026-06-20 02:25:53 +02:00
rouggy 32878c17be fix: bug when autocall for cw keyer is on which was
autocalling no matter which macro now only on CQ
fix: ESC stop transmission but also autocall
2026-06-20 02:05:12 +02:00
rouggy 079d0c32df feat: cw decoder 2026-06-19 17:31:10 +02:00
rouggy 45d081ac0c feat: added colonns for awards in recent qso 2026-06-18 23:20:24 +02:00
rouggy 183db7ac2b fix: Upload to HRDLog 2026-06-18 22:58:00 +02:00
rouggy 4d074de27e fix: batch upload to HRDLog instead of one by one 2026-06-18 19:28:36 +02:00
rouggy 679e8f8d39 fix: added additional selection in recent qso filters 2026-06-18 19:08:38 +02:00
rouggy dd2deee939 feat: added support for eQSL 2026-06-18 14:56:13 +02:00
rouggy cdd71b17c8 feat: implemented HRDLog upload 2026-06-18 14:27:33 +02:00
rouggy e8eedcc1dc fix: updated gitignore 2026-06-18 13:14:26 +02:00
rouggy 3c47366f56 chore: stop tracking personal log.adi 2026-06-18 13:14:16 +02:00
rouggy bd11bb4763 fix: bug where worked filter did not work in cluster 2026-06-18 13:03:13 +02:00
rouggy 40e95e6a16 chore: release v0.11.3 2026-06-18 12:41:54 +02:00
rouggy cc0f9ffc64 fix: download lotw only for current callsign in case
of mixed logs (tm2q & f4bpo in same log)
2026-06-18 12:34:53 +02:00
rouggy e1f1ab4922 fix: bug sending LoTW on close 2026-06-18 12:16:39 +02:00
rouggy b6d991b799 fix: Bug where renaming the main folder did not update db path
settings where the ones of the previous folder.
2026-06-18 11:20:20 +02:00
rouggy 59f1775fcd fix: Updated README 2026-06-18 10:48:23 +02:00
rouggy b2a8b1946f chore: release v0.11.2 2026-06-17 23:15:12 +02:00
rouggy 8b1609f5ce feat: added live status for TM74TFR 2026-06-17 22:10:32 +02:00
rouggy bde1195b34 feat: added FlexRadio support (meters & basic functions) 2026-06-17 18:29:35 +02:00
rouggy abdab22010 feat: added selection of map 4 choices 2026-06-16 21:49:02 +02:00
rouggy 16dc864dbd feat: physical heading for ultrabeam antennas 2026-06-16 21:25:04 +02:00
rouggy 01235624ee chore: release v0.11.1 2026-06-16 21:09:00 +02:00
rouggy a7bbc53c35 feat: Mainview can choose Map, cluster or worked before 2026-06-16 21:04:19 +02:00
rouggy 3d15f20c7f fix: proper beam heading on the map 2026-06-16 20:24:49 +02:00
rouggy e5c6bddb29 fix: /AM not recognizing the country now showing 2026-06-16 20:05:03 +02:00
rouggy 75ee5344a4 chore: release v0.11 2026-06-16 20:00:15 +02:00
rouggy 957182611d feat: check for available updates 2026-06-16 19:52:23 +02:00
rouggy 69d0780bac feat: added versionning & About window 2026-06-16 19:36:56 +02:00
rouggy 33af122964 awards 2026-06-16 09:50:48 +02:00
rouggy 22e3bb4a18 up 2026-06-15 23:45:14 +02:00
rouggy 29fd832bcd up 2026-06-14 01:35:40 +02:00
rouggy 67203cd4a8 up 2026-06-14 00:55:27 +02:00
rouggy 08162fa126 mysql 2026-06-13 21:56:38 +02:00
rouggy 81e505e040 up 2026-06-13 19:14:24 +02:00
rouggy 0b3e22c97e fix 2026-06-13 17:25:48 +02:00
rouggy d3ba7c71f4 fix issues 2026-06-13 16:55:57 +02:00
rouggy 00cab6b204 up 2026-06-13 16:17:58 +02:00
rouggy ff53831be4 up seafile 2026-06-13 10:14:23 +02:00
rouggy 3cb2e466d8 Qsl 2026-06-13 01:34:45 +02:00
rouggy 408b29896c qsl designer 2026-06-11 21:54:35 +02:00
323 changed files with 27474 additions and 51997 deletions
+19 -1
View File
@@ -4,7 +4,25 @@
"Bash(go get *)",
"Bash(go build *)",
"Bash(wails generate *)",
"Bash(npm run *)"
"Bash(npm run *)",
"Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)",
"Bash(timeout 20 git status --short)",
"Bash(echo \"exit: $?\")",
"Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)",
"Bash(echo \"push exit: $?\")",
"Bash(git config *)",
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
"Bash(which git-credential-manager *)",
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
"Bash(gofmt -w internal/qslcard/*.go)",
"Bash(awk '{print $3}')",
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
"Bash(ls -la .claude/)",
"Bash(cat .claude/settings.local.json)",
"Bash(cat .claude/settings.json)"
]
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"Bash(go:*)",
"Bash(gofmt:*)",
"Bash(/c/Users/legre/go/bin/wails:*)",
"Bash(wails:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(cat:*)",
"Bash(ls:*)",
"Bash(find:*)",
"Bash(echo:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(awk:*)",
"Bash(sed:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(wc:*)",
"Bash(xargs:*)",
"Bash(for f in *)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
]
}
}
+1
View File
@@ -39,6 +39,7 @@ desktop.ini
hamlog.db*
cty.dat
cat.log
*.adi
# --- Secrets ---
.env
+152 -10
View File
@@ -1,16 +1,158 @@
# README
# OpsLog
## About
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
optional **shared MySQL** for the logbook so several operators can run one log.
This is the official Wails Svelte-TS template.
Developed by **F4BPO**.
## Live Development
---
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building / developing
## Building
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
`wails generate module`.
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task*
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
and publishes it to Gitea + GitHub releases.
To build a redistributable, production mode package, use `wails build`.
---
## Logging
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
**flag** is shown large next to the RST fields.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
and the QRZ.com tab.
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
date overrides.
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
from cty/QRZ/ClubLog, bulk send to QSL services.
- **Profiles:** every setting is per-profile; each profile can point its logbook
at the local SQLite file or a **shared MySQL** database (multi-operator).
## Maps & antenna
- **Main view = two configurable panes** (per profile, Settings → General →
*Main view*): great-circle map, locator (street) map, the cluster grid, the
worked-before grid, or the **FlexRadio controls**.
- **Great-circle map** with short/long-path distance & azimuth, selectable
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
the **antenna beam lobe(s)** drawn from the rotor azimuth.
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
direction is shown in green and the **mechanical boom** in grey, on both the
compass and the map, so you never lose track of where the antenna points.
## DX Cluster
- Multiple cluster servers with auto-reconnect, a master for commands.
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
mode / status / source) shared by the Cluster tab and the Main-view cluster
pane, with a show/hide toggle.
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
rig, and a multi-band **Band Map** (panadapter-style strips).
## CAT control
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
backend over the radio's TCP API — real-time slice freq/mode/split, auto
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
Flex display, click → fill the call).
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
from the frequency.
### FlexRadio control tab (SmartSDR-style)
Shown only when the CAT backend is a FlexRadio:
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
VOX (+ level + delay), monitor (+ level), mic gain.
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
- **Antenna tuner (ATU):** tune / bypass / memories.
- **Amplifier:** PowerGenius XL operate/standby + fault.
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
## Keyers & audio
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
- **Digital Voice Keyer** (DVK) message playback.
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
audio in CW).
## QSL & awards
- **Awards engine:** built-in + custom award definitions (shared **globally**
across profiles), worked/confirmed/validated by band & mode, OR rules and
manual reference assignment, live reference detection on call entry, and a
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
and **confirmation download** (which auto-refreshes the award stats).
- **QSL Card Designer** (see below).
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
password, or a Microsoft app password.)
## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
Settings → General → *Publish live operator status*. Each OpsLog instance
heartbeats its current activity (operator call, band, frequency, mode) into a
`live_status` table every ~15 s. A small PHP renderer
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
own web server reads that table and produces a live page/image you can embed on
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
only writes to the DB — it is not a web server.
## Other
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running.
- **Update check** at startup with a toast (toggleable).
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences).
---
## QSL Card Designer
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
1. Pick 16 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
grid) and proposes **3 designs** — callsign in the calmest zone of the best
photo, operator name, CQ/ITU zones + locator line, country flag, the other
photos as bordered inserts, and a per-QSO confirmation box.
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
change font / style preset (gel gold, gel silver, classic white outline,
script, flat) and per-preset knobs in the right panel.
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
originals can move). One template can be the default per profile.
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
and sent through the configured SMTP account to the address found by the
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
e-mail subject/body templates live in the designer
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
commonly-worked DXCC entities.
---
## Data & storage
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
award lists, QSL templates) always lives in the local SQLite file under
`data/` — instant even when the logbook is on a far-away MySQL.
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
file or a per-profile shared **MySQL** database.
+1646 -219
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}
+693
View File
@@ -0,0 +1,693 @@
package main
// QSL card designer bindings. All logic lives in internal/qslcard — this file
// only adapts it to the Wails boundary (thin methods on *App, same receiver
// as app.go so bindings keep working). Template documents cross the bridge
// as JSON strings: the schema is the single source of truth and the frontend
// mirrors it in qslTypes.ts.
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Settings keys for the eQSL e-mail templates (subject/body share the
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
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
)
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
)
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }
// QSLTemplateInfo is the template-list row shown in the designer.
type QSLTemplateInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
ProfileID *int64 `json:"profile_id,omitempty"`
IsDefault bool `json:"is_default"`
UpdatedAt string `json:"updated_at"`
}
// QSLFontInfo is one font face offered by the designer, TTF bytes included
// so the frontend can build @font-face rules (and inline them into the SVG
// before rasterization).
type QSLFontInfo struct {
Family string `json:"family"`
Kind string `json:"kind"` // "display" | "script" | "system"
Variable bool `json:"variable"`
DataB64 string `json:"data_b64"`
}
// QSLPresetInfo describes one style preset for the picker.
type QSLPresetInfo struct {
Name string `json:"name"`
Label string `json:"label"`
Params []string `json:"params"` // accepted style_params keys
Defaults qslcard.StyleParams `json:"defaults"`
}
// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
// "browse" path (drag & drop hands the webview File objects without paths,
// so picking through the native dialog is the reliable route to real paths).
func (a *App) QSLPickPhotos() ([]string, error) {
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose card photos (15)",
Filters: []wruntime.FileFilter{
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
},
})
}
// QSLGenerateProposals analyzes the photos and returns 3 distinct template
// documents (JSON) from the automatic placement engine.
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
if len(photoPaths) == 0 {
return nil, fmt.Errorf("no photos selected")
}
if len(photoPaths) > 5 {
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
}
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
for _, p := range photoPaths {
ph, err := qslcard.AnalyzePhoto(p)
if err != nil {
return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
}
photos = append(photos, ph)
}
info, err := a.qslProfileInfo()
if err != nil {
return nil, err
}
var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
templates, err := engine.Propose(photos, info)
if err != nil {
return nil, err
}
out := make([]string, len(templates))
for i, t := range templates {
b, err := qslcard.Encode(t)
if err != nil {
return nil, err
}
out[i] = string(b)
}
return out, nil
}
// QSLListTemplates returns all saved templates (defaults first).
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
if a.qslTemplates == nil {
return nil, fmt.Errorf("db not initialized")
}
recs, err := a.qslTemplates.List(a.ctx)
if err != nil {
return nil, err
}
out := make([]QSLTemplateInfo, 0, len(recs))
for _, r := range recs {
out = append(out, QSLTemplateInfo{
ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
})
}
return out, nil
}
// QSLGetTemplate returns a stored template document (JSON).
func (a *App) QSLGetTemplate(id int64) (string, error) {
if a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
rec, err := a.qslTemplates.Get(a.ctx, id)
if err != nil {
return "", err
}
return rec.JSON, nil
}
// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
// the document references at their original (absolute) paths are copied into
// the template's asset folder so the design survives the user moving files.
// Returns the template id.
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
name = strings.TrimSpace(name)
if name == "" {
return 0, fmt.Errorf("template name required")
}
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return 0, err
}
t.Name = name
if err := qslcard.Validate(t, nil); err != nil {
return 0, err
}
rec := qslcard.Record{ID: id, Name: name}
if forActiveProfile {
if p, err := a.profiles.Active(a.ctx); err == nil {
rec.ProfileID = &p.ID
}
}
// Two-phase save: the row first (a new template needs its id to own an
// asset folder), then photo import + the final document.
rec.JSON = doc
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
if err := qslcard.ImportPhotos(&t, dir); err != nil {
return 0, err
}
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return 0, err
}
final, err := qslcard.Encode(t)
if err != nil {
return 0, err
}
rec.JSON = string(final)
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
return rec.ID, nil
}
// QSLSetDefaultTemplate marks a template as default for its profile scope.
func (a *App) QSLSetDefaultTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
return a.qslTemplates.SetDefault(a.ctx, id)
}
// QSLDeleteTemplate removes a template and its asset folder.
func (a *App) QSLDeleteTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
return err
}
return qslcard.RemoveTemplateDir(a.qslDir(), id)
}
// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
// renders at save time, shown in the template list.
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
data, err := base64.StdEncoding.DecodeString(pngB64)
if err != nil {
return fmt.Errorf("decode preview: %w", err)
}
dir := qslcard.TemplateDir(a.qslDir(), id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create template dir: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
return fmt.Errorf("write preview: %w", err)
}
return nil
}
// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
// template has none yet).
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read preview: %w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
// a draft photo at its original absolute path (pre-save editing); otherwise
// ref is a name inside the template's asset folder.
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
var path string
switch {
case templateID > 0 && !qslcard.IsDraftPhoto(ref):
path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
case qslcard.IsDraftPhoto(ref):
path = ref
default:
return "", fmt.Errorf("photo %q not available without a template id", ref)
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read photo: %w", err)
}
mime := "image/jpeg"
if strings.EqualFold(filepath.Ext(path), ".png") {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
func (a *App) QSLFlagDataURL(iso string) (string, error) {
if iso == "" {
return "", nil
}
svg, err := qslcard.FlagSVG(iso)
if err != nil {
return "", err
}
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
}
// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
// machine has it) for @font-face registration.
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
fonts, err := qslcard.Fonts()
if err != nil {
return nil, err
}
out := make([]QSLFontInfo, 0, len(fonts))
for _, f := range fonts {
out = append(out, QSLFontInfo{
Family: f.Family, Kind: f.Kind, Variable: f.Variable,
DataB64: base64.StdEncoding.EncodeToString(f.Data),
})
}
return out, nil
}
// QSLStylePresets lists the built-in style presets for the picker.
func (a *App) QSLStylePresets() []QSLPresetInfo {
out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
for _, p := range qslcard.Presets {
params := make([]string, 0, len(p.AllowedParams))
for k := range p.AllowedParams {
params = append(params, k)
}
sort.Strings(params)
out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// QSLResolvePreview fills a template's placeholders with the active profile
// and a representative sample QSO, for the live editor preview. Returns the
// RenderModel as JSON.
func (a *App) QSLResolvePreview(doc string) (string, error) {
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return "", err
}
vars, country, err := a.qslVars(sampleQSO())
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// QSLDefaultTemplateID returns the template the eQSL send flow should use
// for the active profile (0 when none exists yet).
func (a *App) QSLDefaultTemplateID() (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return 0, fmt.Errorf("no active profile: %w", err)
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
return 0, nil // no templates yet — the UI offers to open the designer
}
return rec.ID, nil
}
// RenderEQSL loads a QSO and a template and returns the fully-resolved
// render model (JSON) for the frontend to rasterize.
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
if a.qso == nil || a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return "", err
}
rec, err := a.qslTemplates.Get(a.ctx, templateID)
if err != nil {
return "", fmt.Errorf("template %d: %w", templateID, err)
}
t, err := qslcard.Parse([]byte(rec.JSON))
if err != nil {
return "", err
}
dir := qslcard.TemplateDir(a.qslDir(), templateID)
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return "", err
}
vars, country, err := a.qslVars(q)
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return err
}
to := strings.TrimSpace(q.Email)
if to == "" {
return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
}
data, err := base64.StdEncoding.DecodeString(jpegB64)
if err != nil {
return fmt.Errorf("decode card image: %w", err)
}
outbox := filepath.Join(a.qslDir(), "outbox")
if err := os.MkdirAll(outbox, 0o755); err != nil {
return fmt.Errorf("create outbox: %w", err)
}
safeCall := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' {
return '_'
}
return r
}, q.Callsign)
path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write outbox file: %w", err)
}
s, _ := a.GetEmailSettings()
subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
if subject == "" {
subject = defaultQSLEmailSubject
}
body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
if body == "" {
body = defaultQSLEmailBody
}
if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// independent. q came straight from GetByID, so a full Update rewrites the
// row unchanged apart from this field.
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("eQSL sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
return nil
}
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
}
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
if err != nil {
return out, err
}
if s := m[keyQSLEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyQSLEmailBody]; b != "" {
out.Body = b
}
out.AutoSend = m[keyQSLAutoSend] == "1"
return out, nil
}
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
return err
}
v := "0"
if t.AutoSend {
v = "1"
}
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
}
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
// default template for the active profile, and the QSO not already confirmed.
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
// the frontend acts on — never sends from here. Silent when any condition fails.
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
return
}
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
return
}
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
return
}
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
})
}
// ── helpers ─────────────────────────────────────────────────────────────
func encodeRenderModel(m qslcard.RenderModel) (string, error) {
b, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("encode render model: %w", err)
}
return string(b), nil
}
// qslProfileInfo extracts the placement-engine inputs from the active
// profile, filling missing zones from cty.dat.
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
if a.profiles == nil {
return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
}
info := qslcard.ProfileInfo{
Callsign: p.Callsign,
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
Grid: p.MyGrid,
Rig: p.MyRig,
Antenna: p.MyAntenna,
}
if p.MyCQZone != nil {
info.CQZone = *p.MyCQZone
}
if p.MyITUZone != nil {
info.ITUZone = *p.MyITUZone
}
if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok {
if info.CQZone == 0 {
info.CQZone = m.CQZone
}
if info.ITUZone == 0 {
info.ITUZone = m.ITUZone
}
}
}
// Most users define rig/antenna in the operating manager (band defaults),
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
// default so the designer knows the station line has data to show.
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
if e != nil {
continue
}
if info.Rig == "" {
info.Rig = d.StationName
}
if info.Antenna == "" {
info.Antenna = d.AntennaName
}
if info.Rig != "" && info.Antenna != "" {
break
}
}
}
return info, nil
}
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
// default for the QSO's band (so the preview and back-entered QSOs aren't
// blank).
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
rig, ant = q.MyRig, q.MyAntenna
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
return rig, ant
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return rig, ant
}
band := q.Band
if band == "" {
band = "20m"
}
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
if rig == "" {
rig = d.StationName
}
if ant == "" {
ant = d.AntennaName
}
}
return rig, ant
}
// qslVars builds the full placeholder map (profile + QSO) and the country
// block resolution for one render.
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
info, err := a.qslProfileInfo()
if err != nil {
return nil, qslcard.CountryInfo{}, err
}
zone := func(z int) string { // unknown zone reads better blank than "0"
if z == 0 {
return ""
}
return strconv.Itoa(z)
}
vars := map[string]string{
"profile.callsign": info.Callsign,
"profile.operator_name": info.Operator,
"profile.grid": info.Grid,
"profile.cq_zone": zone(info.CQZone),
"profile.itu_zone": zone(info.ITUZone),
"profile.rig": info.Rig,
"profile.antenna": info.Antenna,
"qso.callsign": q.Callsign,
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
"qso.time_on": q.QSODate.UTC().Format("15:04"),
"qso.band": q.Band,
"qso.mode": q.Mode,
"qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent,
"qso.qsl_msg": q.QSLMsg,
"qso.name": q.Name,
}
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
if q.FreqHz != nil {
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
}
return vars, a.qslCountryInfo(), nil
}
// qslCountryInfo resolves the operator's own country (label + flag) from the
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
// callsign. No match → label only or nothing (graceful).
func (a *App) qslCountryInfo() qslcard.CountryInfo {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.CountryInfo{}
}
out := qslcard.CountryInfo{Label: p.MyCountry}
entityNum := 0
if p.MyDXCC != nil {
entityNum = *p.MyDXCC
if out.Label == "" {
out.Label = dxcc.NameForDXCC(entityNum)
}
} else if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
if out.Label == "" {
out.Label = m.Entity.Name
}
entityNum = dxcc.EntityDXCC(m.Entity.Name)
}
}
out.FlagISO = qslcard.FlagISO(entityNum)
return out
}
// sampleQSO is the canned contact used by the editor's live preview.
func sampleQSO() qso.QSO {
return qso.QSO{
Callsign: "DL1ABC",
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
Band: "20m",
Mode: "SSB",
RSTSent: "59",
QSLMsg: "TNX FB QSO — 73!",
}
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/base64"
"fmt"
"hamlog/internal/applog"
"hamlog/internal/secret"
)
// Secret-vault settings keys (NOT themselves sensitive — they hold the salt and
// an encrypted verifier token, never a password).
const (
keySecretSalt = "secret.salt" // base64 PBKDF2 salt
keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase
)
// sensitiveSettingKeys are the password fields encrypted at rest when the user
// sets a passphrase. Everything else stays plaintext.
var sensitiveSettingKeys = map[string]bool{
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
}
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
// SecretStatus tells the UI whether a passphrase is configured and unlocked.
type SecretStatus struct {
HasPassphrase bool `json:"has_passphrase"`
Unlocked bool `json:"unlocked"`
}
// GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI
// shows the unlock prompt.
func (a *App) GetSecretStatus() SecretStatus {
if a.settings == nil {
return SecretStatus{}
}
salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()}
}
// reloadAfterSecretChange rebuilds anything that read passwords at startup while
// they were still locked (lookup providers + external-service config).
func (a *App) reloadAfterSecretChange() {
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
}
// UnlockSecrets validates the passphrase against the stored verifier and, on
// success, installs the cipher so passwords decrypt for the rest of the session.
func (a *App) UnlockSecrets(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier)
if saltB64 == "" || verifier == "" {
return fmt.Errorf("no passphrase configured")
}
salt, err := base64.StdEncoding.DecodeString(saltB64)
if err != nil {
return fmt.Errorf("corrupt salt")
}
key, err := secret.DeriveKey(passphrase, salt)
if err != nil {
return fmt.Errorf("derive key: %w", err)
}
c, err := secret.New(key)
if err != nil {
return fmt.Errorf("cipher: %w", err)
}
if !c.CheckVerifier(verifier) {
return fmt.Errorf("wrong passphrase")
}
a.settings.Unlock(c)
a.reloadAfterSecretChange()
return nil
}
// SetPassphrase sets a new passphrase or changes an existing one, then
// re-encrypts every stored secret under the new key. Changing an existing
// passphrase requires the vault to be unlocked first (so current secrets are
// readable as plaintext).
func (a *App) SetPassphrase(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
if len(passphrase) < 4 {
return fmt.Errorf("passphrase too short (min 4 characters)")
}
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
if saltB64 != "" && !a.settings.Unlocked() {
return fmt.Errorf("unlock with the current passphrase first")
}
// Snapshot current plaintext (Get decrypts when unlocked, passes through
// when no passphrase yet).
current := map[string]string{}
for k := range sensitiveSettingKeys {
if v, _ := a.settings.Get(a.ctx, k); v != "" {
current[k] = v
}
}
salt, err := secret.NewSalt()
if err != nil {
return fmt.Errorf("salt: %w", err)
}
key, err := secret.DeriveKey(passphrase, salt)
if err != nil {
return fmt.Errorf("derive key: %w", err)
}
c, err := secret.New(key)
if err != nil {
return fmt.Errorf("cipher: %w", err)
}
// Persist salt + verifier first so an interrupted migration is still
// recoverable (the prompt appears next launch; plaintext secrets read
// through transparently until re-encrypted).
if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil {
return err
}
if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil {
return err
}
a.settings.Unlock(c)
for k, v := range current {
if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts
applog.Printf("secret: re-encrypt %q failed: %v", k, err)
}
}
a.reloadAfterSecretChange()
return nil
}
// RemovePassphrase decrypts all secrets back to plaintext and clears the vault.
// Requires the current passphrase (validated) so it can't be cleared blindly.
func (a *App) RemovePassphrase(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
if !a.settings.Unlocked() {
if err := a.UnlockSecrets(passphrase); err != nil {
return err
}
}
// Read decrypted, then store back as plaintext and drop salt/verifier.
plain := map[string]string{}
for k := range sensitiveSettingKeys {
if v, _ := a.settings.Get(a.ctx, k); v != "" {
plain[k] = v
}
}
a.settings.Lock()
for k, v := range plain {
if err := a.settings.SetRaw(a.ctx, k, v); err != nil {
applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err)
}
}
_ = a.settings.SetRaw(a.ctx, keySecretSalt, "")
_ = a.settings.SetRaw(a.ctx, keySecretVerifier, "")
a.reloadAfterSecretChange()
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
// launches on startup (JSON array of AutostartProgram).
const keyAutostartPrograms = "autostart.programs"
// AutostartProgram is one external application OpsLog can launch when it starts
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
type AutostartProgram struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Args string `json:"args"`
Enabled bool `json:"enabled"`
}
// AutostartLaunchResult reports what happened for one program when launching.
type AutostartLaunchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // launched | already_running | missing | disabled | error
Message string `json:"message"`
}
// GetAutostartPrograms returns the active profile's autostart list.
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
out := []AutostartProgram{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []AutostartProgram{}, nil
}
return out, nil
}
// SaveAutostartPrograms persists the autostart list for the active profile.
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(progs)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
}
// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose a program to launch on startup",
Filters: []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// LaunchAutostartPrograms starts every enabled program that isn't already
// running, returning a per-program result. Used at startup (best effort) and by
// the "Launch now" button in settings.
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
progs, _ := a.GetAutostartPrograms()
running := runningProcessNames()
out := make([]AutostartLaunchResult, 0, len(progs))
for _, p := range progs {
if !p.Enabled {
continue
}
out = append(out, launchProgram(p, running))
}
return out
}
// LaunchAutostartProgram launches a single program by id on demand (the per-row
// "launch now" action), regardless of its enabled flag.
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
progs, err := a.GetAutostartPrograms()
if err != nil {
return AutostartLaunchResult{}, err
}
for _, p := range progs {
if p.ID == id {
return launchProgram(p, runningProcessNames()), nil
}
}
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
}
// launchProgram starts one program unless its executable is already running.
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
path := strings.TrimSpace(p.Path)
if path == "" {
res.Status, res.Message = "error", "no path configured"
return res
}
if _, err := os.Stat(path); err != nil {
res.Status, res.Message = "missing", "executable not found: "+path
return res
}
// Skip if a process with the same executable name is already running, so we
// never spawn a second copy of WSJT-X / JTAlert / etc.
base := strings.ToLower(filepath.Base(path))
if running[base] {
res.Status, res.Message = "already_running", base+" already running"
return res
}
cmd := exec.Command(path, splitArgs(p.Args)...)
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
if err := cmd.Start(); err != nil {
res.Status, res.Message = "error", err.Error()
return res
}
// Don't wait on the child — it runs independently of OpsLog. Release the
// handle so we don't accumulate zombies.
go func() { _ = cmd.Wait() }()
res.Status, res.Message = "launched", "started "+base
return res
}
// splitArgs does a minimal shell-like split of an argument string, honouring
// double quotes so a path with spaces stays one argument.
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var args []string
var cur strings.Builder
inQuote := false
for _, r := range s {
switch {
case r == '"':
inQuote = !inQuote
case r == ' ' && !inQuote:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
+219
View File
@@ -0,0 +1,219 @@
package main
import (
"fmt"
"strings"
"time"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
// operators on the same shared log (e.g. a special-event call) can talk. No
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
const (
chatPollInterval = 3 * time.Second
chatPresenceEvery = 20 * time.Second
chatRetentionDays = 7
chatHistoryDefault = 80
chatPresenceStaleSecs = 120 // a presence row older than this = offline
)
// ChatMessage is one chat line.
type ChatMessage struct {
ID int64 `json:"id"`
Operator string `json:"operator"`
Station string `json:"station"`
Message string `json:"message"`
CreatedAt string `json:"created_at"` // ISO UTC
}
// ChatPresence is one operator currently online (recent heartbeat).
type ChatPresence struct {
Operator string `json:"operator"`
Station string `json:"station"`
AgoSecs int `json:"ago_secs"`
}
// chatActive reports whether chat can run (shared MySQL logbook).
func (a *App) chatActive() bool {
return a.logDb != nil && a.dbBackend == "mysql"
}
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
func (a *App) ChatAvailable() bool { return a.chatActive() }
func (a *App) ensureChatTables() error {
if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_messages ("+
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
"operator VARCHAR(32), station VARCHAR(32), "+
"message TEXT, created_at DATETIME)"); err != nil {
return err
}
_, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_presence ("+
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
return err
}
// SendChatMessage posts a message to the shared chat and returns the stored row
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
text = strings.TrimSpace(text)
if text == "" {
return ChatMessage{}, nil
}
if len(text) > 1000 {
text = text[:1000]
}
if !a.chatActive() {
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
}
op, station := a.liveStatusOperator()
if op == "" {
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
}
if err := a.ensureChatTables(); err != nil {
return ChatMessage{}, err
}
res, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
op, station, text)
if err != nil {
return ChatMessage{}, err
}
id, _ := res.LastInsertId()
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
}
// GetChatHistory returns the most recent messages (oldest first) for the panel.
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
if !a.chatActive() {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = chatHistoryDefault
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatMessage
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
// Reverse to chronological order (we queried newest-first to honour LIMIT).
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
// GetOnlineOperators lists operators with a recent presence heartbeat.
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
if !a.chatActive() {
return nil, nil
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
chatPresenceStaleSecs)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatPresence
for rows.Next() {
var p ChatPresence
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// chatLoop polls for new messages and heartbeats presence while on a shared
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
func (a *App) chatLoop() {
defer func() { _ = recover() }()
var lastID int64 = -1 // -1 = not yet baselined
lastPresence := time.Time{}
lastPurge := time.Time{}
t := time.NewTicker(chatPollInterval)
defer t.Stop()
for range t.C {
if !a.chatActive() {
lastID = -1 // re-baseline if the backend changes
continue
}
if err := a.ensureChatTables(); err != nil {
continue
}
now := time.Now()
// Presence heartbeat.
if now.Sub(lastPresence) >= chatPresenceEvery {
if op, station := a.liveStatusOperator(); op != "" {
_, _ = a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
op, station)
}
lastPresence = now
}
// Baseline on first run so existing history isn't replayed as "new"
// (the panel loads it via GetChatHistory).
if lastID < 0 {
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
_ = row.Scan(&lastID)
continue
}
// Emit new messages.
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
if err != nil {
continue
}
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
continue
}
if m.ID > lastID {
lastID = m.ID
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "chat:message", m)
}
}
rows.Close()
// Purge old messages occasionally (hourly).
if now.Sub(lastPurge) >= time.Hour {
_, err := a.logDb.ExecContext(a.ctx,
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
if err != nil {
applog.Printf("chat: purge failed: %v", err)
}
lastPurge = now
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* OpsLog multi-operator LIVE STATUS renderer.
*
* Reads the shared `live_status` table that every OpsLog instance heartbeats
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
* shows the operators active in the last 2 minutes.
*
* Put this file on YOUR web server (the one reachable from the internet), point
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
* on the QRZ.com bio of the station call:
*
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
*
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
*/
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
$DB_NAME = 'opslog'; // database name
$DB_USER = 'opslog';
$DB_PASS = 'CHANGE_ME';
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing
// `live_status` table (not yet created by OpsLog) just yields an empty list
// instead of a fatal "table doesn't exist".
mysqli_report(MYSQLI_REPORT_OFF);
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
if ($mysqli->connect_errno) {
http_response_code(500);
exit('DB error');
}
// The table is created by OpsLog on first publish; tolerate it not existing yet.
$rows = [];
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
FROM live_status
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
ORDER BY band, freq_hz";
if ($stmt = @$mysqli->prepare($sql)) {
$stmt->bind_param('i', $STALE_SECONDS);
@$stmt->execute();
if ($res = $stmt->get_result()) {
while ($r = $res->fetch_assoc()) $rows[] = $r;
}
}
$station = $rows ? $rows[0]['station'] : 'OpsLog';
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
if (isset($_GET['img'])) {
header('Content-Type: image/svg+xml');
header('Cache-Control: no-cache, max-age=30');
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
$y = 44 + 18;
if (!$rows) {
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
}
foreach ($rows as $r) {
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
$y += $rowH;
}
echo "</svg>";
exit;
}
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
header('Content-Type: text/html; charset=utf-8');
?><!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="20">
<title><?= htmlspecialchars($station) ?> — live operators</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
table { border-collapse:collapse; width:100%; max-width:560px; }
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
td.mono { font-family:monospace; }
.none { color:#94a3b8; }
</style></head><body>
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
<?php if (!$rows): ?>
<p class="none">No operator active right now.</p>
<?php else: ?>
<table>
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
<?php foreach ($rows as $r): ?>
<tr>
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
<td><?= htmlspecialchars($r['band']) ?></td>
<td><?= htmlspecialchars($r['mode']) ?></td>
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</body></html>
+1106 -322
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -31,11 +31,17 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
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;
total: number; builtin?: boolean;
};
type AwardOrRule = {
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
leading_str?: string; trailing_str?: string; prefix?: string;
};
type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]);
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
{/* Additional OR searches: a QSO earns a reference if the
primary rule OR any of these match. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> also match the reference if any of these hit</p>
<Button size="sm" variant="outline" className="h-7"
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
<Plus className="size-3.5" /> Add OR
</Button>
</div>
{(cur.or_rules ?? []).map((r, ri) => {
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
return (
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">OR search in</span>
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-2 text-[11px]">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1 cursor-pointer">
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
</div>
<div className="grid grid-cols-[1fr_120px] gap-2">
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
</div>
</div>
);
})}
</div>
</div>
</TabsContent>
+13 -6
View File
@@ -29,9 +29,13 @@ interface Props {
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
// automatically — we surface that so the operator needn't pick it by hand.
fieldValues?: Record<string, string>;
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
// the live entry panel passes "flex-1 min-h-0" so it fills the available
// space instead of overflowing and forcing a scrollbar.
heightClass?: string;
}
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA');
@@ -63,13 +67,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
// for a French call but not for others.
const awards = useMemo(() => {
return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
// Computed awards (field = dxcc/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
// Offer the award even when its reference list isn't loaded yet: a custom
// award (e.g. "Worked All Provinces of China") has no built-in list, so
// requiring one would make it impossible to assign references by hand. The
// operator can pick from a loaded list when present, or add an unlisted
// reference (the right-hand panel). Dynamic / list-backed awards behave as
// before — they just always pass here now.
return true;
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
.sort((a, b) => a.code.localeCompare(b.code));
@@ -169,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
}
return (
<div className="flex gap-2 h-[210px]">
<div className={`flex gap-2 ${heightClass}`}>
{/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
+226 -57
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
);
}
type AwardListItem = { code: string; name: string; valid?: boolean };
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
@@ -75,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Bumped by Rescan to force the stats matrix to re-fetch (the selected award
// didn't change, but the backend snapshot did).
const [rescanTick, setRescanTick] = useState(0);
// Lazily fetch the statistics matrix when the Stats view is shown.
useEffect(() => {
@@ -84,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
.then((s) => setStats(s as any))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, [view, selected]);
}, [view, selected, rescanTick]);
// Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
// download are picked up) and the cached results, then recompute everything.
async function rescan() {
if (!selected) return;
setLoading(true);
try {
await RescanAwards();
setByCode({});
setRescanTick((t) => t + 1);
await compute(selected, true);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
}
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
@@ -108,7 +129,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
@@ -121,6 +142,53 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const current = byCode[selected];
// Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
// the bands the operator actually has contacts on (so DXCC, which has no band
// restriction, shows 160m6m instead of empty VHF/UHF columns). Empty set =
// no basis yet → callers fall back to all bands.
const awardBands = useMemo(() => {
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
if (vb.length > 0) return new Set(vb);
const worked = (current?.bands ?? [])
.filter((b) => (b.worked ?? 0) > 0)
.map((b) => String(b.band).toLowerCase());
return new Set(worked);
}, [awardList, selected, current]);
const gridBands = useMemo(() => {
if (awardBands.size === 0) return GRID_BANDS;
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
return filtered.length ? filtered : GRID_BANDS;
}, [awardBands]);
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
// shows only when the award has no emission restriction or lists that emission.
const statsRows = useMemo(() => {
if (!stats) return [];
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
if (em.size === 0) return stats.rows;
return stats.rows.filter((r) => {
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
return !m || em.has(m[1]);
});
}, [stats, awardList, selected]);
// Indices into stats.bands to display (and to slice each row's cells by), same
// band basis as the grid.
const statsBandIdx = useMemo(() => {
if (!stats) return [] as number[];
const all = stats.bands.map((_, i) => i);
if (awardBands.size === 0) return all;
const idx = stats.bands
.map((b, i) => ({ b: b.toLowerCase(), i }))
.filter((x) => awardBands.has(x.b))
.map((x) => x.i);
return idx.length ? idx : all;
}, [stats, awardBands]);
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
@@ -144,8 +212,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
title="Rescan all QSOs and recompute this award">
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan
</Button>
@@ -228,10 +296,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{/* Band breakdown */}
{(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span>
@@ -245,9 +313,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
@@ -255,10 +323,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
>
<AlertTriangle className="size-3" /> Missing refs
@@ -283,27 +351,27 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
</tr>
</thead>
<tbody>
{stats.rows.map((row, i) => {
{statsRows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
); })}
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr>
);
})}
@@ -313,30 +381,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div>
) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
{gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))}
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{GRID_BANDS.map((b) => {
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : (
{s === 'none' ? <span className="block w-11 h-7" /> : (
<button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
@@ -351,22 +419,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div>
) : (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
<th className="py-1.5 pr-2 font-medium">Name</th>
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
<th className="py-1.5 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span>
@@ -401,23 +469,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
// MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM).
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
const [assignRef, setAssignRef] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const load = () => {
setLoading(true);
setSel(new Set());
AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs).
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
useEffect(() => {
ListAwardReferences(code)
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
.catch(() => setRefs([]));
}, [code]);
const qthOf = (q: any) => String(q.qth || q.notes || '');
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
const val = (q: any): string => {
switch (sortKey) {
case 'qso_date': return String(q.qso_date ?? '');
case 'callsign': return String(q.callsign ?? '').toUpperCase();
case 'band': return String(q.band ?? '');
case 'mode': return String(q.mode ?? '');
case 'country': return String(q.country ?? '').toUpperCase();
case 'qth': return qthOf(q).toUpperCase();
}
};
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
}, [qsos, sortKey, sortDir]);
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const setSort = (k: MissingSortKey) => {
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(k); setSortDir('asc'); }
};
async function applyAssign() {
if (!assignRef || sel.size === 0) return;
setBusy(true); setMsg('');
try {
const assignedIds = Array.from(sel);
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
// Optimistic: the assigned contacts now carry a reference, so drop them
// from the list locally instead of re-running the slow whole-log scan.
const done = new Set(assignedIds);
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
setSel(new Set());
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
<span className="inline-flex items-center gap-0.5">{label}
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</span>
</th>
);
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span>
@@ -432,8 +568,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
</div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{sel.size} selected </span>
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
<SelectContent className="max-h-72">
{refs.map((r) => (
<SelectItem key={r.code} value={r.code}>
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
</Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
@@ -443,22 +599,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
<SortTh k="callsign" label="Callsign" />
<SortTh k="band" label="Band" />
<SortTh k="mode" label="Mode" />
<SortTh k="country" label="Country" />
<SortTh k="qth" label="QTH / Note" />
</tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i}
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
{sorted.map((q, i) => {
const id = q.id as number;
return (
<tr key={id ?? i}
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
</td>
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
</tr>
))}
);
})}
</tbody>
</table>
)}
+34 -2
View File
@@ -12,6 +12,7 @@ interface Props {
currentBand: string;
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -78,7 +79,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -100,6 +101,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
return m;
}, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
// lists every real (band, mode) the entity was worked on.
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim();
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
// New Band = the band is new (the mode was worked on another band).
// New Mode = the mode is new (the band was worked in another mode).
// New Slot = both band and mode already worked — just not together.
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
const newBandMode = slotNew && !bandWorked && !modeWorked;
const newBand = slotNew && !bandWorked && modeWorked;
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
return (
<section
className={cn(
@@ -135,6 +159,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</>
)}
</span>
{(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{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>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div>
)}
</>
) : busy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
@@ -181,7 +213,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</th>
{cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = b.tag === currentBand && classCurrent;
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return (
<td
key={b.tag}
+167
View File
@@ -0,0 +1,167 @@
import { useMemo, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { BulkUpdateField } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
type FieldKind = 'status' | 'text';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
// the backend BulkUpdateField whitelist.
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
// My station / operator
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
// Misc
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
];
const STATUS_VALUES: { v: string; label: string }[] = [
{ v: 'Y', label: 'Y — Yes / uploaded' },
{ v: 'N', label: 'N — No' },
{ v: 'R', label: 'R — Requested' },
{ v: 'I', label: 'I — Ignore' },
{ v: '_', label: '(blank — clear)' },
];
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
type Props = {
open: boolean;
ids: number[];
onClose: () => void;
onApplied: (n: number) => void;
};
// BulkEditModal sets one QSL/upload status field to a chosen value across the
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const [field, setField] = useState('hrdlog_upload');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
const isStatus = def.kind === 'status';
const effectiveValue = isStatus
? (statusValue === '_' ? '' : statusValue)
: textValue.trim();
async function apply() {
setBusy(true);
setError('');
try {
const n = await BulkUpdateField(ids as any, field, effectiveValue);
onApplied(Number(n) || 0);
onClose();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Bulk edit field</DialogTitle>
<DialogDescription>
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
This overwrites the current value there is no undo.
</DialogDescription>
</DialogHeader>
<div className="px-5 py-2 space-y-3">
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Label className="text-sm">Field</Label>
<Select value={field} onValueChange={setField}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
{FIELDS.filter((f) => f.group === g).map((f) => (
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<Label className="text-sm">Value</Label>
{isStatus ? (
<Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
value={textValue}
placeholder="leave empty to clear the field"
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground">
Will set <span className="font-semibold">{def.label}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
</div>
{error && <div className="text-xs text-rose-700">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={apply} disabled={busy || ids.length === 0}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
Apply to {ids.length}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, Send, Users, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
export type ChatPresence = { operator: string; station: string; ago_secs: number };
function hhmm(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
onSend: (text: string) => void; onClose: () => void;
}) {
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const me = (myCall || '').toUpperCase();
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
function send() {
const t = text.trim();
if (!t) return;
setText('');
onSend(t);
}
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
<MessageSquare className="size-4 text-sky-600" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
<span className="flex-1" />
<Users className="size-3.5 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground" title={online.map((o) => o.operator).join(', ')}>
{online.length}
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
<X className="size-3.5" />
</button>
</div>
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
{msgs.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
) : msgs.map((m) => {
const mine = m.operator.toUpperCase() === me;
return (
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
<span className="whitespace-pre-wrap break-words">{m.message}</span>
</div>
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="Message…"
maxLength={1000}
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
/>
<button type="button" onClick={send} disabled={!text.trim()}
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
<Send className="size-3.5" />
</button>
</div>
</div>
);
}
+45
View File
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
];
}
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
}
}
const COL_CATALOG: ColEntry[] = [
{
group: 'Spot', label: 'Time', colId: 'time',
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
},
},
{
group: 'Spot', label: 'Status', colId: 'status',
headerName: 'Status', width: 96, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell.
valueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : '';
},
cellRenderer: (p: any) => {
const b = statusBadge(statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
return (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
if (s?.status === 'new-band') return 'NEW BAND for this entity';
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
if (s?.worked_call) return 'Already worked this call';
return undefined;
},
},
{
group: 'Spot', label: 'POTA', colId: 'pota',
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
+63 -7
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -118,9 +119,43 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
);
}
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
try {
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
setDetected(all as any);
const cur = details.award_refs ?? '';
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
if (next !== cur) onChange({ award_refs: next });
} catch { /* leave detection empty on failure */ }
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
@@ -179,10 +214,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
))}
</nav>
<div className="overflow-y-auto min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} />
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
</div>
)}
@@ -205,8 +240,18 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field>
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
{/* DXCC # closes the top row (next to the zones); Continent and
Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label="DXCC #">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={details.dxcc ?? ''}
placeholder="—"
/>
</Field>
<Field label="Azimuth LP">
<Input
readOnly
@@ -247,13 +292,24 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
)}
{open === 'awards' && (
<div className="px-3 py-2.5">
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
<AwardRefSelector
dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
<span className="font-medium text-foreground/70">Detected this contact will count for:</span>{' '}
{detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
</span>
))}
</div>
)}
</div>
)}
+1 -1
View File
@@ -24,7 +24,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting</span>}
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> Stop
+16 -1
View File
@@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'iota', label: 'IOTA', type: 'text' },
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
{ value: 'rig', label: 'Rig', type: 'text' },
{ value: 'ant', label: 'Antenna', type: 'text' },
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
@@ -64,6 +65,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
{ value: 'stx', label: 'Serial sent', type: 'number' },
@@ -74,6 +76,17 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
{ value: 'my_grid', label: 'My grid', type: 'text' },
{ value: 'my_country', label: 'My country', type: 'text' },
{ value: 'my_state', label: 'My state', type: 'text' },
{ value: 'my_cnty', label: 'My county', type: 'text' },
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
{ value: 'my_street', label: 'My street', type: 'text' },
{ value: 'my_city', label: 'My city', type: 'text' },
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
{ value: 'my_rig', label: 'My rig', type: 'text' },
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
{ value: 'comment', label: 'Comment', type: 'text' },
{ value: 'notes', label: 'Notes', type: 'text' },
@@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
)}
{conditions.map((c, i) => {
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
return (
<div key={i} className="flex items-center gap-2">
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent>
</Select>
<Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? 'value' : '—'}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// FirstRunModal collects the mandatory station identity on the very first launch
// (no callsign configured yet). It writes straight into the active profile, so
// OpsLog has a valid station before any QSO is logged. Not dismissable.
export function FirstRunModal({ onDone }: { onDone: () => void }) {
const [p, setP] = useState<Profile | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
const [refsMsg, setRefsMsg] = useState('');
async function downloadRefs() {
setRefsState('loading');
try {
const summary = await DownloadAllReferenceLists();
setRefsMsg(summary);
setRefsState('done');
} catch (e: any) {
setRefsMsg(String(e?.message ?? e));
setRefsState('idle');
}
}
useEffect(() => {
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
}, []);
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
const callsign = (p?.callsign ?? '').trim().toUpperCase();
const grid = (p?.my_grid ?? '').trim().toUpperCase();
const operator = (p?.operator ?? '').trim().toUpperCase();
const canSave = callsign.length >= 3 && grid.length >= 4;
async function save() {
if (!p || !canSave) return;
setSaving(true);
setErr('');
try {
await SaveProfile({
...p,
callsign,
my_grid: grid,
operator: operator || callsign,
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
op_name: (p.op_name ?? '').trim(),
} as any);
onDone();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
<div className="flex items-center gap-2 mb-1">
<Radio className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences Station Information (and per profile).
</p>
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
<Label className="text-sm">Operator</Label>
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
<Label className="text-sm">Owner</Label>
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
<Label className="text-sm">Name</Label>
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
</div>
{/* Optional: grab the award reference lists now (also in Tools later). */}
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">Award reference lists</div>
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA names &amp; totals for those awards (optional, can take a minute).</div>
</div>
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
</Button>
</div>
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
</div>
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
<div className="mt-5 flex items-center justify-end gap-2">
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
</div>
</div>
</div>
);
}
+473
View File
@@ -0,0 +1,473 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
type FlexState = {
available: boolean; model?: string;
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
vox_enable: boolean; vox_level: number; vox_delay: number;
proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
meters?: Meter[];
};
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false,
};
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
}) {
const v = Math.max(0, Math.min(max, value));
const pct = max > 0 ? (v / max) * 100 : 0;
const ref = useRef<HTMLInputElement>(null);
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
// is ignored), so attach a non-passive native listener; read live values via
// refs to avoid stale closures.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const stepRef = useRef(step); stepRef.current = step;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={0} max={max} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
/>
);
}
// Segmented — radio-style multi-choice (AGC, Processor preset).
function Segmented({ value, options, onChange, disabled }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
}) {
const onCls = {
emerald: 'bg-emerald-600 border-emerald-600 text-white',
violet: 'bg-violet-600 border-violet-600 text-white',
cyan: 'bg-cyan-600 border-cyan-600 text-white',
amber: 'bg-amber-500 border-amber-500 text-white',
}[accent];
return (
<button type="button" onClick={onClick} disabled={disabled}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
<div className="flex items-baseline justify-between gap-1 mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
<div className="flex gap-[2px] h-2.5 items-stretch">
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100"
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
);
})}
</div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
export function FlexPanel() {
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = (await GetFlexState()) as any as FlexState;
if (!alive) return;
setSt((prev) => {
const now = Date.now();
const merged: any = { ...s };
for (const k in hold.current) {
if (hold.current[k] > now) merged[k] = (prev as any)[k];
}
return merged as FlexState;
});
} catch { /* not connected */ }
};
tick();
const id = window.setInterval(tick, 400);
return () => { alive = false; window.clearInterval(id); };
}, []);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val }));
send().catch(() => {});
};
const off = !st.available;
const rxOff = off || !st.rx_avail;
const isCW = (st.mode || '').toUpperCase().includes('CW');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* Header strip */}
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
<Radio className="size-6 text-sky-400" />
<div className="flex flex-col leading-tight">
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
</div>
<div className="flex-1" />
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-slate-700 text-slate-300'
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
<span className="size-2 rounded-full bg-current" />
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
</span>
</div>
{off && (
<div className="text-center text-sm text-muted-foreground py-6">
Waiting for the FlexRadio (set CAT to FlexRadio and connect)
</div>
)}
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title="Transmit" accent="#dc2626">
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
</div>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
</div>
<div className="flex items-center gap-2 pt-0.5">
<button type="button" disabled={off}
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
TUNE
</button>
<button type="button" disabled={off}
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button>
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
<span className="flex-1" />
</div>
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
</div>
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div>
</div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
</div>
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div>
)}
</Card>
{/* RECEIVE */}
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
</div>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{CW_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{bw}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</div>
</div>
)}
</Card>
</div>
{/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && (
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
<button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
</span>
<div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
)}
</div>
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
</Card>
)}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title="Meters">
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet waiting for the radio's UDP stream…</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const s = (dbm + 127) / 6; // S0 = -127 dBm
if (s >= 9) {
const over = Math.round(dbm + 73); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
}
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})}
</div>
</div>
)}
</>
);
})()}
</Card>
</div>
</div>
);
}
+182 -95
View File
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
}
// MainMap — Log4OM-style dual map for the Main tab:
// • Left: a world map with the great-circle path drawn from the operator to
// the contacted station, plus distance + short/long-path azimuth.
// • Right: a street map zoomed onto the contacted station's grid locator.
// The Main tab is built from two independent map panes that the operator can
// place on either side (Settings → Main view):
// • WorldMap ("map1"): a world map with the great-circle path from the
// operator to the contacted station, distance, short/long-path azimuth and
// the antenna beam lobe.
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
interface Props {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
@@ -53,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; OpenStreetMap contributors';
// Selectable basemaps for the world (great-circle) map. All key-free and all
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
// overlay on top of an imagery basemap (so satellite keeps its names too).
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attr: CARTO_ATTR, subdomains: 'abcd' },
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics',
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
};
function loadBasemap(): BasemapKey {
const v = localStorage.getItem('opslog.mapBasemap');
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
}
function dot(color: string): L.DivIcon {
return L.divIcon({
className: '',
@@ -62,14 +73,24 @@ function dot(color: string): L.DivIcon {
});
}
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
interface WorldProps {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
}
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
const locatorMap = useRef<L.Map | null>(null);
// Layers we add/remove as the QSO changes (kept separate from the basemap).
const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
const baseLayer = useRef<L.TileLayer | null>(null);
const labelsLayer = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
@@ -83,86 +104,110 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
.setView([20, 0], 1);
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
// Restore the saved free-pan view when not auto-zooming.
const sv = loadMapView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
// Remember the view as the user pans/zooms (only meaningful when free).
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
}
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
// The Main tab may have just become visible — fix tile sizing.
const t = window.setTimeout(() => {
worldMap.current?.invalidateSize();
locatorMap.current?.invalidateSize();
}, 80);
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
// Redraw overlays whenever the operator/DX grids change.
// Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's
// overlayPane, always above any tile layer, so nothing to re-stack there.
useEffect(() => {
const wm = worldMap.current, lm = locatorMap.current;
const wo = worldOverlay.current, lo = locatorOverlay.current;
if (!wm || !lm || !wo || !lo) return;
const m = worldMap.current;
if (!m) return;
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
}, [basemap]);
// Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current;
if (!wm || !wo) return;
wo.clearLayers();
lo.clearLayers();
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
if (from && to) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
// line, which makes a smooth arc look angular/bumpy).
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
if (beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 9000; // lobe length (km)
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
const D = 5500; // lobe length (km)
// Great-circle radial out to distance D, stopping just short of the pole
// so a poleward line doesn't snap across the top of the Mercator map.
const radial = (b: number): [number, number][] => {
const out: [number, number][] = [];
const N = 64;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
out.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
}
return out;
};
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
const d = destinationPoint(from.lat, from.lon, b, D);
arc.push([d.lat, d.lon]);
// Draw the lobe as a FAN of translucent great-circle radials, not a
// filled polygon: a polygon breaks badly near the poles on Mercator
// (its edges run off toward ±90° and the fill smears across the map),
// while each radial LINE stays clean. The overlapping lines read as a
// lobe — solid near the antenna, fanning out toward the front. Works
// for any azimuth, north/south included.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
}
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
// Boresight (dashed centre line).
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
// Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
// next to the red radiating lobe(s).
if (boomAzimuth != null) {
const bpts: [number, number][] = [[from.lat, from.lon]];
const N = 64, D = 5500;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
bpts.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break;
}
if (from && to) {
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
// chosen (remembered) view so the beam heading stays visible.
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
if (autoZoom) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
} else if (autoZoom && to) {
@@ -170,31 +215,31 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
} else if (autoZoom && from) {
wm.setView([from.lat, from.lon], 3);
}
// ── Right: street map on the DX locator ──
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(lo);
const b = gridSquareBounds(toGrid);
if (b) {
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
} else if (from) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
const path = pathBetween(fromGrid, toGrid);
return (
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button
key={k}
type="button"
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
title={`Basemap: ${BASEMAPS[k].label}`}
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
}`}
>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */}
<button
@@ -204,8 +249,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m); // entering free mode → remember current view
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
if (!v && m) saveMapView(m);
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
@@ -223,7 +267,52 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
</div>
)}
</div>
<div className="relative isolate rounded-lg overflow-hidden border border-border">
);
}
interface LocatorProps {
toGrid: string; // contacted-station grid
toLabel?: string; // DX callsign
}
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
const locatorRef = useRef<HTMLDivElement>(null);
const locatorMap = useRef<L.Map | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
useEffect(() => {
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
const lm = locatorMap.current, lo = locatorOverlay.current;
if (!lm || !lo) return;
lo.clearLayers();
const to = gridToLatLon(toGrid);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(lo);
const b = gridSquareBounds(toGrid);
if (b) {
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
}
setTimeout(() => { lm.invalidateSize(); }, 0);
}, [toGrid, toLabel]);
return (
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
@@ -231,7 +320,5 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
</div>
)}
</div>
</div>
</div>
);
}
+80 -46
View File
@@ -1,20 +1,42 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]);
// Warm theme matching the other grids (Recent QSOs / Cluster).
const qslTheme = themeQuartz.withParams({
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
});
type UploadRow = {
id: number; qso_date: string; callsign: string;
band: string; mode: string; country: string; status: string;
};
const UPLOAD_COLS: ColDef<UploadRow>[] = [
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
{ field: 'band', headerName: 'Band', width: 90 },
{ field: 'mode', headerName: 'Mode', width: 100 },
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
];
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
@@ -23,6 +45,8 @@ type Confirmation = {
const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' },
{ v: 'hrdlog', label: 'HRDLog.net' },
{ v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' },
{ v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' },
@@ -77,6 +101,14 @@ export function fmtQslDate(s?: string): string {
// and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [service, setService] = useState('lotw');
// The callsign this profile signs/uploads/downloads as for the selected
// service (Force station callsign, else the profile call). Shown so the user
// knows WHICH of their calls a download/upload targets in a mixed-call log.
const [uploadCall, setUploadCall] = useState('');
useEffect(() => {
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
@@ -141,7 +173,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}
const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
const gridRef = useRef<AgGridReact<UploadRow>>(null);
const [selectedCount, setSelectedCount] = useState(0);
const selectAllNext = useRef(false); // selectAll once after the next data load
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
@@ -172,10 +207,20 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
return () => { offLog(); offDone(); offConf(); };
}, []);
const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload.
function onUploadSelChanged() {
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
}
// After "Select required" loads new rows, select them all (the old default).
function onUploadRowsLoaded() {
if (selectAllNext.current) {
selectAllNext.current = false;
gridRef.current?.api?.selectAll();
}
}
const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
@@ -192,32 +237,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[];
selectAllNext.current = true; // pre-select everything once the grid renders
setRows(list);
setSelected(new Set(list.map((x) => x.id)));
setSelectedCount(list.length);
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
setSelected(new Set());
setSelectedCount(0);
} finally {
setSearching(false);
}
}, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
}
function toggleAll() {
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
}
async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); }
@@ -251,6 +286,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
{uploadCall && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
<span
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
>
{uploadCall}
</span>
</div>
)}
{service === 'pota' ? (
<>
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
@@ -463,33 +509,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
<div className="h-full w-full">
<AgGridReact<UploadRow>
ref={gridRef}
theme={qslTheme}
rowData={rows}
columnDefs={UPLOAD_COLS}
defaultColDef={{ sortable: true, resizable: true, filter: true }}
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
onSelectionChanged={onUploadSelChanged}
onRowDataUpdated={onUploadRowsLoaded}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/>
</div>
)}
</div>
+31 -3
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -11,6 +11,8 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
};
@@ -18,13 +20,15 @@ type Props = {
const UPLOAD_TARGETS: { service: string; label: string }[] = [
{ service: 'qrz', label: 'Send to QRZ.com' },
{ service: 'clublog', label: 'Send to Club Log' },
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
{ service: 'eqsl', label: 'Send to eQSL.cc' },
{ service: 'lotw', label: 'Send to LoTW' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -80,9 +84,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</button>
)}
{onSendRecording && (
{(onSendRecording || onSendEQSL) && (
<>
<div className="my-1 border-t border-border" />
{onSendEQSL && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
>
<Mail className="size-4 text-amber-600" />
<span>Send OpsLog QSL by e-mail</span>
</button>
)}
{onSendRecording && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
@@ -90,6 +104,20 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
)}
</>
)}
{onBulkEdit && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
>
<PencilLine className="size-4 text-indigo-600" />
<span>Bulk edit field ({n})</span>
</button>
</>
)}
+15 -3
View File
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
if (!d) return '';
const date = new Date(d);
if (isNaN(date.getTime())) return '';
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
// doesn't apply to a time struct), so a QSO with no end time arrives as a
// year-1 date. Treat anything that old as unset — otherwise the datetime
// field shows a garbage value and fights the user's typing.
if (date.getUTCFullYear() <= 1) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
}
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
const [dateOff, setDateOff] = useState(dateOffISO);
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div>
<Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
const on = !!c;
setEndEnabled(on);
// Prefill an empty end with the start time so the user
// only tweaks the minutes instead of typing a full date.
if (on && !dateOff) setDateOff(dateOn);
}} /> QSO End (UTC)
</Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div>
+61 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -46,14 +46,19 @@ type Props = {
rows: QSOForm[];
total: number;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void;
onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
// One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -100,8 +105,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
@@ -149,6 +154,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -215,7 +222,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -241,10 +248,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins.
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
});
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: `${a.name} — reference this QSO counts for`,
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
@@ -270,12 +288,24 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
if (state) saveState(COL_STATE_KEY, state);
}, []);
// The award columns load asynchronously; when they arrive (or change) the
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
// default — wiping the user's saved visibility (award columns reappear,
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
if (!api) return;
const local = loadLocal(COL_STATE_KEY);
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
}, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onSelectionChanged() {
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
}
// ── Column picker (visibility) ──
@@ -361,6 +391,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered}
/>
@@ -401,6 +433,26 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
</div>
);
})}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
+31 -3
View File
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
@@ -36,7 +38,7 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -76,6 +78,18 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
: 'bg-emerald-200 text-emerald-900')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
@@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* antenna heading needle(s) — green; two when bidirectional */}
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
File diff suppressed because it is too large Load Diff
+24
View File
@@ -38,6 +38,10 @@ interface Props {
onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char
autoCall: boolean; // repeat the clicked macro on a timer
autoCallSecs: number; // gap (s) after the message before repeating
onToggleAutoCall: (on: boolean) => void;
onSetAutoCallSecs: (n: number) => void;
}
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) {
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
</Button>
</div>
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} />
Auto-call
</label>
<span className="text-[11px] text-muted-foreground">gap</span>
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
<input type="number" min={0} max={120}
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span>
</div>
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (
+38 -3
View File
@@ -52,6 +52,9 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -64,7 +67,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -92,10 +95,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
});
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: `${a.name} — reference this QSO counts for`,
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -238,6 +252,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
/>
{count > entries.length && (
@@ -281,6 +296,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div>
);
})}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
+113
View File
@@ -0,0 +1,113 @@
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
// log (auto-send on, recipient e-mail known, default template, not already
// sent) and emits "qsl:autosend"; this component does the part Go can't —
// render the card off-screen, rasterize it to JPEG and hand it back to
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
import { useCallback, useEffect, useRef, useState } from 'react';
import { EventsOn } from '../../../wailsjs/runtime/runtime';
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Job {
qsoId: number;
templateId: number;
callsign: string;
}
interface Props {
onSent?: (callsign: string) => void;
onError?: (message: string) => void;
}
export function AutoEQSL({ onSent, onError }: Props) {
const queue = useRef<Job[]>([]);
const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null);
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
// Keep the callbacks in refs so they never change the effects' identity — a
// toast/grid re-render from onSent must NOT re-run the send effect (that
// re-sent the same eQSL many times in a row).
const onSentRef = useRef(onSent);
const onErrorRef = useRef(onError);
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
// Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it).
const pump = useCallback(async () => {
if (busy.current) return;
const job = queue.current.shift();
if (!job) return;
busy.current = true;
try {
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets });
} catch (e) {
onErrorRef.current?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, []);
useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump();
});
return () => off();
}, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
// Sends exactly once per job (guarded by sentForRef), independent of renders.
useEffect(() => {
if (!current) return;
if (sentForRef.current === current.job.qsoId) return; // already sent this one
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
sentForRef.current = current.job.qsoId;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSentRef.current?.(current.job.callsign);
} catch (e) {
onErrorRef.current?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
busy.current = false;
void pump();
}
}
})();
return () => { cancelled = true; };
}, [current, pump]);
if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the
// editor preview exactly.
return (
<div
aria-hidden
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
>
<CardPreview
model={current.model}
assets={current.assets}
width={current.model.template.card.w}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
);
}
+554
View File
@@ -0,0 +1,554 @@
// CardPreview — the single SVG rendering implementation for the QSL card.
// Proposals, the live editor, thumbnails and final rasterization ALL go
// through this component so the output can never drift from the preview.
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
// section 4 of the design spec); the gel stack renders the text 7 times,
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
// gradient face, wavy gloss band clipped to the glyphs.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
import { QSO_FIELD_LABELS } from './qslTypes';
import type { CardAssets } from './qslAssets';
import { FONT_WEIGHTS } from './qslAssets';
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';
// An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null;
// Fraction of the font size between a glyph's em-box top and its cap line.
// Used to make a text element's y align to the visible letter tops.
const CAP_INSET = 0.18;
interface Props {
model: RenderModel;
assets: CardAssets;
width: number; // display width in CSS px
selected?: CardSelection;
onSelect?: (sel: CardSelection) => void;
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
svgRef?: (el: SVGSVGElement | null) => void;
}
// Fallbacks mirroring the preset defaults in presets.go, applied when a
// document omits a knob the stack needs.
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
// fontSpec maps a template font name to a concrete family + weight.
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
if (!font || font === 'system-bold-sans') {
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
}
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
}
// Presets whose call text is rendered with the ported canvas FX (the only way
// to get the exact glossy/western look). Everything else stays plain SVG text.
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
function fxKindFor(preset?: string): TextFxKind | null {
if (preset && FX_GLOSSY.has(preset)) return 'glossy';
if (preset && FX_WESTERN.has(preset)) return 'western';
return null;
}
function buildFxParams(e: CardElement): TextFxParams | null {
const kind = fxKindFor(e.style_preset);
if (!kind || !e.size) return null;
const fs = fontSpec(e.font);
const sp = e.style_params ?? {};
const fx = sp.fx ?? {};
const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
const silver = e.style_preset?.includes('silver');
return {
kind,
text: e.text ?? '',
weight: String(fs.weight === 'normal' ? 400 : fs.weight),
family: fs.family,
size: e.size,
space: e.size * (kind === 'western' ? 0.08 : 0.04),
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
// Dark inter-letter edge + rim — from the chosen colour palette, else the
// default near-black edge (the navy outline adaptStyle sets is for the old
// SVG stack, not this look).
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
// Per-call overrides from the editor (undefined → renderer default).
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
bevel: fx.bevel, seed: fx.seed,
};
}
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
// doesn't trigger an expensive re-render of the canvas text.
function fxSignature(e: CardElement): string {
if (!fxKindFor(e.style_preset)) return '';
return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
}
function elementTransform(e: CardElement): string {
const r = e.rotate ? ` rotate(${e.rotate})` : '';
return `translate(${e.x} ${e.y})${r}`;
}
// Shared <text> layer of a style stack. All layers must carry identical
// glyph geometry — only paint/transform/filter differ.
function Layer({
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
}: {
e: CardElement;
content: string;
fill: string;
stroke?: string;
strokeWidth?: number;
transform?: string;
filter?: string;
opacity?: number;
paintOrder?: 'stroke';
}) {
const f = fontSpec(e.font);
return (
<text
x={0}
y={0}
fontFamily={f.family}
fontWeight={f.weight}
fontSize={e.size}
dominantBaseline="text-before-edge"
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinejoin="round"
paintOrder={paintOrder}
transform={transform}
filter={filter}
opacity={opacity}
style={{ userSelect: 'none' }}
>
{content}
</text>
);
}
// glossWavePath builds the gel highlight band: full glyph width, bottom
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
// is what produces the "gel" look; the band is clipped to the glyphs.
function glossWavePath(e: CardElement, content: string, coverage: number): string {
const size = e.size ?? 100;
const w = 0.78 * size * Math.max(content.length, 1);
const capTop = 0.16 * size; // cap height starts below the em-box top
const bottom = capTop + coverage * 0.72 * size;
const amp = 0.045 * size;
const period = 0.25 * size;
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
let i = 0;
for (let x = w; x > 0.1; x -= period, i++) {
const x2 = Math.max(0, x - period);
const mid = (x + x2) / 2;
const dy = i % 2 === 0 ? amp : -amp;
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
}
return d + ' Z';
}
// TextStack renders one text element through its style preset.
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
const p: StyleParams = e.style_params ?? {};
const preset = e.style_preset ?? 'flat_modern';
const ow = p.outline_width ?? 0;
if (preset === 'flat_modern') {
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
}
if (preset === 'script_white' || preset === 'outlined_white') {
return (
<>
{p.shadow && (
<Layer
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
/>
)}
<Layer
e={e} content={content} fill={p.color ?? '#ffffff'}
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
/>
</>
);
}
// Gel family + classic white outline: the layered stack.
const gradient = p.gradient ?? GOLD;
const shadow = p.shadow ?? DEF_SHADOW;
const outline = p.outline_color ?? '#2a3f5c';
const outlineW = ow || 9;
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
const halo = p.halo ?? DEF_HALO;
const bevel = p.bevel_offset ?? DEF_BEVEL;
const shine = p.shine ?? DEF_SHINE;
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
return (
<>
{isGel && (
<Layer
e={e} content={content} fill="none" stroke={halo.color}
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
/>
)}
<Layer
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
transform={`translate(${shadow.dx} ${shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
/>
<Layer
e={e} content={content} fill={outline} stroke={outline}
strokeWidth={outlineW} paintOrder="stroke"
/>
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
{isGel && (
<Layer
e={e} content={content} fill={bevel.light}
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
/>
)}
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
{isGel && (
<path
d={glossWavePath(e, content, shine.coverage)}
fill={`url(#qsl-gloss-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={shine.opacity}
/>
)}
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
{isGrunge && (
<rect
x={0} y={0}
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
height={(e.size ?? 0) * 1.05}
fill="#241405"
filter={`url(#qsl-grunge-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={0.6}
/>
)}
{/* defs that belong to this stack (unique ids per element index) */}
<defs>
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
{gradient.map((c, i) => (
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
))}
</linearGradient>
{isGel && (
<>
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
</linearGradient>
<clipPath id={`qsl-clip-${idx}`}>
<text
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
fontSize={e.size} dominantBaseline="text-before-edge"
>
{content}
</text>
</clipPath>
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={halo.blur} />
</filter>
</>
)}
{isGrunge && (
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
{/* sparse dark specks: fractal noise → threshold into the alpha */}
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
<feColorMatrix in="n" type="matrix"
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
</filter>
)}
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={shadow.blur} />
</filter>
</defs>
</>
);
}
// QSO box field column weights — the date string is far wider than the rest,
// so equal columns let it spill into the next field. Wider weight = more room.
const QSO_FIELD_WEIGHT: Record<string, number> = {
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
};
// QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
const avail = box.w - 56;
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
let cursor = 28;
const cols = box.fields.map((f) => {
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
const col = { f, x: cursor, w };
cursor += w;
return col;
});
return (
<>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title}
</text>
{cols.map(({ f, x }) => (
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text>
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''}
</text>
</g>
))}
{box.footer && (
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
fontFamily="'Segoe UI', Arial, sans-serif">
{box.footer}
</text>
)}
</>
);
}
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
const { template: t, qso_fields } = model;
const card = t.card;
const scale = card.w / width;
const rootRef = useRef<SVGSVGElement | null>(null);
const groupRefs = useRef(new Map<string, SVGGElement>());
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
// Embedded fonts may still be loading when the card first mounts; the canvas
// FX needs them for correct metrics, so (re)render once they're ready.
const [fontsReady, setFontsReady] = useState(false);
useEffect(() => {
let alive = true;
const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
void ready.then(() => { if (alive) setFontsReady(true); });
return () => { alive = false; };
}, [assets]);
// Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
// so dragging doesn't regenerate it. Maps element index → PNG + size.
const fxSig = t.elements.map(fxSignature).join(';');
const fxImages = useMemo(() => {
const map = new Map<number, TextFxResult>();
if (!fontsReady) return map;
t.elements.forEach((e, idx) => {
const params = buildFxParams(e);
if (!params) return;
try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
});
return map;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fxSig, fontsReady]);
// Measure the selected group for the dashed selection outline. For plain SVG
// text we use the glyphs' real ink metrics — getBBox would span the font's
// full ascent/descent. FX call images fall through to getBBox (already tight).
useEffect(() => {
if (selected === null || selected === undefined) { setSelBox(null); return; }
const key = String(selected);
const g = groupRefs.current.get(key);
if (!g) { setSelBox(null); return; }
const transform = g.getAttribute('transform') ?? '';
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
const isFx = el ? !!fxKindFor(el.style_preset) : false;
if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
const cv = (measureCanvas.current ??= document.createElement('canvas'));
const ctx = cv.getContext('2d');
if (ctx) {
const f = fontSpec(el.font);
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
const m = ctx.measureText(el.text ?? '');
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
const left = -(m.actualBoundingBoxLeft ?? 0);
const right = m.actualBoundingBoxRight ?? m.width;
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
return;
}
}
const b = g.getBBox();
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
}, [selected, model, assets, width, t.elements, fxImages]);
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => {
onSelect?.(sel);
if (!onMove) return;
ev.stopPropagation();
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
};
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
const d = drag.current;
if (!d || !onMove) return;
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
onMove(d.sel, x, y);
};
const endDrag = () => { drag.current = null; };
const hero = assets.photos[t.hero.photo];
const crop = t.hero.crop;
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
if (el) groupRefs.current.set(key, el);
else groupRefs.current.delete(key);
};
return (
<svg
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
viewBox={`0 0 ${card.w} ${card.h}`}
width={width}
height={Math.round(width * (card.h / card.w))}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block', borderRadius: 4 }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerDown={() => onSelect?.(null)}
>
<defs>
{/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
canvas) sees them; document styles don't apply there. */}
<style>{assets.fontCss}</style>
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
</filter>
</defs>
{/* hero photo, source crop mapped onto the full card */}
<rect width={card.w} height={card.h} fill="#1a2433" />
{hero && crop.w > 0 && crop.h > 0 && (
<image
href={hero.url}
x={(-crop.x * card.w) / crop.w}
y={(-crop.y * card.h) / crop.h}
width={(hero.w * card.w) / crop.w}
height={(hero.h * card.h) / crop.h}
preserveAspectRatio="none"
/>
)}
{t.hero.scrim?.enabled && (
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
)}
{t.elements.map((e, idx) => {
const key = String(idx);
if (e.hidden) return null; // toggled off in the editor
if (e.type === 'insert') {
const ph = assets.photos[e.photo ?? ''];
if (!ph || !e.w) return null;
const h = (e.w * ph.h) / ph.w;
const b = e.border_px ?? 0;
return (
<g
key={key} ref={setGroupRef(key)}
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
</g>
);
}
if (e.type === 'country') {
const size = e.size ?? 30;
const flagH = size * 1.4;
const flagW = flagH * (4 / 3);
const hasFlag = !!assets.flagUrl;
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
<text
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
strokeLinejoin="round" dominantBaseline="middle"
>
{e.label === 'auto' ? '' : e.label}
</text>
</g>
);
}
// Text elements. Glossy/western calls render through the ported canvas
// FX as an <image> (its trimmed bounds already give a tight frame, so
// no CAP_INSET shim). Everything else is plain SVG text; the inner
// translate pulls the glyphs up to the em-box cap line so the element's
// y means "top of the visible letters".
const fx = fxImages.get(idx);
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
{fx ? (
<image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
) : (
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
)}
</g>
);
})}
{t.qso_box?.enabled && (
<g
ref={setGroupRef('box')}
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<QSOBoxView box={t.qso_box} values={qso_fields} />
</g>
)}
{/* selection outline (editor only) */}
{selBox && onSelect && (
<g transform={selBox.t} pointerEvents="none">
<rect
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
strokeDasharray={`${10 * scale} ${6 * scale}`}
/>
</g>
)}
</svg>
);
}
+188
View File
@@ -0,0 +1,188 @@
// EditorPanel — the designer's right-hand controls: card-level options
// (scrim, name, default) and per-selection editing (font, size, rotation,
// style preset + params; insert width/border; QSO box geometry).
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
interface Props {
template: CardTemplate;
sel: CardSelection;
presets: QSLPresetInfo[];
fontFamilies: string[]; // embedded + detected system faces
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
onSelect?: (sel: CardSelection) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country + flag',
insert: 'Photo insert',
};
// A readable layer name, disambiguating the two info lines and numbering inserts.
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
if (el.type === 'info_line') {
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
}
if (el.type === 'insert') {
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
return `Photo insert ${n}`;
}
return TYPE_LABELS[el.type] ?? el.type;
}
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fontFamilies.map((f) => (
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
onChange={(size) => onPatch(idx, { size })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
onChange={(rotate) => onPatch(idx, { rotate })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Text</Label>
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
</div>
<Separator className="my-2" />
<StylePresetPicker
presets={presets}
preset={e.style_preset ?? 'flat_modern'}
params={e.style_params ?? {}}
onChange={(style_preset, style_params: StyleParams) =>
onPatch(idx, { style_preset, style_params })}
/>
</div>
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={!!template.hero.scrim?.enabled}
onCheckedChange={(v) => onScrim(v === true)}
/>
Darken photo behind text (scrim)
</label>
</div>
{/* Layers: show/hide each piece, click a name to edit it. */}
<div className="space-y-1 rounded-md border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
{template.elements.map((el, i) => (
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
{layerLabel(el, i, template.elements)}
</button>
</div>
))}
{box && (
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
QSO info box
</button>
</div>
)}
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
</div>
{!e && sel !== 'box' && (
<p className="text-xs text-muted-foreground">
Click an element on the card to edit it; drag to move it.
</p>
)}
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
<TextControls e={e} idx={sel as number} presets={presets}
fontFamilies={fontFamilies} onPatch={onPatchElement} />
)}
{e && e.type === 'insert' && (
<div className="space-y-2">
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
onChange={(w) => onPatchElement(sel as number, { w })} />
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!e.shadow}
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
Drop shadow
</label>
</div>
)}
{e && e.type === 'country' && (
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
onChange={(size) => onPatchElement(sel as number, { size })} />
)}
{sel === 'box' && box && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={box.enabled}
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
Show QSO box
</label>
<NumberField label="Width" value={box.w} min={300} max={1400}
onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500}
onChange={(h) => onPatchBox({ h })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
</div>
</div>
)}
</div>
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders they fill in
automatically on every card.
</p>
</div>
);
}
@@ -0,0 +1,380 @@
// QslDesignerModal — the QSL card designer. Flow: drop/choose photos →
// "Generate designs" (3 automatic proposals from the placement engine) →
// pick one → live editor (click/drag elements, style controls) → save.
// Saved templates are listed with thumbnails and can be edited, deleted or
// marked default (used by "Send eQSL by e-mail" on the QSO grid).
import { useEffect, useRef, useState } from 'react';
import { Images, Loader2, Sparkles, Star, Trash2, Pencil, ChevronLeft } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
} from '../../../wailsjs/go/main/App';
import type {
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
} from './qslTypes';
import { loadCardAssets, loadFonts, type CardAssets } from './qslAssets';
import { CardPreview, type CardSelection } from './CardPreview';
import { EditorPanel } from './EditorPanel';
import { rasterizeCard } from './rasterize';
interface Props {
open: boolean;
onClose: () => void;
}
interface Proposal {
template: CardTemplate;
model: RenderModel;
assets: CardAssets;
}
interface Editing {
templateId: number; // 0 = new (photos still at their original paths)
template: CardTemplate; // raw document ({placeholders})
model: RenderModel; // resolved copy for display
assets: CardAssets;
}
type View = 'home' | 'proposals' | 'editor';
async function resolveModel(template: CardTemplate): Promise<RenderModel> {
return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel;
}
export function QslDesignerModal({ open, onClose }: Props) {
const [view, setView] = useState<View>('home');
const [photoPaths, setPhotoPaths] = useState<string[]>([]);
const [proposals, setProposals] = useState<Proposal[]>([]);
const [editing, setEditing] = useState<Editing | null>(null);
const [sel, setSel] = useState<CardSelection>(null);
const [name, setName] = useState('');
const [asDefault, setAsDefault] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
const [previews, setPreviews] = useState<Record<number, string>>({});
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
const [deleteArm, setDeleteArm] = useState(0);
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!open) return;
setView('home');
setError('');
setPhotoPaths([]);
setProposals([]);
setEditing(null);
void refreshSaved();
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
void loadFonts().then(({ fonts }) =>
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
}, [open]);
async function refreshSaved() {
try {
const list = ((await QSLListTemplates()) ?? []) as QSLTemplateInfo[];
setSaved(list);
const p: Record<number, string> = {};
await Promise.all(list.map(async (t) => {
const url = await QSLPreviewDataURL(t.id);
if (url) p[t.id] = url;
}));
setPreviews(p);
} catch (e) {
setError(String(e));
}
}
async function choosePhotos() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 5));
} catch (e) {
setError(String(e));
}
}
async function generate() {
setBusy(true);
setError('');
try {
const docs = (await QSLGenerateProposals(photoPaths)) as string[];
const out: Proposal[] = [];
for (const doc of docs) {
const template = JSON.parse(doc) as CardTemplate;
const model = await resolveModel(template);
const assets = await loadCardAssets(model.template, 0);
out.push({ template, model, assets });
}
setProposals(out);
setView('proposals');
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
function openEditor(templateId: number, template: CardTemplate, model: RenderModel, assets: CardAssets) {
setEditing({ templateId, template, model, assets });
setName(template.name);
setAsDefault(false);
setSel(null);
setError('');
setView('editor');
}
async function editSaved(info: QSLTemplateInfo) {
setBusy(true);
setError('');
try {
const template = JSON.parse(await QSLGetTemplate(info.id)) as CardTemplate;
const model = await resolveModel(template);
const assets = await loadCardAssets(model.template, info.id);
openEditor(info.id, template, model, assets);
setAsDefault(info.is_default);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
// Edits patch the raw template AND the resolved display copy in lock-step;
// only a text change needs a backend re-resolve (placeholders may differ).
function patchBoth(fn: (t: CardTemplate) => void, reResolve = false) {
setEditing((cur) => {
if (!cur) return cur;
const template = structuredClone(cur.template);
const model = structuredClone(cur.model);
fn(template);
fn(model.template);
if (reResolve) {
void resolveModel(template).then((m) =>
setEditing((c) => (c ? { ...c, model: m } : c)));
}
return { ...cur, template, model };
});
}
const patchElement = (idx: number, patch: Partial<CardElement>) =>
patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch);
const patchBox = (patch: Partial<QSOBox>) =>
patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch);
const onMove = (target: Exclude<CardSelection, null>, x: number, y: number) =>
patchBoth((t) => {
if (target === 'box') {
if (t.qso_box) { t.qso_box.x = x; t.qso_box.y = y; }
} else {
t.elements[target].x = x;
t.elements[target].y = y;
}
});
const onScrim = (enabled: boolean) =>
patchBoth((t) => {
t.hero.scrim = { enabled, color: t.hero.scrim?.color ?? '#0b1a2e', opacity: t.hero.scrim?.opacity ?? 0.25 };
});
async function save() {
if (!editing) return;
setBusy(true);
setError('');
try {
const id = await QSLSaveTemplate(editing.templateId, name, JSON.stringify(editing.template), true);
if (svgEl.current) {
const card = editing.template.card;
const png = await rasterizeCard(svgEl.current, 480, Math.round(480 * (card.h / card.w)), 'image/png');
await QSLSavePreview(id, png);
}
if (asDefault) await QSLSetDefaultTemplate(id);
await refreshSaved();
setView('home');
setEditing(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function removeTemplate(id: number) {
if (deleteArm !== id) { setDeleteArm(id); return; }
setDeleteArm(0);
try {
await QSLDeleteTemplate(id);
await refreshSaved();
} catch (e) {
setError(String(e));
}
}
async function makeDefault(id: number) {
try {
await QSLSetDefaultTemplate(id);
await refreshSaved();
} catch (e) {
setError(String(e));
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[1260px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-amber-500" />
QSL card designer
{view !== 'home' && (
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
onClick={() => setView(view === 'editor' && proposals.length && editing?.templateId === 0 ? 'proposals' : 'home')}>
<ChevronLeft className="size-3.5" /> Back
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
{error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
)}
{view === 'home' && (
<div className="space-y-5">
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground">
Pick 15 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically.
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={choosePhotos}>
<Images className="mr-1 size-4" /> Choose photos
</Button>
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
Generate designs
</Button>
{photoPaths.length > 0 && (
<span className="text-xs text-muted-foreground">
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
</span>
)}
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Saved templates</h3>
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
<div className="grid grid-cols-3 gap-3">
{saved.map((t) => (
<div key={t.id} className="rounded-md border border-border p-2">
{previews[t.id]
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
<div className="mt-1.5 flex items-center justify-between gap-1">
<span className="truncate text-sm font-medium">
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
{t.name}
</span>
<span className="flex shrink-0 gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
onClick={() => editSaved(t)}>
<Pencil className="size-3.5" />
</Button>
{!t.is_default && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
onClick={() => makeDefault(t.id)}>
<Star className="size-3.5" />
</Button>
)}
<Button variant="ghost" size="sm"
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
title="Delete" onClick={() => removeTemplate(t.id)}>
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
</Button>
</span>
</div>
</div>
))}
</div>
</section>
<p className="text-xs text-muted-foreground">
The eQSL e-mail message and the auto-send option are in Settings E-mail (SMTP).
</p>
</div>
)}
{view === 'proposals' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
<div className="grid grid-cols-3 gap-3">
{proposals.map((p, i) => (
<button
key={i}
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
onClick={() => openEditor(0, p.template, p.model, p.assets)}
>
<CardPreview model={p.model} assets={p.assets} width={352} />
</button>
))}
</div>
</div>
)}
{view === 'editor' && editing && (
<div className="flex gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="rounded-md bg-slate-800/60 p-3">
<CardPreview
model={editing.model}
assets={editing.assets}
width={760}
selected={sel}
onSelect={setSel}
onMove={onMove}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Name</Label>
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Vietnam sunset" />
<label className="flex items-center gap-1.5 text-sm">
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
Default for this profile
</label>
<div className="flex-1" />
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
</Button>
</div>
</div>
<EditorPanel
template={editing.template}
sel={sel}
presets={presets}
fontFamilies={fontFamilies}
onPatchElement={patchElement}
onPatchBox={patchBox}
onScrim={onScrim}
onSelect={setSel}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,126 @@
// SendEQSLModal — per-QSO eQSL flow: render the default template with the
// QSO's data, show the exact card, send it as a JPEG e-mail attachment on
// explicit confirmation (never automatic). On success the backend stamps
// eqsl_sent and emits "qsl:sent" so the grid refreshes.
import { useEffect, useRef, useState } from 'react';
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
QSLDefaultTemplateID, RenderEQSL, SendEQSL,
} from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Props {
open: boolean;
qsoId: number | null;
onClose: () => void;
onOpenDesigner: () => void;
}
export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
const [model, setModel] = useState<RenderModel | null>(null);
const [assets, setAssets] = useState<CardAssets | null>(null);
const [templateId, setTemplateId] = useState(0);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!open || !qsoId) return;
setModel(null);
setAssets(null);
setSent(false);
setError('');
void (async () => {
try {
const tid = await QSLDefaultTemplateID();
setTemplateId(tid);
if (!tid) return; // no template yet — offer the designer below
const m = JSON.parse(await RenderEQSL(qsoId, tid)) as RenderModel;
setModel(m);
setAssets(await loadCardAssets(m.template, tid));
} catch (e) {
setError(String(e));
}
})();
}, [open, qsoId]);
async function send() {
if (!qsoId || !model || !svgEl.current) return;
setBusy(true);
setError('');
try {
const card = model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(qsoId, templateId, jpeg);
setSent(true);
// Auto-close shortly after the success tick (the grid refreshes on the
// qsl:sent event); the user doesn't need to dismiss it manually.
window.setTimeout(onClose, 1100);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[820px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
</DialogTitle>
</DialogHeader>
<div className="space-y-3 px-6 py-5">
{error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
)}
{open && !templateId && !error && (
<div className="space-y-3 py-2 text-sm">
<p>No QSL template yet design one first (drop a few photos, pick a layout).</p>
<Button size="sm" onClick={() => { onClose(); onOpenDesigner(); }}>Open the designer</Button>
</div>
)}
{templateId > 0 && !model && !error && (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Preparing the card
</div>
)}
{model && assets && (
<div className="rounded-md bg-slate-800/60 p-3">
<CardPreview model={model} assets={assets} width={740}
svgRef={(el) => { svgEl.current = el; }} />
</div>
)}
</div>
<DialogFooter>
{sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div>
) : (
<>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" disabled={!model || busy} onClick={send}>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Send
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
// StylePresetPicker — preset choice + per-preset parameter controls for the
// selected text element. Only the knobs the preset declares (params) are
// shown, matching the backend whitelist so saving can't fail on a stray key.
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
interface Props {
presets: QSLPresetInfo[];
preset: string;
params: StyleParams;
onChange: (preset: string, params: StyleParams) => void;
}
// Quick colour palettes per FX family (mirrors the reference generators):
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
const GLOSSY_PALETTES: Palette[] = [
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
];
const WESTERN_PALETTES: Palette[] = [
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
];
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<input
type="color"
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function SliderRow({ label, value, min, max, step, onChange }: {
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
</div>
);
}
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
const info = presets.find((p) => p.name === preset);
const has = (k: string) => info?.params.includes(k) ?? false;
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch });
// gel_* presets are rendered by the canvas FX, not the SVG stack: show the FX
// knobs instead of the old shine/halo/shadow/outline rows.
const isFx = preset.startsWith('gel_');
const fxWestern = preset.includes('grunge');
const fx: FxParams = params.fx ?? {};
const setFx = (patch: Partial<FxParams>) => set({ fx: { ...fx, ...patch } });
return (
<div className="space-y-2">
<Select
value={preset}
onValueChange={(name) => {
const next = presets.find((p) => p.name === name);
// Switching presets resets the params to that preset's defaults —
// stale keys from the previous preset would be rejected on save.
onChange(name, next ? { ...next.defaults } : {});
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{presets.map((p) => (
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{isFx && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Palette</Label>
<div className="flex flex-wrap gap-1">
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
<button
key={p.name} type="button" title={p.name}
className="h-6 w-6 rounded border border-border"
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
onClick={() => onChange(preset, {
...params,
gradient: [p.top, p.mid, p.bot],
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
})}
/>
))}
</div>
</div>
)}
{has('color') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)}
{has('gradient') && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Gradient</Label>
<div className="flex gap-1">
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
<input
key={i} type="color" value={c}
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
onChange={(e) => {
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
g[i] = e.target.value;
set({ gradient: g });
}}
/>
))}
</div>
</div>
)}
{!isFx && has('outline_color') && (
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
onChange={(v) => set({ outline_color: v })} />
)}
{!isFx && has('outline_width') && (
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
onChange={(v) => set({ outline_width: v })} />
)}
{!isFx && has('shine') && (
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
)}
{!isFx && has('halo') && (
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
onChange={(v) => set({
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
})} />
)}
{!isFx && has('shadow') && (
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => set({
shadow: {
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
},
})} />
)}
{isFx && !fxWestern && (
<>
<SliderRow label="Plump" value={fx.plump ?? 0.03} min={0} max={0.06} step={0.005}
onChange={(v) => setFx({ plump: v })} />
<SliderRow label="Gloss" value={fx.gloss ?? 0.85} min={0} max={1} step={0.05}
onChange={(v) => setFx({ gloss: v })} />
<SliderRow label="Gloss height" value={fx.gloss_h ?? 0.45} min={0.2} max={0.8} step={0.05}
onChange={(v) => setFx({ gloss_h: v })} />
<SliderRow label="Inner shadow" value={fx.inner_b ?? 0.7} min={0} max={1} step={0.05}
onChange={(v) => setFx({ inner_b: v })} />
<SliderRow label="Dark edge" value={fx.edge ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => setFx({ edge: v })} />
<SliderRow label="Silver rim" value={fx.outerw ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ outerw: v })} />
</>
)}
{isFx && fxWestern && (
<>
<SliderRow label="3D depth" value={fx.depth ?? 12} min={0} max={40} step={1}
onChange={(v) => setFx({ depth: v })} />
<SliderRow label="Slant" value={fx.slant ?? 0.08} min={-0.4} max={0.4} step={0.02}
onChange={(v) => setFx({ slant: v })} />
<SliderRow label="Grunge" value={fx.grunge ?? 0.8} min={0} max={1} step={0.05}
onChange={(v) => setFx({ grunge: v })} />
<SliderRow label="Top light" value={fx.bevel ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ bevel: v })} />
</>
)}
</div>
);
}
// Tiny labelled numeric input shared by the designer's right panel.
export function NumberField({ label, value, onChange, min, max }: {
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Asset loading for the QSL card renderer: designer fonts (registered as
// @font-face AND kept as CSS text so rasterization can inline them into the
// serialized SVG — an SVG drawn through an <img> cannot see document fonts),
// photos with their natural dimensions, and the country flag.
import { QSLFonts, QSLPhotoDataURL, QSLFlagDataURL } from '../../../wailsjs/go/main/App';
import type { CardTemplate, QSLFontInfo } from './qslTypes';
export interface PhotoAsset {
url: string; // data URL
w: number; // natural pixel size
h: number;
}
export interface CardAssets {
photos: Record<string, PhotoAsset>; // keyed by template photo ref
flagUrl: string; // data URL, '' when no flag
fontCss: string; // @font-face rules with data: sources
}
// Weight each embedded family renders at (variable fonts need an explicit
// heavy weight to match the printed-card look).
export const FONT_WEIGHTS: Record<string, number> = {
'Baloo 2': 800,
Oswald: 700,
};
let fontsPromise: Promise<{ css: string; fonts: QSLFontInfo[] }> | null = null;
// loadFonts fetches the embedded fonts once, registers them with the
// document and returns the @font-face CSS for SVG embedding.
export function loadFonts(): Promise<{ css: string; fonts: QSLFontInfo[] }> {
if (!fontsPromise) {
fontsPromise = (async () => {
const fonts = (await QSLFonts()) as QSLFontInfo[];
const rules = fonts.map((f) => {
const weight = f.variable ? '100 900' : 'normal';
return `@font-face{font-family:'${f.family}';src:url(data:font/ttf;base64,${f.data_b64}) format('truetype');font-weight:${weight};}`;
});
const css = rules.join('\n');
const el = document.createElement('style');
el.dataset.qslFonts = '1';
el.textContent = css;
document.head.appendChild(el);
await document.fonts.ready;
return { css, fonts };
})();
}
return fontsPromise;
}
async function imageSize(url: string): Promise<{ w: number; h: number }> {
const img = new Image();
img.src = url;
await img.decode();
return { w: img.naturalWidth, h: img.naturalHeight };
}
// Photos are multi-MB base64 payloads; cache them so the three proposals
// (which share the same photo set) and editor re-renders fetch each once.
const photoCache = new Map<string, Promise<PhotoAsset>>();
function loadPhoto(templateId: number, ref: string): Promise<PhotoAsset> {
const key = `${templateId}:${ref}`;
let p = photoCache.get(key);
if (!p) {
p = (async () => {
const url = await QSLPhotoDataURL(templateId, ref);
const size = await imageSize(url);
return { url, ...size };
})();
photoCache.set(key, p);
}
return p;
}
// loadCardAssets resolves everything a template needs to render: each
// referenced photo as a data URL with its natural size, the flag and the
// font CSS. templateId 0 reads draft photos at their original paths.
export async function loadCardAssets(template: CardTemplate, templateId: number): Promise<CardAssets> {
const refs = new Set<string>([template.hero.photo]);
for (const e of template.elements) {
if (e.type === 'insert' && e.photo) refs.add(e.photo);
}
const photos: Record<string, PhotoAsset> = {};
await Promise.all(
[...refs].map(async (ref) => {
photos[ref] = await loadPhoto(templateId, ref);
}),
);
let flagUrl = '';
const country = template.elements.find((e) => e.type === 'country');
if (country?.flag && country.flag !== 'auto') {
flagUrl = await QSLFlagDataURL(country.flag);
}
const { css } = await loadFonts();
return { photos, flagUrl, fontCss: css };
}
+175
View File
@@ -0,0 +1,175 @@
// TypeScript mirror of the QSL template JSON schema (v1) defined in
// internal/qslcard/template.go. Template documents cross the Wails boundary
// as JSON strings; these types are the frontend's contract with that schema.
export interface CardGeom {
w: number;
h: number;
dpi: number;
bleed_px: number;
}
export interface CropRect {
x: number;
y: number;
w: number;
h: number;
}
export interface Scrim {
enabled: boolean;
color: string;
opacity: number;
}
export interface Hero {
photo: string;
crop: CropRect;
scrim?: Scrim;
}
export interface Shine {
coverage: number;
opacity: number;
}
export interface HaloFx {
color: string;
blur: number;
opacity: number;
}
export interface ShadowFx {
dx: number;
dy: number;
blur: number;
color: string;
opacity: number;
}
export interface BevelFx {
dx: number;
dy: number;
dark: string;
light: string;
}
// FxParams — per-call tuning of the canvas renderer (textFx.ts). All optional;
// omitted values fall back to the renderer's per-preset default.
export interface FxParams {
plump?: number;
edge?: number;
outerw?: number;
gloss?: number;
gloss_h?: number;
gloss_i?: number;
inner_b?: number;
depth?: number;
angle?: number;
slant?: number;
grunge?: number;
bevel?: number;
seed?: number;
dark?: string;
outer?: string;
}
export interface StyleParams {
gradient?: string[];
shine?: Shine;
outline_color?: string;
outline_width?: number;
halo?: HaloFx;
shadow?: ShadowFx;
bevel_offset?: BevelFx;
color?: string;
fx?: FxParams;
}
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
export interface CardElement {
type: ElementType;
x: number;
y: number;
rotate?: number;
hidden?: boolean; // toggled off in the editor
// text elements
text?: string;
font?: string;
size?: number;
style_preset?: string;
style_params?: StyleParams;
// country
flag?: string;
label?: string;
// insert
photo?: string;
w?: number;
border_px?: number;
shadow?: boolean;
}
export interface QSOBox {
enabled: boolean;
x: number;
y: number;
w: number;
h: number;
bg: string;
bg_opacity: number;
radius: number;
title: string;
fields: string[];
footer: string;
}
export interface CardTemplate {
schema: number;
name: string;
card: CardGeom;
hero: Hero;
elements: CardElement[];
qso_box?: QSOBox;
}
// RenderModel mirrors qslcard.RenderModel: a template with placeholders
// resolved plus the values for the QSO box fields.
export interface RenderModel {
template: CardTemplate;
qso_fields: Record<string, string>;
}
// Binding result types (see app_qsl_designer.go).
export interface QSLTemplateInfo {
id: number;
name: string;
profile_id?: number;
is_default: boolean;
updated_at: string;
}
export interface QSLFontInfo {
family: string;
kind: 'display' | 'script' | 'system';
variable: boolean;
data_b64: string;
}
export interface QSLPresetInfo {
name: string;
label: string;
params: string[];
defaults: StyleParams;
}
// Human labels for the QSO box fields the renderer knows.
export const QSO_FIELD_LABELS: Record<string, string> = {
qso_date: 'Date',
time_on: 'UTC',
band: 'Band',
mode: 'Mode',
rst_sent: 'RST',
freq: 'Freq',
submode: 'Submode',
};
+52
View File
@@ -0,0 +1,52 @@
// Rasterizes the CardPreview SVG to bitmap bytes. The SVG is serialized and
// loaded through an <img>, so every resource it needs (fonts, photos, flag)
// must be inline data URLs — CardPreview guarantees that. Fonts are awaited
// before drawing so glyphs never rasterize with a fallback face.
// rasterizeCard returns base64 image bytes (no data: prefix).
// type 'image/jpeg' targets e-mail (maxBytes enforced by stepping quality
// down); 'image/png' is used for template thumbnails.
export async function rasterizeCard(
svgEl: SVGSVGElement,
outW: number,
outH: number,
type: 'image/jpeg' | 'image/png',
maxBytes = 800 * 1024,
): Promise<string> {
await document.fonts.ready;
const clone = svgEl.cloneNode(true) as SVGSVGElement;
clone.setAttribute('width', String(outW));
clone.setAttribute('height', String(outH));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d context unavailable');
if (type === 'image/jpeg') {
ctx.fillStyle = '#ffffff'; // JPEG has no alpha — avoid black background
ctx.fillRect(0, 0, outW, outH);
}
ctx.drawImage(img, 0, 0, outW, outH);
if (type === 'image/png') {
return canvas.toDataURL('image/png').split(',')[1];
}
// Step the JPEG quality down until the e-mail size target is met.
for (const q of [0.9, 0.8, 0.7, 0.6]) {
const b64 = canvas.toDataURL('image/jpeg', q).split(',')[1];
if (b64.length * 0.75 <= maxBytes) return b64;
}
return canvas.toDataURL('image/jpeg', 0.5).split(',')[1];
} finally {
URL.revokeObjectURL(url);
}
}
+576
View File
@@ -0,0 +1,576 @@
// Canvas-2D call-text renderer, ported faithfully from the user's two
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
// SVG filters can only approximate these looks; the exact technique is
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
// boolean compositing, a candy gloss that hugs the outline, an orange inner
// shadow, and organic multi-layer grunge.
//
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
// embeds that as an <image> so the same output drives the editor preview,
// thumbnails and the final e-mail rasterization.
export type TextFxKind = 'glossy' | 'western';
export interface TextFxParams {
kind: TextFxKind;
text: string;
weight: string; // canvas font weight, e.g. "800" or "400"
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
size: number; // font size in card px
space: number; // letter spacing in px
cTop: string;
cMid: string;
cBot: string;
cDark: string; // dark edge / outline
cOuter?: string; // silver rim (glossy only)
// glossy
plump?: number; // 0..0.06 — inflation as a fraction of size
edge?: number; // 0..1 — dark edge weight
outerw?: number; // 0..1 — silver rim weight
gloss?: number; // 0..1 — gloss intensity
glossH?: number; // 0..1 — gloss height
glossI?: number; // 0..0.14 — gloss inset
innerB?: number; // 0..1 — bottom inner shadow
// western
depth?: number; // 3D extrusion depth in px
angle?: number; // extrusion direction in radians
slant?: number; // shear, -0.4..0.4
grunge?: number; // 0..1 — distress amount
bevel?: number; // 0..1 — top-edge light
seed?: number;
}
export interface TextFxResult {
url: string; // PNG data URL of the trimmed text
w: number; // logical (card-px) width
h: number; // logical (card-px) height
}
const SS = 2; // supersample for crisp downscaling
function supportsFilter(c: CanvasRenderingContext2D) {
return typeof c.filter === 'string';
}
function shade(hex: string, pct: number) {
const n = parseInt(hex.slice(1), 16);
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
}
function deepen(hex: string) {
const n = parseInt(hex.slice(1), 16);
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
}
function rustOf(hex: string) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.round((n >> 16) * 0.72));
const g = Math.round(((n >> 8) & 255) * 0.49);
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function mulberry32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s: string) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const plumpPx = p.size * p.plump;
const edgeW = p.size * 0.1 * p.edge;
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad, y = pad + asc;
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'round';
};
const glyph = (delta: number, dx?: number, dy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.strokeStyle = '#fff';
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
if (delta > 0.1) {
g.lineWidth = delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
} else if (delta < -0.1) {
g.globalCompositeOperation = 'destination-out';
g.lineWidth = -delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
}
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
const k = glyph(plumpPx, 0, 0);
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
g.restore();
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
};
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalCompositeOperation = 'destination-in';
const gr = g.createLinearGradient(0, top, 0, bot);
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
g.fillStyle = gr;
g.fillRect(0, 0, k.width / scale, k.height / scale);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
if (alpha < 1) {
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
}
return k;
};
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
cx.fillStyle = paint;
cx.fillText(p.text, x, y);
if (plumpPx > 0.1) {
cx.strokeStyle = paint;
cx.lineWidth = plumpPx * 2;
cx.strokeText(p.text, x, y);
}
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(10,15,25,0.40)';
c.shadowBlur = p.size * 0.045 * scale;
c.shadowOffsetY = p.size * 0.03 * scale;
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
c.strokeStyle = 'rgba(10,15,25,0.40)';
c.strokeText(p.text, x, y);
c.restore();
const face = mk();
const f = face.getContext('2d')!;
setT(f);
if (p.outerw > 0) {
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
gOuter.addColorStop(0, '#ffffff');
gOuter.addColorStop(0.45, p.cOuter);
gOuter.addColorStop(1, shade(p.cOuter, -22));
f.lineWidth = outerW;
f.strokeStyle = gOuter;
f.strokeText(p.text, x, y);
}
if (p.edge > 0) {
f.lineWidth = edgeW + 2 * plumpPx;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
}
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.55, p.cMid);
gBody.addColorStop(1, p.cBot);
paintText(f, gBody);
if (p.innerB > 0) {
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
band = blurred(band, p.size * 0.04);
band = clipTo(band, glyph(plumpPx, 0, 0));
band = vRamp(band, 0.45, 0.8, false);
band = tint(band, deepen(p.cBot), 0.7);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, p.innerB);
f.drawImage(band, 0, 0);
f.restore();
}
if (p.gloss > 0) {
const shiftY = p.glossH * capH;
const shiftX = 0.12 * shiftY;
const inset = p.size * p.glossI;
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
let band = contourBand(shiftX, shiftY, inset);
band = vRamp(band, clip0, clip1, true);
let halo = blurred(band, p.size * 0.014);
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
let core = mk();
core.getContext('2d')!.drawImage(band, 0, 0);
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
core = blurred(core, p.size * 0.007);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
f.drawImage(halo, 0, 0);
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
f.drawImage(core, 0, 0);
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
const sw = Math.max(2, Math.round(W / featurePx));
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sg = small.getContext('2d')!;
const id = sg.createImageData(sw, sh);
const rnd = mulberry32(seed);
for (let i = 0; i < sw * sh; i++) {
const v = (rnd() * 255) | 0;
id.data[i * 4] = v;
id.data[i * 4 + 1] = v;
id.data[i * 4 + 2] = v;
id.data[i * 4 + 3] = 255;
}
sg.putImageData(id, 0, 0);
const big = document.createElement('canvas');
big.width = W;
big.height = H;
const bg = big.getContext('2d')!;
bg.imageSmoothingEnabled = true;
bg.imageSmoothingQuality = 'high';
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
bg.drawImage(small, 0, 0, W, H);
if (supportsFilter(bg)) bg.filter = 'none';
const src = bg.getImageData(0, 0, W, H);
const out = bg.createImageData(W, H);
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
for (let i = 0; i < W * H; i++) {
const v = src.data[i * 4] / 255;
let a = (v - thresh) * k;
a = a < 0 ? 0 : a > 1 ? 1 : a;
out.data[i * 4] = 255;
out.data[i * 4 + 1] = 255;
out.data[i * 4 + 2] = 255;
out.data[i * 4 + 3] = (a * 255) | 0;
}
const mask = document.createElement('canvas');
mask.width = W;
mask.height = H;
const mg = mask.getContext('2d')!;
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
const tmp = document.createElement('canvas');
tmp.width = W;
tmp.height = H;
tmp.getContext('2d')!.putImageData(out, 0, 0);
mg.drawImage(tmp, 0, 0);
if (supportsFilter(mg)) mg.filter = 'none';
return mask;
}
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const edgeW = p.size * 0.07;
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
const slantPad = Math.abs(p.slant) * capH;
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
const top = y - asc, bot = y + desc;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
};
const glyph = (dxx?: number, dyy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(15,8,4,0.50)';
c.shadowBlur = p.size * 0.035 * scale;
c.shadowOffsetX = p.size * 0.02 * scale;
c.shadowOffsetY = p.size * 0.035 * scale;
c.lineWidth = edgeW;
c.strokeStyle = 'rgba(15,8,4,0.50)';
c.fillStyle = 'rgba(15,8,4,0.50)';
c.strokeText(p.text, x, y);
c.fillText(p.text, x, y);
c.restore();
// 3D extrusion
if (p.depth > 0) {
const deepCol = shade(p.cDark, -4);
c.lineWidth = edgeW;
const steps = Math.ceil(p.depth);
for (let i = steps; i >= 1; i--) {
const t = i / steps;
c.strokeStyle = deepCol;
c.fillStyle = deepCol;
c.strokeText(p.text, x + dx * t, y + dy * t);
c.fillText(p.text, x + dx * t, y + dy * t);
}
}
const face = mk();
const f = face.getContext('2d')!;
setT(f);
f.lineWidth = edgeW;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.5, p.cMid);
gBody.addColorStop(1, p.cBot);
f.fillStyle = gBody;
f.fillText(p.text, x, y);
const M = glyph(0, 0);
if (p.bevel > 0) {
let band = glyph(0, 0);
const g = band.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
g.restore();
band = blurred(band, p.size * 0.008);
band = clipTo(band, M);
band = tintAlpha(band, '#ffeba8', p.bevel);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.drawImage(band, 0, 0);
f.restore();
}
if (p.grunge > 0) {
const W = canvas.width, H = canvas.height;
const S = p.size * scale;
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
const rust = rustOf(p.cBot);
const layers = [
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
];
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
for (const L of layers) {
let k = clipTo(L.mask, M);
k = tintAlpha(k, L.color, L.a * p.grunge);
f.drawImage(k, 0, 0);
}
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// Trim transparent padding so the element's x/y maps to the visible glyphs.
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
const ctx = canvas.getContext('2d')!;
const W = canvas.width, H = canvas.height;
const data = ctx.getImageData(0, 0, W, H).data;
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (data[(y * W + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX) return { canvas, w: W, h: H };
const cw = maxX - minX + 1, ch = maxY - minY + 1;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
return { canvas: out, w: cw, h: ch };
}
export function renderTextFx(p: TextFxParams): TextFxResult {
let raw: HTMLCanvasElement;
if (p.kind === 'western') {
raw = renderWestern(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
},
SS,
);
} else {
raw = renderGlossy(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
},
SS,
);
}
const t = trim(raw);
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
}
+13 -5
View File
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false,
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
}: {
value: string;
onChange: (v: string) => void;
@@ -14,6 +14,10 @@ export function Combobox({
placeholder?: string;
className?: string;
allowFreeText?: boolean;
// Commit each keystroke to the parent immediately (not just on blur). Use for
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
@@ -41,9 +45,13 @@ export function Combobox({
// Defer so a click on an option registers first.
setTimeout(() => {
setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
if (exact) { onChange(exact); setQuery(exact); }
else if (allowFreeText) { onChange(query.trim()); }
const trimmed = query.trim();
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
// Only fire onChange when the value actually changed — committing an
// unchanged value on a plain tab-through would wrongly flag the field as
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
else { setQuery(value); } // revert typo
}, 120);
}
@@ -56,7 +64,7 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input
type={type}
className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
+25 -6
View File
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out;
}
// applyAwardRefs writes picked references onto a QSO payload using each award's
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
// reference can be assigned even for awards that scan a free-text field by
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
// the field wouldn't match.
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
// awards backed by a standard ADIF column also get that column written so the
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) {
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
overrides.push(`${code}@${r}`);
}
const field = fieldOf[code] || code.toLowerCase();
switch (field) {
case 'iota': payload.iota = ref; break;
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref;
break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
// scans the note for "D06"). Picking such a reference appends its code(s)
// to that field so the matcher finds it.
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
// "D06" in the note): append the code so the in-field matcher finds it too.
case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref);
break;
case 'comment':
payload.comment = appendTokens(payload.comment, ref);
break;
// address / qth / name / custom: the override above is authoritative; do
// not write a code into a free-text field the award matches by name.
default:
extras[field.toUpperCase()] = ref;
break;
}
}
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
else delete extras[MANUAL_REFS_KEY];
if (Object.keys(extras).length > 0) payload.extras = extras;
else if (payload.extras) delete payload.extras;
}
// awardRefValue reads a single award's stored reference from a QSO, inverse of
+4
View File
@@ -12,6 +12,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
const PORTABLE_KEYS = [
'hamlog.qsoLimit', // QSO list page size
'bandmap.side', // band map docked left / right
'bandmap.show', // band map open / closed
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
@@ -21,6 +22,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
+6
View File
@@ -0,0 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.12';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+188 -2
View File
@@ -3,10 +3,10 @@
import {adif} from '../models';
import {qso} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {winkeyer} from '../models';
@@ -25,14 +25,28 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
@@ -81,12 +95,18 @@ export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>;
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
export function DeleteUDPIntegration(arg1:number):Promise<void>;
export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
@@ -105,10 +125,78 @@ export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
export function FlexATUBypass():Promise<void>;
export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
export function FlexSetAudioLevel(arg1:number):Promise<void>;
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
export function FlexSetCWFilter(arg1:number):Promise<void>;
export function FlexSetCWPitch(arg1:number):Promise<void>;
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>;
export function FlexSetNR(arg1:boolean):Promise<void>;
export function FlexSetNRLevel(arg1:number):Promise<void>;
export function FlexSetPower(arg1:number):Promise<void>;
export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>;
export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -127,6 +215,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClusterAutoConnect():Promise<boolean>;
@@ -135,6 +227,10 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
@@ -147,12 +243,22 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLogFilePath():Promise<string>;
export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -163,10 +269,14 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -179,12 +289,16 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
export function ImportAwards():Promise<main.AwardImportResult>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -233,6 +347,40 @@ export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function QSLDefaultTemplateID():Promise<number>;
export function QSLDeleteTemplate(arg1:number):Promise<void>;
export function QSLFlagDataURL(arg1:string):Promise<string>;
export function QSLFonts():Promise<Array<main.QSLFontInfo>>;
export function QSLGenerateProposals(arg1:Array<string>):Promise<Array<string>>;
export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
export function QSLGetTemplate(arg1:number):Promise<string>;
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
export function QSLPickPhotos():Promise<Array<string>>;
export function QSLPreviewDataURL(arg1:number):Promise<string>;
export function QSLResolvePreview(arg1:string):Promise<string>;
export function QSLSaveEmailTemplates(arg1:main.QSLEmailTemplates):Promise<void>;
export function QSLSavePreview(arg1:number,arg2:string):Promise<void>;
export function QSLSaveTemplate(arg1:number,arg2:string,arg3:string,arg4:boolean):Promise<number>;
export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
@@ -245,8 +393,16 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
export function RescanAwards():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
@@ -265,6 +421,8 @@ export function SaveADIFFile():Promise<string>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
@@ -283,6 +441,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
@@ -305,16 +465,22 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
@@ -323,22 +489,38 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>;
@@ -349,6 +531,8 @@ export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function UltrabeamRetract():Promise<void>;
export function UnlockSecrets(arg1:string):Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
@@ -359,6 +543,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WinkeyerBackspace():Promise<void>;
+374 -2
View File
@@ -22,6 +22,10 @@ export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
}
@@ -34,10 +38,34 @@ export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
}
export function AwardRefsForQSOs(arg1) {
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
export function BulkUpdateField(arg1, arg2, arg3) {
return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3);
}
export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
export function CheckForUpdate() {
return window['go']['main']['App']['CheckForUpdate']();
}
export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache']();
}
@@ -134,6 +162,10 @@ export function DeleteQSO(arg1) {
return window['go']['main']['App']['DeleteQSO'](arg1);
}
export function DeleteQSOs(arg1) {
return window['go']['main']['App']['DeleteQSOs'](arg1);
}
export function DeleteUDPIntegration(arg1) {
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
}
@@ -146,6 +178,14 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
}
export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
@@ -182,6 +222,138 @@ export function FindQSOsForUpload(arg1, arg2) {
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
}
export function FlexATUBypass() {
return window['go']['main']['App']['FlexATUBypass']();
}
export function FlexATUStart() {
return window['go']['main']['App']['FlexATUStart']();
}
export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
export function FlexSetAGCThreshold(arg1) {
return window['go']['main']['App']['FlexSetAGCThreshold'](arg1);
}
export function FlexSetANF(arg1) {
return window['go']['main']['App']['FlexSetANF'](arg1);
}
export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
}
export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1);
}
export function FlexSetAPFLevel(arg1) {
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
}
export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
}
export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
}
export function FlexSetCWBreakInDelay(arg1) {
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
}
export function FlexSetCWFilter(arg1) {
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
}
export function FlexSetCWPitch(arg1) {
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
}
export function FlexSetCWSidetone(arg1) {
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
}
export function FlexSetCWSpeed(arg1) {
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1);
}
export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
}
export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1);
}
export function FlexSetNBLevel(arg1) {
return window['go']['main']['App']['FlexSetNBLevel'](arg1);
}
export function FlexSetNR(arg1) {
return window['go']['main']['App']['FlexSetNR'](arg1);
}
export function FlexSetNRLevel(arg1) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
}
export function FlexSetPower(arg1) {
return window['go']['main']['App']['FlexSetPower'](arg1);
}
export function FlexSetProcessor(arg1) {
return window['go']['main']['App']['FlexSetProcessor'](arg1);
}
export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
}
export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1);
}
export function FlexSetVox(arg1) {
return window['go']['main']['App']['FlexSetVox'](arg1);
}
export function FlexSetVoxDelay(arg1) {
return window['go']['main']['App']['FlexSetVoxDelay'](arg1);
}
export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
@@ -190,6 +362,10 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
}
@@ -226,6 +402,14 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
@@ -242,6 +426,14 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo']();
}
export function GetDBBackendStatus() {
return window['go']['main']['App']['GetDBBackendStatus']();
}
export function GetDBConnectionInfo() {
return window['go']['main']['App']['GetDBConnectionInfo']();
}
export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages']();
}
@@ -266,18 +458,38 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
}
export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath']();
}
export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision']();
}
export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
@@ -298,6 +510,10 @@ export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus']();
}
@@ -306,6 +522,10 @@ export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings']();
}
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1);
}
@@ -330,8 +550,8 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function ImportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
}
export function ImportAwardReferencesText(arg1, arg2) {
@@ -342,6 +562,14 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards']();
}
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms']();
}
export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices']();
}
@@ -438,6 +666,74 @@ export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function QSLDefaultTemplateID() {
return window['go']['main']['App']['QSLDefaultTemplateID']();
}
export function QSLDeleteTemplate(arg1) {
return window['go']['main']['App']['QSLDeleteTemplate'](arg1);
}
export function QSLFlagDataURL(arg1) {
return window['go']['main']['App']['QSLFlagDataURL'](arg1);
}
export function QSLFonts() {
return window['go']['main']['App']['QSLFonts']();
}
export function QSLGenerateProposals(arg1) {
return window['go']['main']['App']['QSLGenerateProposals'](arg1);
}
export function QSLGetEmailTemplates() {
return window['go']['main']['App']['QSLGetEmailTemplates']();
}
export function QSLGetTemplate(arg1) {
return window['go']['main']['App']['QSLGetTemplate'](arg1);
}
export function QSLListTemplates() {
return window['go']['main']['App']['QSLListTemplates']();
}
export function QSLPhotoDataURL(arg1, arg2) {
return window['go']['main']['App']['QSLPhotoDataURL'](arg1, arg2);
}
export function QSLPickPhotos() {
return window['go']['main']['App']['QSLPickPhotos']();
}
export function QSLPreviewDataURL(arg1) {
return window['go']['main']['App']['QSLPreviewDataURL'](arg1);
}
export function QSLResolvePreview(arg1) {
return window['go']['main']['App']['QSLResolvePreview'](arg1);
}
export function QSLSaveEmailTemplates(arg1) {
return window['go']['main']['App']['QSLSaveEmailTemplates'](arg1);
}
export function QSLSavePreview(arg1, arg2) {
return window['go']['main']['App']['QSLSavePreview'](arg1, arg2);
}
export function QSLSaveTemplate(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['QSLSaveTemplate'](arg1, arg2, arg3, arg4);
}
export function QSLSetDefaultTemplate(arg1) {
return window['go']['main']['App']['QSLSetDefaultTemplate'](arg1);
}
export function QSLStylePresets() {
return window['go']['main']['App']['QSLStylePresets']();
}
export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin']();
}
@@ -462,10 +758,26 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
}
export function ReportLiveActivity(arg1, arg2, arg3) {
return window['go']['main']['App']['ReportLiveActivity'](arg1, arg2, arg3);
}
export function RescanAwards() {
return window['go']['main']['App']['RescanAwards']();
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
@@ -502,6 +814,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1);
}
@@ -538,6 +854,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
export function SaveOperatingAntenna(arg1) {
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
}
@@ -582,6 +902,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
export function SendChatMessage(arg1) {
return window['go']['main']['App']['SendChatMessage'](arg1);
}
export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1);
}
@@ -590,6 +914,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SendEQSL(arg1, arg2, arg3) {
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
}
export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
@@ -602,6 +930,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
@@ -618,6 +950,18 @@ export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetLiveStatusEnabled(arg1) {
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
}
export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
}
@@ -626,6 +970,14 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
@@ -638,10 +990,18 @@ export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
export function TestEQSLUpload() {
return window['go']['main']['App']['TestEQSLUpload']();
}
export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1);
}
export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload']();
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
@@ -650,6 +1010,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
}
export function TestMySQLConnection(arg1) {
return window['go']['main']['App']['TestMySQLConnection'](arg1);
}
export function TestPTT(arg1) {
return window['go']['main']['App']['TestPTT'](arg1);
}
@@ -670,6 +1034,10 @@ export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract']();
}
export function UnlockSecrets(arg1) {
return window['go']['main']['App']['UnlockSecrets'](arg1);
}
export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
}
@@ -690,6 +1058,10 @@ export function UpdateQSOsFromQRZ(arg1) {
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
}
export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
+652 -4
View File
@@ -104,6 +104,30 @@ export namespace award {
this.confirmed = source["confirmed"];
}
}
export class OrRule {
field: string;
match_by?: string;
exact_match?: boolean;
pattern?: string;
leading_str?: string;
trailing_str?: string;
prefix?: string;
static createFrom(source: any = {}) {
return new OrRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.field = source["field"];
this.match_by = source["match_by"];
this.exact_match = source["exact_match"];
this.pattern = source["pattern"];
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.prefix = source["prefix"];
}
}
export class Def {
code: string;
name: string;
@@ -126,6 +150,7 @@ export namespace award {
multi?: boolean;
dynamic?: boolean;
add_prefixes?: string[];
or_rules?: OrRule[];
dxcc_filter: number[];
valid_bands?: string[];
valid_modes?: string[];
@@ -164,6 +189,7 @@ export namespace award {
this.multi = source["multi"];
this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"];
this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"];
this.valid_modes = source["valid_modes"];
@@ -175,7 +201,26 @@ export namespace award {
this.total = source["total"];
this.builtin = source["builtin"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Ref {
ref: string;
name?: string;
@@ -340,6 +385,162 @@ export namespace awardref {
export namespace cat {
export class FlexMeter {
id: number;
src?: string;
name?: string;
unit?: string;
value: number;
lo: number;
hi: number;
static createFrom(source: any = {}) {
return new FlexMeter(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.src = source["src"];
this.name = source["name"];
this.unit = source["unit"];
this.value = source["value"];
this.lo = source["lo"];
this.hi = source["hi"];
}
}
export class FlexRadio {
ip: string;
port: number;
model: string;
nickname: string;
serial: string;
callsign: string;
static createFrom(source: any = {}) {
return new FlexRadio(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ip = source["ip"];
this.port = source["port"];
this.model = source["model"];
this.nickname = source["nickname"];
this.serial = source["serial"];
this.callsign = source["callsign"];
}
}
export class FlexTXState {
available: boolean;
model?: string;
rf_power: number;
tune_power: number;
tune: boolean;
transmitting: boolean;
vox_enable: boolean;
vox_level: number;
vox_delay: number;
proc_enable: boolean;
proc_level: number;
mon: boolean;
mon_level: number;
mic_level: number;
atu_status?: string;
atu_memories: boolean;
rx_avail: boolean;
agc_mode?: string;
agc_threshold: number;
audio_level: number;
nb: boolean;
nb_level: number;
nr: boolean;
nr_level: number;
anf: boolean;
anf_level: number;
mode?: string;
cw_speed: number;
cw_pitch: number;
cw_break_in_delay: number;
cw_sidetone: boolean;
cw_mon_level: number;
apf: boolean;
apf_level: number;
filter_lo: number;
filter_hi: number;
amp_available: boolean;
amp_model?: string;
amp_operate: boolean;
amp_fault?: string;
meters?: FlexMeter[];
static createFrom(source: any = {}) {
return new FlexTXState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.rf_power = source["rf_power"];
this.tune_power = source["tune_power"];
this.tune = source["tune"];
this.transmitting = source["transmitting"];
this.vox_enable = source["vox_enable"];
this.vox_level = source["vox_level"];
this.vox_delay = source["vox_delay"];
this.proc_enable = source["proc_enable"];
this.proc_level = source["proc_level"];
this.mon = source["mon"];
this.mon_level = source["mon_level"];
this.mic_level = source["mic_level"];
this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"];
this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"];
this.nb = source["nb"];
this.nb_level = source["nb_level"];
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.anf = source["anf"];
this.anf_level = source["anf_level"];
this.mode = source["mode"];
this.cw_speed = source["cw_speed"];
this.cw_pitch = source["cw_pitch"];
this.cw_break_in_delay = source["cw_break_in_delay"];
this.cw_sidetone = source["cw_sidetone"];
this.cw_mon_level = source["cw_mon_level"];
this.apf = source["apf"];
this.apf_level = source["apf_level"];
this.filter_lo = source["filter_lo"];
this.filter_hi = source["filter_hi"];
this.amp_available = source["amp_available"];
this.amp_model = source["amp_model"];
this.amp_operate = source["amp_operate"];
this.amp_fault = source["amp_fault"];
this.meters = this.convertValues(source["meters"], FlexMeter);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RigState {
enabled: boolean;
connected: boolean;
@@ -488,11 +689,13 @@ export namespace extsvc {
username: string;
password: string;
callsign: string;
code: string;
qth_nickname: string;
force_station_callsign: string;
tqsl_path: string;
station_location: string;
key_password: string;
upload_flag: string;
upload_flags: string[];
write_log: boolean;
auto_upload: boolean;
upload_mode: string;
@@ -508,11 +711,13 @@ export namespace extsvc {
this.username = source["username"];
this.password = source["password"];
this.callsign = source["callsign"];
this.code = source["code"];
this.qth_nickname = source["qth_nickname"];
this.force_station_callsign = source["force_station_callsign"];
this.tqsl_path = source["tqsl_path"];
this.station_location = source["station_location"];
this.key_password = source["key_password"];
this.upload_flag = source["upload_flag"];
this.upload_flags = source["upload_flags"];
this.write_log = source["write_log"];
this.auto_upload = source["auto_upload"];
this.upload_mode = source["upload_mode"];
@@ -522,6 +727,8 @@ export namespace extsvc {
qrz: ServiceConfig;
clublog: ServiceConfig;
lotw: ServiceConfig;
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
static createFrom(source: any = {}) {
return new ExternalServices(source);
@@ -532,6 +739,8 @@ export namespace extsvc {
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -682,6 +891,44 @@ export namespace main {
this.mic_gain = source["mic_gain"];
}
}
export class AutostartLaunchResult {
id: string;
name: string;
status: string;
message: string;
static createFrom(source: any = {}) {
return new AutostartLaunchResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.status = source["status"];
this.message = source["message"];
}
}
export class AutostartProgram {
id: string;
name: string;
path: string;
args: string;
enabled: boolean;
static createFrom(source: any = {}) {
return new AutostartProgram(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.args = source["args"];
this.enabled = source["enabled"];
}
}
export class AwardImportResult {
awards: number;
references: number;
@@ -792,6 +1039,9 @@ export namespace main {
enabled: boolean;
backend: string;
omnirig_rig: number;
flex_host: string;
flex_port: number;
flex_spots: boolean;
poll_ms: number;
delay_ms: number;
digital_default: string;
@@ -805,11 +1055,50 @@ export namespace main {
this.enabled = source["enabled"];
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
}
}
export class ChatMessage {
id: number;
operator: string;
station: string;
message: string;
created_at: string;
static createFrom(source: any = {}) {
return new ChatMessage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.operator = source["operator"];
this.station = source["station"];
this.message = source["message"];
this.created_at = source["created_at"];
}
}
export class ChatPresence {
operator: string;
station: string;
ago_secs: number;
static createFrom(source: any = {}) {
return new ChatPresence(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.operator = source["operator"];
this.station = source["station"];
this.ago_secs = source["ago_secs"];
}
}
export class ClublogCtyInfo {
enabled: boolean;
loaded: boolean;
@@ -846,6 +1135,36 @@ export namespace main {
this.file_mod_time = source["file_mod_time"];
}
}
export class DBBackendStatus {
active: string;
fallback: boolean;
error: string;
static createFrom(source: any = {}) {
return new DBBackendStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.active = source["active"];
this.fallback = source["fallback"];
this.error = source["error"];
}
}
export class DBConnectionInfo {
backend: string;
label: string;
static createFrom(source: any = {}) {
return new DBConnectionInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.label = source["label"];
}
}
export class DVKMessage {
slot: number;
label: string;
@@ -903,6 +1222,7 @@ export namespace main {
smtp_user: string;
smtp_password: string;
from: string;
reply_to: string;
encryption: string;
auth: boolean;
auto_send: boolean;
@@ -921,6 +1241,7 @@ export namespace main {
this.smtp_user = source["smtp_user"];
this.smtp_password = source["smtp_password"];
this.from = source["from"];
this.reply_to = source["reply_to"];
this.encryption = source["encryption"];
this.auth = source["auth"];
this.auto_send = source["auto_send"];
@@ -1009,6 +1330,28 @@ export namespace main {
}
}
export class MySQLSettings {
enabled: boolean;
host: string;
port: number;
user: string;
password: string;
database: string;
static createFrom(source: any = {}) {
return new MySQLSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
}
}
export class POTAUnmatched {
activator: string;
date: string;
@@ -1126,6 +1469,96 @@ export namespace main {
this.qrzcom_confirmed = source["qrzcom_confirmed"];
}
}
export class QSLEmailTemplates {
subject: string;
body: string;
auto_send: boolean;
static createFrom(source: any = {}) {
return new QSLEmailTemplates(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.subject = source["subject"];
this.body = source["body"];
this.auto_send = source["auto_send"];
}
}
export class QSLFontInfo {
family: string;
kind: string;
variable: boolean;
data_b64: string;
static createFrom(source: any = {}) {
return new QSLFontInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.family = source["family"];
this.kind = source["kind"];
this.variable = source["variable"];
this.data_b64 = source["data_b64"];
}
}
export class QSLPresetInfo {
name: string;
label: string;
params: string[];
defaults: qslcard.StyleParams;
static createFrom(source: any = {}) {
return new QSLPresetInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.label = source["label"];
this.params = source["params"];
this.defaults = this.convertValues(source["defaults"], qslcard.StyleParams);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class QSLTemplateInfo {
id: number;
name: string;
profile_id?: number;
is_default: boolean;
updated_at: string;
static createFrom(source: any = {}) {
return new QSLTemplateInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.profile_id = source["profile_id"];
this.is_default = source["is_default"];
this.updated_at = source["updated_at"];
}
}
export class QSOAwardRef {
code: string;
ref: string;
@@ -1180,6 +1613,20 @@ export namespace main {
this.has_elevation = source["has_elevation"];
}
}
export class SecretStatus {
has_passphrase: boolean;
unlocked: boolean;
static createFrom(source: any = {}) {
return new SecretStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.has_passphrase = source["has_passphrase"];
this.unlocked = source["unlocked"];
}
}
export class SpotQuery {
call: string;
band: string;
@@ -1224,7 +1671,6 @@ export namespace main {
ok: boolean;
err: string;
db_path: string;
migrated_from_app_data: boolean;
static createFrom(source: any = {}) {
return new StartupStatus(source);
@@ -1235,7 +1681,6 @@ export namespace main {
this.ok = source["ok"];
this.err = source["err"];
this.db_path = source["db_path"];
this.migrated_from_app_data = source["migrated_from_app_data"];
}
}
export class StationInfoComputed {
@@ -1324,6 +1769,24 @@ export namespace main {
this.moving = source["moving"];
}
}
export class UpdateInfo {
current: string;
latest: string;
available: boolean;
url: string;
static createFrom(source: any = {}) {
return new UpdateInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.current = source["current"];
this.latest = source["latest"];
this.available = source["available"];
this.url = source["url"];
}
}
export class WKMacro {
label: string;
text: string;
@@ -1527,11 +1990,34 @@ export namespace operating {
export namespace profile {
export class ProfileDB {
backend: string;
host: string;
port: number;
user: string;
password: string;
database: string;
static createFrom(source: any = {}) {
return new ProfileDB(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
}
}
export class Profile {
id: number;
name: string;
callsign: string;
operator: string;
op_name: string;
owner_callsign: string;
my_grid: string;
my_country: string;
@@ -1552,6 +2038,7 @@ export namespace profile {
tx_pwr?: number;
is_active: boolean;
sort_order: number;
db: ProfileDB;
// Go type: time
created_at: any;
// Go type: time
@@ -1567,6 +2054,7 @@ export namespace profile {
this.name = source["name"];
this.callsign = source["callsign"];
this.operator = source["operator"];
this.op_name = source["op_name"];
this.owner_callsign = source["owner_callsign"];
this.my_grid = source["my_grid"];
this.my_country = source["my_country"];
@@ -1587,6 +2075,7 @@ export namespace profile {
this.tx_pwr = source["tx_pwr"];
this.is_active = source["is_active"];
this.sort_order = source["sort_order"];
this.db = this.convertValues(source["db"], ProfileDB);
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
@@ -1612,6 +2101,165 @@ export namespace profile {
}
export namespace qslcard {
export class Bevel {
dx: number;
dy: number;
dark: string;
light: string;
static createFrom(source: any = {}) {
return new Bevel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.dx = source["dx"];
this.dy = source["dy"];
this.dark = source["dark"];
this.light = source["light"];
}
}
export class FxParams {
plump?: number;
edge?: number;
outerw?: number;
gloss?: number;
gloss_h?: number;
gloss_i?: number;
inner_b?: number;
depth?: number;
angle?: number;
slant?: number;
grunge?: number;
bevel?: number;
seed?: number;
dark?: string;
outer?: string;
static createFrom(source: any = {}) {
return new FxParams(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.plump = source["plump"];
this.edge = source["edge"];
this.outerw = source["outerw"];
this.gloss = source["gloss"];
this.gloss_h = source["gloss_h"];
this.gloss_i = source["gloss_i"];
this.inner_b = source["inner_b"];
this.depth = source["depth"];
this.angle = source["angle"];
this.slant = source["slant"];
this.grunge = source["grunge"];
this.bevel = source["bevel"];
this.seed = source["seed"];
this.dark = source["dark"];
this.outer = source["outer"];
}
}
export class Halo {
color: string;
blur: number;
opacity: number;
static createFrom(source: any = {}) {
return new Halo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.color = source["color"];
this.blur = source["blur"];
this.opacity = source["opacity"];
}
}
export class ShadowFx {
dx: number;
dy: number;
blur: number;
color: string;
opacity: number;
static createFrom(source: any = {}) {
return new ShadowFx(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.dx = source["dx"];
this.dy = source["dy"];
this.blur = source["blur"];
this.color = source["color"];
this.opacity = source["opacity"];
}
}
export class Shine {
coverage: number;
opacity: number;
static createFrom(source: any = {}) {
return new Shine(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.coverage = source["coverage"];
this.opacity = source["opacity"];
}
}
export class StyleParams {
gradient?: string[];
shine?: Shine;
outline_color?: string;
outline_width?: number;
halo?: Halo;
shadow?: ShadowFx;
bevel_offset?: Bevel;
color?: string;
fx?: FxParams;
static createFrom(source: any = {}) {
return new StyleParams(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.gradient = source["gradient"];
this.shine = this.convertValues(source["shine"], Shine);
this.outline_color = source["outline_color"];
this.outline_width = source["outline_width"];
this.halo = this.convertValues(source["halo"], Halo);
this.shadow = this.convertValues(source["shadow"], ShadowFx);
this.bevel_offset = this.convertValues(source["bevel_offset"], Bevel);
this.color = source["color"];
this.fx = this.convertValues(source["fx"], FxParams);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace qso {
export class BandMode {
+2
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
@@ -16,6 +17,7 @@ require (
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
+4
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
@@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+19
View File
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String()
}
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
var b strings.Builder
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
len(adifVersion), adifVersion, now)
for _, r := range records {
r = strings.TrimRight(r, "\r\n")
if r == "" {
continue
}
b.WriteString(r)
b.WriteString("\n")
}
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
+17
View File
@@ -0,0 +1,17 @@
package audio
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
// like the CW decoder can configure their DSP to match.
const SampleRate = sampleRate
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
// frames (as int16) until stop closes. A thin wrapper over the internal capture
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
return captureStream(deviceID, stop, func(chunk []byte) {
if len(chunk) == 0 {
return
}
onSamples(bytesToInt16(chunk))
})
}
+40 -6
View File
@@ -5,11 +5,26 @@ package audio
import (
"encoding/binary"
"fmt"
"runtime/debug"
"strings"
"sync"
"time"
)
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
if r := recover(); r != nil {
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
}
}
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder mixer")
t := time.NewTicker(40 * time.Millisecond)
defer t.Stop()
for {
@@ -221,27 +239,43 @@ func (r *Recorder) RestartQSO() {
r.active = true
}
// SaveQSO writes the accumulated recording to path as a WAV and stops
// accumulating. Returns an error if no recording was active.
func (r *Recorder) SaveQSO(path string) error {
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
// new take immediately. Pair with WritePCM to encode/write off the hot path so
// a long recording doesn't delay logging.
func (r *Recorder) TakeQSO() ([]byte, error) {
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return fmt.Errorf("no active recording")
return nil, fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.mu.Unlock()
if len(samples) == 0 {
return fmt.Errorf("recording was empty")
return nil, fmt.Errorf("recording was empty")
}
data := int16sToBytes(samples)
return int16sToBytes(samples), nil
}
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
// .mp3). Slow for MP3 — call off the logging path.
func WritePCM(path string, data []byte) error {
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
return writeMP3(path, data)
}
return writeWAV(path, data)
}
// SaveQSO snapshots and writes the recording in one call (synchronous).
func (r *Recorder) SaveQSO(path string) error {
data, err := r.TakeQSO()
if err != nil {
return err
}
return WritePCM(path, data)
}
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
+120 -7
View File
@@ -68,6 +68,12 @@ type Def struct {
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
// earns a reference if the primary match OR any of these match. Lets a
// French department (DDFM) be found from "D74" in the note AND from a postal
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
@@ -84,6 +90,20 @@ type Def struct {
Builtin bool `json:"builtin"` // shipped default (informational)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
Field string `json:"field"` // QSO field to scan
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern,omitempty"` // 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
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
@@ -414,7 +434,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
}
}
rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0)
found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
@@ -445,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
// returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
if raw == "" {
return nil
}
predefined := hasList && !d.Dynamic
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
var found []string
switch {
@@ -464,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if d.ExactMatch {
if exact {
if up == nc.name {
found = append(found, nc.code)
}
@@ -472,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
found = append(found, nc.code)
}
}
case predefined && !d.ExactMatch:
case predefined && !exact:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex.
@@ -492,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// counts each reference separately.
found = splitRefs(raw)
}
if prefix != "" {
for i := range found {
found[i] = prefix + found[i]
}
}
return found
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
for i := range d.OrRules {
r := &d.OrRules[i]
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p)
if err != nil {
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
}
if !predefined {
return dedupe(found)
+2 -2
View File
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
return 0, fmt.Errorf("clear refs: %w", err)
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references
`REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return fmt.Errorf("empty award or reference code")
}
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references
`REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
+82 -10
View File
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
return dstPath, nil
}
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// 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) {
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
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")
tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("export adif: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".adi.zip")
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".adi")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error {
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
return out.Close()
}
// rotate keeps the most recent `keep` backups in folder and deletes the
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
// are touched — never user files that happen to live in the same folder.
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// opslog-*.db.zip) and deletes the rest.
func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
entries, err := os.ReadDir(folder)
if err != nil {
return err
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "opslog-") {
if !strings.HasPrefix(name, prefix) {
continue
}
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
ok := false
for _, sfx := range suffixes {
if strings.HasSuffix(name, sfx) {
ok = true
break
}
}
if !ok {
continue
}
info, err := e.Info()
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
return firstErr
}
// HasBackupToday returns true if a backup for today's date already exists
// in folder. Used by the startup auto-backup to skip when the user has
// already restarted the app once today.
// HasBackupToday returns true if a SQLite backup for today's date already
// exists in folder. Used by the auto-backup to skip when the user has already
// restarted the app once today.
func HasBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}
func hasBackupToday(folder, prefix string, exts ...string) bool {
if folder == "" {
return false
}
stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} {
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
return true
}
}
+220 -24
View File
@@ -57,6 +57,7 @@ type RigState struct {
// Manager owns the active backend and runs the polling loop.
type Manager struct {
mu sync.RWMutex
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
state RigState
emit func(RigState)
backend Backend
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
// state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) {
m.Stop()
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() {
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
m.mu.Lock()
stop := m.stopCh
done := m.doneCh
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
if done != nil {
<-done
}
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
@@ -169,6 +184,177 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
type Spotter interface {
SendSpot(SpotInfo) error
}
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
// missed spot on the panadapter is harmless.
func (m *Manager) SendSpot(s SpotInfo) {
m.mu.RLock()
cmds := m.cmdCh
b := m.backend
m.mu.RUnlock()
if cmds == nil || b == nil {
return
}
if _, ok := b.(Spotter); !ok {
return
}
select {
case cmds <- func() {
if sp, ok := b.(Spotter); ok {
_ = sp.SendSpot(s)
}
}:
default: // queue busy → drop this spot
}
}
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.)
type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"`
RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
VoxEnable bool `json:"vox_enable"`
VoxLevel int `json:"vox_level"`
VoxDelay int `json:"vox_delay"`
ProcEnable bool `json:"proc_enable"`
ProcLevel int `json:"proc_level"`
Mon bool `json:"mon"`
MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"`
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
CWPitch int `json:"cw_pitch"`
CWBreakInDelay int `json:"cw_break_in_delay"`
CWSidetone bool `json:"cw_sidetone"`
CWMonLevel int `json:"cw_mon_level"` // sidetone level
APF bool `json:"apf"`
APFLevel int `json:"apf_level"`
FilterLo int `json:"filter_lo"`
FilterHi int `json:"filter_hi"`
// External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"`
AmpOperate bool `json:"amp_operate"`
AmpFault string `json:"amp_fault,omitempty"`
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
Meters []FlexMeter `json:"meters,omitempty"`
}
// FlexMeter is one live meter value (already scaled to real units).
type FlexMeter struct {
ID int `json:"id"`
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"`
Value float64 `json:"value"`
Lo float64 `json:"lo"`
Hi float64 `json:"hi"`
}
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
type FlexController interface {
FlexState() FlexTXState
SetRFPower(int) error
SetTunePower(int) error
SetTune(bool) error
SetVOX(bool) error
SetVOXLevel(int) error
SetVOXDelay(int) error
SetProcessor(bool) error
SetProcessorLevel(int) error
SetMon(bool) error
SetMonLevel(int) error
SetMic(int) error
ATUStart() error
ATUBypass() error
SetATUMemories(bool) error
// RX slice DSP controls (target the active receive slice).
SetAGCMode(string) error
SetAGCThreshold(int) error
SetAudioLevel(int) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetANFLevel(int) error
SetAPF(bool) error
SetAPFLevel(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
// External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error
}
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
// the active backend isn't a Flex. Safe to call from any goroutine.
func (m *Manager) FlexState() (FlexTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if fc, ok := b.(FlexController); ok {
return fc.FlexState(), true
}
return FlexTXState{}, false
}
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
// active backend isn't a Flex.
func (m *Manager) FlexDo(fn func(FlexController) error) error {
return m.exec(func(b Backend) error {
fc, ok := b.(FlexController)
if !ok {
return fmt.Errorf("active CAT backend is not a FlexRadio")
}
return fn(fc)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
@@ -195,23 +381,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
defer runtime.UnlockOSThread()
defer close(done)
if err := b.Connect(); err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Stay idle until Stop is called — let the user fix config and re-Start.
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
}
}
}
defer b.Disconnect()
// Connection is (re)established lazily and retried with a backoff, so a rig
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
// reconnects on its own instead of staying dead until the user toggles CAT.
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
connected = true
}
tryConnect()
ticker := time.NewTicker(pollEvery)
defer ticker.Stop()
@@ -223,12 +413,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
fn()
m.applyCommandDelay()
case <-ticker.C:
if !connected {
tryConnect()
continue
}
ns, err := b.ReadState()
if err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Lost the rig — drop the backend so the next attempt reconnects
// cleanly, then back off before retrying.
connected = false
lastAttempt = time.Now()
b.Disconnect()
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
continue
}
ns.Enabled = true
+1598
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
//go:build windows
package cat
import (
"context"
"net"
"regexp"
"strconv"
"syscall"
"time"
"golang.org/x/sys/windows"
)
// FlexRadio is one radio found by discovery.
type FlexRadio struct {
IP string `json:"ip"`
Port int `json:"port"`
Model string `json:"model"`
Nickname string `json:"nickname"`
Serial string `json:"serial"`
Callsign string `json:"callsign"`
}
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
// second. DiscoverFlex listens for that broadcast for the given duration and
// returns the unique radios seen. Best effort: if the port can't be bound
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
// user falls back to entering the IP by hand.
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
if timeout <= 0 {
timeout = 2 * time.Second
}
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
// listens for the same broadcast.
lc := net.ListenConfig{
Control: func(_, _ string, c syscall.RawConn) error {
var serr error
_ = c.Control(func(fd uintptr) {
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
})
return serr
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
if err != nil {
return nil, err
}
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(timeout))
found := map[string]FlexRadio{}
buf := make([]byte, 2048)
for {
n, _, err := pc.ReadFrom(buf)
if err != nil {
break // deadline reached or socket closed
}
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
if _, dup := found[r.IP]; !dup {
found[r.IP] = r
}
}
}
out := make([]FlexRadio, 0, len(found))
for _, r := range found {
out = append(out, r)
}
return out, nil
}
var (
reFlexModel = regexp.MustCompile(`model=(\S+)`)
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
reFlexPort = regexp.MustCompile(`port=(\d+)`)
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
)
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
// The payload carries a space-separated key=value ASCII blob after a binary
// header, so we scan the whole packet text for the keys we need.
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
s := string(pkt)
m := reFlexIP.FindStringSubmatch(s)
if m == nil {
return FlexRadio{}, false
}
r := FlexRadio{IP: m[1], Port: 4992}
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
r.Port = p
}
}
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
r.Model = mm[1]
}
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
r.Serial = mm[1]
}
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
r.Nickname = mm[1]
}
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
r.Callsign = mm[1]
}
return r, true
}
+94 -37
View File
@@ -3,11 +3,21 @@ package cat
import (
"fmt"
"strings"
"time"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
@@ -21,6 +31,15 @@ type OmniRig struct {
omnirig *ole.IDispatch
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read both VFO frequencies separately so we can expose split TX/RX.
// Fall back to generic Freq if the rig only exposes the merged property.
freqA, freqB := int64(0), int64(0)
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
// generic Freq is what the rig is operating on — the reliable source for the
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
freqB = v.Val
}
// Split detection: trust the explicit Split property when it's set,
// BUT only call it a real split if both VFO frequencies are non-zero
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
// slice model doesn't map to VFO A/B — that would yield a useless
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// compared to PM_SPLITON, not "!= 0".
splitRaw := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
splitRaw = v.Val
}
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
}
return -1
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
// ADIF: FreqHz = TX, RxFreqHz = RX.
s.Split = true
switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA":
s.FreqHz = freqA // TX
s.RxFreqHz = freqB // RX
case "B", "BB":
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
default: // "AB" and the usual "TX on the other VFO" case
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown
} else {
s.FreqHz = freqA
}
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
}
}
return s, nil
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig.
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Read current freq to decide USB vs LSB.
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
return ""
}
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
func omniRigVfo(v int64) string {
switch {
case v&1024 != 0:
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
case v&0x40 != 0: // PM_VFOAA
return "AA"
case v&128 != 0:
case v&0x80 != 0: // PM_VFOAB
return "AB"
case v&256 != 0:
case v&0x100 != 0: // PM_VFOBA
return "BA"
case v&512 != 0:
case v&0x200 != 0: // PM_VFOBB
return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
}
return ""
}
+370
View File
@@ -0,0 +1,370 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// tone (level-independent, so weak or strong signals both key cleanly), an
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
// marks/spaces to Morse and then to characters.
//
// It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// out of the decode.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
type Decoder struct {
fs int
hop int // samples between updates
win int // Goertzel window length
freqs []float64
coeffs []float64 // precomputed 2*cos(w) per freq
ring []float64 // last win samples
acc int // samples since last hop
mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
// another goroutine, so it's atomic.
targetHz atomic.Int32
// Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
quietHops int // consecutive key-up hops while locked
noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
// Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
elem []byte // current "." / "-" run for the in-progress character
charEmitted bool
wordEmitted bool
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
"-.--": 'Y', "--..": 'Z',
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
dotHops: 15, // ~20 WPM seed
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
lockIdx: -1,
candIdx: -1,
statusEvery: 25, // ~10 Hz
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
// Candidate CW tones: 4001000 Hz every 25 Hz. Deliberately NOT lower: strong
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
for f := 400.0; f <= 1000.0; f += 25 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// nearestBin returns the bin index closest to hz.
func (d *Decoder) nearestBin(hz float64) int {
best, bestD := 0, math.Inf(1)
for i, f := range d.freqs {
if dd := math.Abs(f - hz); dd < bestD {
bestD, best = dd, i
}
}
return best
}
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring = append(d.ring, float64(s))
if len(d.ring) > d.win {
d.ring = d.ring[len(d.ring)-d.win:]
}
d.acc++
if d.acc >= d.hop && len(d.ring) >= d.win {
d.acc = 0
d.analyze()
d.step()
}
}
}
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() {
n := float64(len(d.ring))
var sumSq float64
maxIdx, maxMag := 0, -1.0
for i, coeff := range d.coeffs {
var s1, s2 float64
for _, x := range d.ring {
s0 := x + coeff*s1 - s2
s2 = s1
s1 = s0
}
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
d.mags[i] = m
if m > maxMag {
maxMag = m
maxIdx = i
}
}
for _, x := range d.ring {
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
if th := int(d.targetHz.Load()); th > 0 {
d.lockIdx = d.nearestBin(float64(th))
d.lastPitch = d.freqs[d.lockIdx]
return
}
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
if d.lockIdx < 0 {
if maxIdx == d.candIdx {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
snr := maxMag / (d.noise + 1e-9)
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise).
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
}
}
if d.lockIdx >= 0 {
d.lastPitch = d.freqs[d.lockIdx]
} else {
d.lastPitch = 0
}
}
// step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() {
on := false
if d.lockIdx >= 0 {
m := d.mags[d.lockIdx]
// Peak: fast attack, slow release.
if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else {
d.peak += (m - d.peak) * 0.02
}
// Floor: drops fast toward the signal, but only RISES between marks (when
// keyed up). Letting the floor rise during a long dash would shrink the
// span until the dash drops below the threshold and fragments into dots —
// the cause of the "all dots" garbage on a strong clean signal.
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else if !d.state {
d.floor += (m - d.floor) * 0.02
}
span := d.peak - d.floor
// The frozen floor already stops dashes fragmenting, so keep balanced
// thresholds: low enough that short inter-element GAPS are still seen
// (otherwise elements merge into >7-symbol runs that decode to nothing).
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
}
if on == d.state {
d.stateHops++
if !d.state {
d.spaceProgress()
}
} else {
if d.state {
d.endMark(d.stateHops)
}
d.state = on
d.stateHops = 1
if on {
d.charEmitted, d.wordEmitted = false, false
}
}
d.emitStatus(on)
}
// endMark classifies a finished key-down run as a dot or dash and adapts the
// dot-length estimate. Runs shorter than a third of a dot are rejected as
// clicks/noise.
func (d *Decoder) endMark(hops int) {
h := float64(hops)
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
if h < d.dotHops*0.35 || h < 4 {
return
}
if h > d.dotHops*2 {
d.elem = append(d.elem, '-')
d.adaptDot(h / 3)
} else {
d.elem = append(d.elem, '.')
d.adaptDot(h)
}
}
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~5100 WPM).
func (d *Decoder) adaptDot(obs float64) {
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5
}
if d.dotHops > 55 {
d.dotHops = 55
}
}
// spaceProgress flushes the current character once the gap exceeds a character
// gap, and a word space once it exceeds a word gap.
func (d *Decoder) spaceProgress() {
g := float64(d.stateHops)
if !d.charEmitted && g > d.dotHops*2 {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && g > d.dotHops*5 {
if d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar looks up the accumulated element string and emits the character.
func (d *Decoder) flushChar() {
if len(d.elem) == 0 {
return
}
if c, ok := morse[string(d.elem)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
} else if d.onChar != nil && len(d.elem) <= 7 {
// Only flag a genuinely Morse-shaped but unknown char with "?". An
// over-long element run is noise — drop it silently rather than spam "?".
d.onChar("?")
}
d.elem = d.elem[:0]
}
func (d *Decoder) emitStatus(on bool) {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
hopMs := float64(d.hop) / float64(d.fs) * 1000
wpm := 0
if d.dotHops > 0 {
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
}
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
}
+201
View File
@@ -0,0 +1,201 @@
package cwdecode
import (
"math"
"strings"
"testing"
)
// reverse Morse map for the synthesizer.
func charToMorse() map[byte]string {
m := map[byte]string{}
for code, ch := range morse {
m[ch] = code
}
return m
}
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
}
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dot
c2m := charToMorse()
var out []int16
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
out = append(out, int16(amp*math.Sin(phase)))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4) // 250 ms lead-in for AGC warmup
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(7 * dot)
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot) // inter-element gap
}
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
}
silence(fs / 4)
return out
}
func TestDecodeCleanSignal(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Repeat so AGC warm-up only costs the first word.
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
// Feed in small chunks like the live capture would.
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeWithQRM(t *testing.T) {
const fs = 16000
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
// quieter, sending different text. The pitch lock should hold on the target.
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
mix := make([]int16, len(target))
for i := range target {
v := int(target[i])
if i < len(qrm) {
v += int(qrm[i])
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
mix[i] = int16(v)
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(mix); i += 256 {
end := i + 256
if end > len(mix) {
end = len(mix)
}
d.Process(mix[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeFirstCharStrong(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Strong signal: the very first element (T = a dash) must not be eaten by
// lock acquisition. Output should begin with the first character.
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(strings.TrimSpace(sb.String()))
if !strings.HasPrefix(got, "TEST") {
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
}
}
func TestDecodeWithAmplitudeRipple(t *testing.T) {
const fs = 16000
// A real signal's tone amplitude wobbles within a mark; if the floor chases
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
rp := 0.0
for i := range samples {
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "TEST DE OM") {
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
}
}
func TestDecodeCQFixedPitch(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
d.SetTarget(700) // fixed pitch like the user's manual override
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if n := strings.Count(got, "CQ"); n < 2 {
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
}
}
func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "599") {
t.Fatalf("decoded %q, want it to contain 599", got)
}
}
+164 -8
View File
@@ -7,13 +7,143 @@ import (
"fmt"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
)
// MySQLConfig targets a shared MySQL database for multi-operator logging
// (multiple OpsLog instances on one logbook, à la Log4OM).
type MySQLConfig struct {
Host string
Port int
User string
Password string
Database string
}
func (c MySQLConfig) dsn() string {
port := c.Port
if port == 0 {
port = 3306
}
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
// or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database)
}
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
// use a placeholder). Only plain identifiers allowed.
func validDBIdent(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
return false
}
}
return true
}
// PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required")
}
server := c
server.Database = "" // connect to the server, not a specific DB
conn, err := sql.Open("mysql", server.dsn())
if err != nil {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
}
name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err)
}
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
}
return nil
}
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
var Dialect = "sqlite"
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
// server. Config tables always live in local SQLite, so this only governs
// qso-table SQL (see columnExpr).
func IsMySQL() bool { return Dialect == "mysql" }
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
// startup after the logbook backend is chosen, so it doesn't depend on the
// order in which the local and logbook connections were opened.
func SetDialect(d string) {
if d == "mysql" {
Dialect = "mysql"
} else {
Dialect = "sqlite"
}
}
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
// *_at / date columns — millisecond precision, matching the old SQLite
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
@@ -30,7 +160,8 @@ func Open(path string) (*sql.DB, error) {
_ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(conn); err != nil {
Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
_ = conn.Close()
return nil, err
}
@@ -39,12 +170,18 @@ func Open(path string) (*sql.DB, error) {
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency).
func migrate(conn *sql.DB) error {
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil {
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil {
translate = func(s string) string { return s }
}
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -74,11 +211,30 @@ func migrate(conn *sql.DB) error {
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := translate(string(content))
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
// "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database).
if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
continue
}
// SQLite supports DDL inside a transaction, so keep it atomic there.
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
@@ -0,0 +1,14 @@
-- QSL card designer templates. The json column holds the full template
-- document (schema v1, internal/qslcard). Photos referenced by a template
-- are copied into <dataDir>/qsl/templates/<id>/ — never referenced at their
-- original path. profile_id scopes the "default template" choice; a NULL
-- profile keeps the template usable by everyone.
CREATE TABLE qsl_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
profile_id INTEGER REFERENCES station_profiles(id) ON DELETE SET NULL,
json TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
@@ -0,0 +1,4 @@
-- Operator's personal name (e.g. "Greg"), distinct from the ADIF OPERATOR
-- callsign. Used by the QSL card designer for the signature/script line; may
-- feed correspondence and exports later. Optional, blank by default.
ALTER TABLE station_profiles ADD COLUMN op_name TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,6 @@
-- Per-profile logbook database. Each profile can target its own logbook:
-- the local SQLite file, or a specific shared MySQL database. Switching the
-- active profile switches the logbook accordingly. Stored as a small JSON
-- document {backend, host, port, user, password, database}; empty = inherit
-- the default (local SQLite).
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
+459
View File
@@ -0,0 +1,459 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ── SQLite → MySQL schema translation ──────────────────────────────────
//
// OpsLog's migrations are authored in SQLite dialect (the default backend).
// For the shared-MySQL backend we translate that DDL on the fly rather than
// maintaining a second set of migration files, so the two backends can never
// drift apart. The translation is deliberately narrow — it only rewrites the
// SQLite-isms that actually appear in our migrations:
//
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
// - bare INTEGER → BIGINT (so FK child/parent types match)
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
// - TEXT → VARCHAR(255), except a few free-text
// columns that stay TEXT/LONGTEXT
// - reserved column `key` → backticked
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
//
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
// are all valid MySQL as written.
var (
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
// actually needs in-row storage: it's indexed, a primary key, or carries a
// DEFAULT (TEXT can't hold a literal default in MySQL).
// varcharColumns must be VARCHAR because they're indexed or part of a primary
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
// below can't catch them). MySQL can't index a TEXT column without a prefix
// length. Keyed by column name (a name may repeat across tables — harmless).
var varcharColumns = map[string]bool{
// qso indexes (0001, 0003, 0019)
"callsign": true, "qso_date": true, "band": true, "mode": true,
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
// integrations_udp index (0011)
"direction": true,
// award_references composite primary key (0017)
"award_code": true, "ref_code": true,
}
// longTextColumns override the default TEXT with a bigger type where the
// content can be large (a full QSL-template document).
var longTextColumns = map[string]string{
"json": "LONGTEXT",
}
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
// inserts that omit the id still get one.
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
s = reStrftimeDefault.ReplaceAllString(s, "''")
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
s = reBareInteger.ReplaceAllString(s, "BIGINT")
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
// a primary key, or default-bearing — keeping the row under MySQL's limit.
s = translateTextColumns(s)
// `key` is a MySQL reserved word — backtick the settings column declaration.
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
return s
}
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
// right MySQL type, deciding per line so it can see whether the line carries a
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
func translateTextColumns(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
m := reColText.FindStringSubmatchIndex(line)
if m == nil {
continue
}
col := line[m[2]:m[3]]
var typ string
switch {
case longTextColumns[col] != "":
typ = longTextColumns[col]
case varcharColumns[col],
strings.Contains(line, "DEFAULT"),
strings.Contains(line, "PRIMARY KEY"):
typ = "VARCHAR(255)"
default:
typ = "TEXT"
}
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
}
return strings.Join(lines, "\n")
}
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
}
name := strings.TrimSpace(c.Database)
if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
return conn, nil
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
// keeps migrations idempotent and lets a half-applied database self-heal.
//
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
// connection returns to the pool, so runtime queries keep FK enforcement.
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
}
}
return nil
}
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping comments (full-line and inline "-- …") and blank fragments. Our
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
var out []string
for _, part := range strings.Split(b.String(), ";") {
if strings.TrimSpace(part) != "" {
out = append(out, part)
}
}
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.
func isIgnorableDDLError(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
return true
}
return false
}
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"regexp"
"strings"
"testing"
)
func TestMySQLDDL_Translations(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
// The column-name/type whitespace collapses to a single space — harmless.
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
}
for _, c := range cases {
if got := mysqlDDL(c.in); got != c.want {
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
}
}
}
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
got := mysqlDDL(in)
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
t.Errorf("key column not backticked/translated:\n%s", got)
}
}
func TestSplitStatements(t *testing.T) {
in := "-- a comment\n" +
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
"\n" +
"CREATE INDEX idx ON qso(a);\n"
got := splitStatements(in)
if len(got) != 3 {
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
}
if !strings.Contains(got[0], "ADD COLUMN a") ||
!strings.Contains(got[1], "ADD COLUMN b") ||
!strings.Contains(got[2], "CREATE INDEX") {
t.Errorf("unexpected split: %#v", got)
}
// No fragment should be a comment-only or blank statement.
for _, s := range got {
if strings.TrimSpace(s) == "" {
t.Errorf("empty statement in result: %#v", got)
}
}
}
// Every embedded migration must split into at least one runnable statement
// and never produce an empty fragment.
func TestSplitStatements_AllMigrations(t *testing.T) {
entries, _ := migrationsFS.ReadDir("migrations")
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
stmts := splitStatements(mysqlDDL(string(raw)))
if len(stmts) == 0 {
t.Errorf("%s: produced no statements", e.Name())
}
for _, s := range stmts {
if strings.TrimSpace(s) == "" {
t.Errorf("%s: empty statement fragment", e.Name())
}
}
}
}
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
// clear of it. TEXT columns are off-page and don't count here.
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
varchars := 0
for _, name := range qsoMigrations {
raw, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatal(err)
}
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
}
const bytesPerVarchar = 255 * 4 // utf8mb4
rowBytes := varchars * bytesPerVarchar
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
}
}
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
// fails if any SQLite-only construct survives — a fast guard against a new
// migration sneaking in a dialect-ism the translator doesn't cover.
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
t.Fatal(err)
}
reInteger := regexp.MustCompile(`\bINTEGER\b`)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
t.Fatal(err)
}
out := mysqlDDL(string(raw))
if strings.Contains(out, "AUTOINCREMENT") {
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
}
if strings.Contains(out, "strftime") {
t.Errorf("%s: strftime survived", e.Name())
}
if strings.Contains(out, "IF NOT EXISTS idx") {
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
}
// Strip comment lines before checking for bare INTEGER (comments are
// prose and may legitimately mention the word).
var code strings.Builder
for _, ln := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
continue
}
code.WriteString(ln)
code.WriteByte('\n')
}
if reInteger.MatchString(code.String()) {
t.Errorf("%s: bare INTEGER survived in code", e.Name())
}
}
}
+16 -14
View File
@@ -278,36 +278,38 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
// operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
// handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
// (the Scotland prefix) isn't mistaken for it.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "QRP": true, "A": true,
"PM": true, "LH": true,
}
// normalizeCallsign uppercases, trims, and resolves the "active" call when
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
// normalizeCallsign uppercases, trims, and resolves the "active" call when the
// operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
// (maritime/aeronautical mobile) is stripped so the home entity still resolves
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
// should still show the operator's country.
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
for i, p := range parts {
if i > 0 && (p == "MM" || p == "AM") {
return ""
}
}
keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for _, p := range parts {
for i, p := range parts {
if p == "" {
continue
}
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
// the operator's home entity is kept, so the contact still resolves to a
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
if i > 0 && (p == "MM" || p == "AM") {
continue
}
if suffixModifiers[p] {
continue
}
+2 -2
View File
@@ -214,8 +214,8 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO",
" F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO",
"F4BPO/MM": "", // maritime mobile → no DXCC entity
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
+6
View File
@@ -16,6 +16,7 @@ type Config struct {
User string
Password string
From string
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
Encryption string // "ssl" | "starttls" | "none"
Auth bool // SMTP requires authorization (send username/password)
}
@@ -57,6 +58,11 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
if err := m.To(to); err != nil {
return fmt.Errorf("bad recipient %q: %w", to, err)
}
if cfg.ReplyTo != "" {
if err := m.ReplyTo(cfg.ReplyTo); err != nil {
return fmt.Errorf("bad reply-to %q: %w", cfg.ReplyTo, err)
}
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" {
+76 -2
View File
@@ -1,19 +1,26 @@
package extsvc
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
return res, nil
}
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
}
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
+161
View File
@@ -0,0 +1,161 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
// POST (or URL params) with the account credentials and the ADIF content.
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
// eqslPost performs the import POST and returns the raw response body. eQSL
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
// callers classify it via the markers below.
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
form := url.Values{}
form.Set("EQSL_USER", user)
form.Set("EQSL_PSWD", pswd)
form.Set("ADIFData", adif)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("eqsl: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("eqsl: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
}
return msg, nil
}
// authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string {
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
return "invalid username or password"
}
return ""
}
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
// record when nick is set, so eQSL files the QSO under the right QTH profile
// (required when the account has more than one). ADIF field order is free, so
// prepending before the rest of the record is valid.
func eqslRecordWithNickname(record, nick string) string {
nick = strings.TrimSpace(nick)
if nick == "" {
return record
}
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
}
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
// is the optional eQSL QTH nickname.
//
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
// "Bad record: Duplicate" for an already-present QSO (treated as success so
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
// credentials.
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
user = strings.ToUpper(strings.TrimSpace(user))
if user == "" {
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(pswd) == "" {
return UploadResult{}, fmt.Errorf("eqsl: password not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
}
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
b := strings.ToLower(body)
if reason := authErrEQSL(body); reason != "" {
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
}
if strings.Contains(b, "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
added, _ := strconv.Atoi(m[1])
if added >= 1 {
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
}
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}
// eqslReason trims an eQSL reply to a short human-readable reason: the first
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
// (capped), else a generic phrase.
func eqslReason(body string) string {
for _, line := range strings.Split(body, "\n") {
l := strings.TrimSpace(line)
ll := strings.ToLower(l)
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
return l
}
}
b := strings.TrimSpace(body)
if b == "" {
return "upload rejected"
}
if len(b) > 200 {
b = b[:200]
}
return b
}
// TestEQSL validates the configured eQSL credentials with a REAL request: it
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
// marker. Anything else means the credentials were accepted.
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
if user == "" {
return "", fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(cfg.Password) == "" {
return "", fmt.Errorf("eqsl: password not set")
}
body, err := eqslPost(ctx, client, user, cfg.Password, "")
if err != nil {
return "", err
}
if reason := authErrEQSL(body); reason != "" {
return "", fmt.Errorf("eqsl: %s", reason)
}
return fmt.Sprintf("Credentials accepted — %s", user), nil
}
+23 -8
View File
@@ -31,6 +31,8 @@ const (
ServiceQRZ Service = "qrz" // QRZ.com Logbook
ServiceClublog Service = "clublog" // Club Log real-time upload
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -64,12 +66,14 @@ type ServiceConfig struct {
Email string `json:"email"` // Club Log account email
Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
Code string `json:"code"` // HRDLog: account upload code
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"`
@@ -81,16 +85,25 @@ func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code)
c.Username = strings.TrimSpace(c.Username)
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
c.StationLocation = strings.TrimSpace(c.StationLocation)
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; default to "R".
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
c.UploadFlag = uf
} else {
c.UploadFlag = "R"
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
// here (the caller injects N+R when nothing is configured).
var flags []string
seen := map[string]bool{}
for _, f := range c.UploadFlags {
f = strings.ToUpper(strings.TrimSpace(f))
if (f == "N" || f == "R") && !seen[f] {
seen[f] = true
flags = append(flags, f)
}
}
c.UploadFlags = flags
switch c.UploadMode {
case ModeDelayed, ModeOnClose:
// keep
@@ -105,6 +118,8 @@ type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
}
// UploadResult is the outcome of a single upload attempt.
+145
View File
@@ -0,0 +1,145 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a
// form-encoded POST with the uploader's callsign, the account's secret upload
// Code (HRDLog account → "My Account" → upload code), an App identifier, and
// one ADIF record.
const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx"
// hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to
// OpsLog in the user's HRDLog activity.
const hrdlogApp = "OpsLog"
// hrdlogPost performs the form POST to NewEntry.aspx and returns the raw
// response body. The endpoint replies HTTP 200 with a small XML document even
// for errors; callers classify it via the markers in classifyHRDLog.
func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) {
form := url.Values{}
form.Set("Callsign", callsign)
form.Set("Code", code)
form.Set("App", hrdlogApp)
form.Set("ADIFData", adif)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("hrdlog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("hrdlog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg)
}
return msg, nil
}
// authErrHRDLog returns a non-empty, human-readable reason when the response
// signals a credential problem (wrong upload code or unregistered callsign),
// or "" otherwise. Markers mirror HRDLog's documented XML replies
// ("Invalid token</error>" / "Unknown user</error>").
func authErrHRDLog(body string) string {
b := strings.ToLower(body)
switch {
case strings.Contains(b, "invalid token"):
return "invalid upload code"
case strings.Contains(b, "unknown user"):
return "callsign not registered at HRDLog"
default:
return ""
}
}
// UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station
// callsign the log belongs to, code is the account's upload code.
//
// Form fields (application/x-www-form-urlencoded POST):
//
// Callsign=<station call>&Code=<upload code>&App=OpsLog&ADIFData=<one record>
//
// HRDLog replies with XML: "<insert>1" on success, "<insert>0" for a duplicate
// (already logged — treated as success so retries are idempotent), or an
// "<error>…</error>" payload otherwise.
func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) {
callsign = strings.ToUpper(strings.TrimSpace(callsign))
code = strings.TrimSpace(code)
if callsign == "" {
return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set")
}
if code == "" {
return UploadResult{}, fmt.Errorf("hrdlog: upload code not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("hrdlog: empty adif record")
}
body, err := hrdlogPost(ctx, client, callsign, code, adifRecord)
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
b := strings.ToLower(body)
switch {
case strings.Contains(b, "<insert>1"):
return UploadResult{OK: true, Message: "uploaded"}, nil
case strings.Contains(b, "<insert>0"):
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := authErrHRDLog(body)
if reason == "" {
reason = body
if reason == "" {
reason = "upload rejected"
}
}
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason)
}
// NOTE: HRDLog's NewEntry.aspx inserts ONLY the first record of a multi-record
// ADIFData, so there is no batch upload — callers must POST one record per
// request (see UploadHRDLog). The bulk uploader in app.go does exactly that.
// TestHRDLog validates the configured HRDLog credentials with a REAL request:
// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth
// errors. A wrong upload code comes back as "Invalid token", a wrong callsign
// as "Unknown user"; anything else means the credentials were accepted (HRDLog
// simply had no QSO to add).
func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
code := strings.TrimSpace(cfg.Code)
if callsign == "" {
return "", fmt.Errorf("hrdlog: station callsign not set")
}
if code == "" {
return "", fmt.Errorf("hrdlog: upload code not set")
}
body, err := hrdlogPost(ctx, client, callsign, code, "")
if err != nil {
return "", err
}
if reason := authErrHRDLog(body); reason != "" {
return "", fmt.Errorf("hrdlog: %s", reason)
}
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
}
+8 -2
View File
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
// returned — used for incremental "Last download" updates.
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
// returned — used for incremental "Last download" updates. When ownCall is
// non-empty, only confirmations for that station callsign are returned (an
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
// scopes the pull to the active profile's call).
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set")
@@ -33,6 +36,9 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign
}
if s := strings.TrimSpace(since); s != "" {
q.Set("qso_qslsince", s)
}
+109 -34
View File
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
return baseCall(a) == baseCall(b)
}
// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
// the same "same operator?" rule when filtering an on-close upload batch by the
// active logbook's callsign.
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }
// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
@@ -62,6 +67,13 @@ type Deps struct {
// option would otherwise silently relabel it). "" → no station call known.
StationCallOf func(id int64) string
// CloseUploadIDs returns the QSO ids to upload for a service when the app
// closes — scanning the WHOLE logbook, not just this session: LoTW returns
// rows whose lotw_sent matches the configured status set; QRZ/Club Log
// return anything not yet "Y". This is what makes an imported ADIF (old
// QSOs still marked unsent) upload on close. nil → nothing to do.
CloseUploadIDs func(svc Service) []int64
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
@@ -75,7 +87,6 @@ type Manager struct {
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
}
func NewManager(deps Deps) *Manager {
@@ -87,7 +98,6 @@ func NewManager(deps Deps) *Manager {
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
pending: map[Service][]int64{},
}
}
@@ -103,6 +113,10 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock()
defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised()
cfg.Clublog = cfg.Clublog.normalised()
cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
m.cfg = cfg
}
@@ -139,17 +153,23 @@ func (m *Manager) OnQSOLogged(id int64) {
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
m.route(ServiceLoTW, id, lt)
}
// HRDLog — needs the station callsign + the account upload code.
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
m.route(ServiceHRDLog, id, h)
}
// eQSL — needs the account username (callsign) + password.
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e)
}
}
// route sends a logged QSO down the configured timing path: queue it for the
// app-close batch, or schedule an immediate / delayed upload.
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeOnClose {
m.mu.Lock()
m.pending[svc] = append(m.pending[svc], id)
n := len(m.pending[svc])
m.mu.Unlock()
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
// Nothing to queue: on-close upload sweeps the whole logbook from the
// database at shutdown (see FlushOnClose), so this QSO is picked up by
// its sent-status then — no in-memory tracking needed.
return
}
m.scheduleUpload(svc, id, cfg)
@@ -166,47 +186,82 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
go m.upload(svc, id, cfg)
}
// PendingCount returns how many QSOs are queued for on-close upload across
// all services. The shutdown sequence uses it to decide whether to show the
// upload step.
func (m *Manager) PendingCount() int {
m.mu.Lock()
defer m.mu.Unlock()
// onCloseServices returns the services configured for on-close auto-upload,
// with the minimum credentials to actually run.
func (m *Manager) onCloseServices() []Service {
cfg := m.Config()
var out []Service
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
out = append(out, ServiceQRZ)
}
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
out = append(out, ServiceClublog)
}
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
out = append(out, ServiceLoTW)
}
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
out = append(out, ServiceHRDLog)
}
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL)
}
return out
}
// CloseUploadCount returns how many QSOs across the whole logbook would be
// uploaded at app close (sum over every on-close service). The shutdown
// sequence uses it to decide whether to show the upload step and its label.
func (m *Manager) CloseUploadCount() int {
if m.deps.CloseUploadIDs == nil {
return 0
}
n := 0
for _, ids := range m.pending {
n += len(ids)
for _, svc := range m.onCloseServices() {
n += len(m.deps.CloseUploadIDs(svc))
}
return n
}
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
// single TQSL batch. Returns the number of QSOs uploaded successfully.
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
// batch. Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
m.mu.Lock()
pending := m.pending
m.pending = map[Service][]int64{}
cfg := m.cfg
m.mu.Unlock()
if m.deps.CloseUploadIDs == nil {
return 0
}
cfg := m.Config()
uploaded := 0
for svc, ids := range pending {
for _, svc := range m.onCloseServices() {
ids := m.deps.CloseUploadIDs(svc)
if len(ids) == 0 {
continue
}
switch svc {
case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
default:
var sc ServiceConfig
switch svc {
case ServiceQRZ:
sc = cfg.QRZ
case ServiceClublog:
sc = cfg.Clublog
}
for _, id := range ids {
if m.upload(svc, id, sc) {
if m.upload(svc, id, cfg.QRZ) {
uploaded++
}
}
case ServiceClublog:
for _, id := range ids {
if m.upload(svc, id, cfg.Clublog) {
uploaded++
}
}
case ServiceHRDLog:
for _, id := range ids {
if m.upload(svc, id, cfg.HRDLog) {
uploaded++
}
}
case ServiceEQSL:
for _, id := range ids {
if m.upload(svc, id, cfg.EQSL) {
uploaded++
}
}
@@ -276,8 +331,10 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
switch svc {
case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign
case ServiceClublog:
case ServiceClublog, ServiceHRDLog:
owner = cfg.Callsign
case ServiceEQSL:
owner = cfg.Username
}
if owner != "" && m.deps.StationCallOf != nil {
qcall := m.deps.StationCallOf(id)
@@ -324,6 +381,24 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
return false
}
res, err = UploadLoTW(ctx, cfg, "", record)
case ServiceHRDLog:
// HRDLog takes the station callsign as a separate param, so the ADIF
// keeps the QSO's own station call (no override), like Club Log.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
}
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
case ServiceEQSL:
// eQSL keeps the QSO's own station call; the account is identified by
// the Username + Password, with an optional QTH nickname.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
default:
return false
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"context"
"database/sql"
"fmt"
"hamlog/internal/db"
)
// Direction is "inbound" (we listen) or "outbound" (we emit).
@@ -112,10 +114,10 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
direction = ?, name = ?, port = ?, service_type = ?,
multicast = ?, multicast_group = ?, destination_ip = ?,
enabled = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
updated_at = ?
WHERE id = ?`,
c.Direction, c.Name, c.Port, c.ServiceType,
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID)
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
if err != nil {
return fmt.Errorf("update udp: %w", err)
}
+21 -14
View File
@@ -12,6 +12,8 @@ import (
"sync"
"time"
"unicode"
"hamlog/internal/db"
)
// ErrNotFound is returned by providers when a callsign is unknown.
@@ -357,24 +359,29 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
return r, true
}
// Put upserts a lookup result.
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, `
updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
"source", "fetched_at",
}
// The lookup cache always lives in the local SQLite database, so SQLite
// upsert syntax is used unconditionally.
sets := make([]string, len(updateCols))
for i, c := range updateCols {
sets[i] = c + " = excluded." + c
}
q := `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(callsign) DO UPDATE SET
name = excluded.name, qth = excluded.qth, address = excluded.address,
state = excluded.state, cnty = excluded.cnty,
country = excluded.country, grid = excluded.grid,
lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
image_url = excluded.image_url,
source = excluded.source, fetched_at = excluded.fetched_at`,
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
_, err := c.db.ExecContext(ctx, q,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County),
nullable(r.Country), nullable(r.Grid),
@@ -382,7 +389,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
nullable(r.ImageURL),
r.Source,
r.Source, db.NowISO(),
)
return err
}
+59 -4
View File
@@ -8,6 +8,8 @@ import (
"database/sql"
"fmt"
"strings"
"hamlog/internal/db"
)
// Station is a radio / TRX line. The display Name is also what gets
@@ -56,6 +58,59 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// CopyProfile duplicates the entire stations→antennas→bands tree from one
// profile to another (used when a profile is duplicated). New ids are assigned;
// the band defaults and ordering are preserved.
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
tree, err := r.ListTree(ctx, srcProfileID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := db.NowISO()
for _, st := range tree {
var pwr any
if st.TXPower != nil {
pwr = *st.TXPower
}
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy station: %w", err)
}
newStationID, _ := res.LastInsertId()
for _, ant := range st.Antennas {
ares, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy antenna: %w", err)
}
newAntID, _ := ares.LastInsertId()
for _, b := range ant.Bands {
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
return fmt.Errorf("copy band: %w", err)
}
}
}
}
return tx.Commit()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// ListTree returns every station for the profile with its nested antennas
// and bands. One round-trip per level — three queries total regardless of
// tree size, so the Settings panel stays snappy on big setups.
@@ -190,8 +245,8 @@ func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
_, err := r.db.ExecContext(ctx,
`UPDATE operating_stations
SET name = ?, tx_pwr = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
updated_at = ?
WHERE id = ?`, s.Name, pwr, s.SortOrder, db.NowISO(), s.ID)
if err != nil {
return fmt.Errorf("update station: %w", err)
}
@@ -230,8 +285,8 @@ func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
if _, err := tx.ExecContext(ctx,
`UPDATE operating_antennas
SET name = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
updated_at = ?
WHERE id = ?`, a.Name, a.SortOrder, db.NowISO(), a.ID); err != nil {
return fmt.Errorf("update antenna: %w", err)
}
}
+52 -10
View File
@@ -7,13 +7,27 @@
// credentials (LoTW / Clublog / QRZ.com) so each callsign can export to
// its own account.
package profile
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
// the local SQLite file; "mysql" = the shared MySQL server described by the
// rest. Switching the active profile switches the live logbook to this.
type ProfileDB struct {
Backend string `json:"backend"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct {
@@ -21,6 +35,7 @@ type Profile struct {
Name string `json:"name"`
Callsign string `json:"callsign"`
Operator string `json:"operator"`
OpName string `json:"op_name"` // operator's personal name (e.g. "Greg")
OwnerCallsign string `json:"owner_callsign"`
MyGrid string `json:"my_grid"`
MyCountry string `json:"my_country"`
@@ -41,6 +56,7 @@ type Profile struct {
TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -51,10 +67,10 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at`
is_active, sort_order, db_config, created_at, updated_at`
// List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
@@ -100,12 +116,12 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
if p.ID == 0 {
res, err := r.db.ExecContext(ctx, `
INSERT INTO station_profiles
(name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
(name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
VALUES(?,?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower),
@@ -119,13 +135,13 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
}
_, err := r.db.ExecContext(ctx, `
UPDATE station_profiles SET
name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
name = ?, callsign = ?, operator = ?, op_name = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?,
my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?,
sort_order = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.Name, p.Callsign, p.Operator, p.OpName, p.OwnerCallsign, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna,
nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone),
@@ -134,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
return err
}
// SetDB updates only a profile's logbook database target, leaving the rest of
// the profile untouched (the general Save deliberately doesn't write db_config).
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
_, err = r.db.ExecContext(ctx,
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
string(b), now, id)
return err
}
// SetActive atomically switches the active profile. Clears the flag on all
// rows first to keep the "only one active" invariant from the schema doc.
func (r *Repo) SetActive(ctx context.Context, id int64) error {
@@ -200,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err != nil {
return Profile{}, err
}
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
src.ID = 0
src.Name = newName
src.IsActive = false
@@ -207,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err := r.Save(ctx, &src); err != nil {
return Profile{}, err
}
if dbCfg.Backend != "" {
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
return Profile{}, err
}
src.DB = dbCfg
}
return src, nil
}
@@ -219,23 +256,28 @@ type scannable interface {
func scan(row scannable) (Profile, error) {
var p Profile
var (
callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty,
callsign, operator, opName, ownerCall, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA,
myRig, myAntenna sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int
dbConfig sql.NullString
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &createdAt, &updatedAt)
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil {
return p, err
}
if s := dbConfig.String; s != "" {
_ = json.Unmarshal([]byte(s), &p.DB)
}
p.Callsign = callsign.String
p.Operator = operator.String
p.OpName = opName.String
p.OwnerCallsign = ownerCall.String
p.MyGrid = myGrid.String
p.MyCountry = myCountry.String
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013 Panayiotis Lipiridis
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+150
View File
@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 640 480">
<path fill="#d0103a" d="M0 0h640v480H0z"/>
<path fill="#fedf00" d="M0 0h435.2v480H0z"/>
<path fill="#0018a8" d="M0 0h204.8v480H0z"/>
<path fill="#c7b37f" d="M300.4 136.6c7.7 0 10.9 6.6 18.6 6.6 4.7 0 7.5-1.5 11.7-3.9 2.9-1.6 4.7-2.5 8-2.5 3.4 0 5.5 1 7.3 4 1 1.6 1.8 4.9 1.3 6.7a40 40 0 0 1-2.7 8.3c-.7 1.6-1.3 2.5-1.3 4.2 0 4.1 5.6 5.5 9.4 5.6.8 0 7.7 0 12-4.2-2.3-.1-4.9-2-4.9-4.3 0-2.6 1.8-4.3 4.3-5.1.5-.1 1.3.3 1.7 0 .7-.3.4-1 1-1.4 1.2-1 2-1.6 3.6-1.6q1.3-.1 2.5.7.5.7 1 .8c1.2 0 1.8-.8 3-.8a5 5 0 0 1 2.3.6c.6.3.6 1.5 1.4 1.5.4 0 2.4-.9 3.5-.9 2.2 0 3.4.8 4.8 2.5.4.5.6 1.4 1 1.4a6 6 0 0 1 4.8 3c.3.4.7 1.4 1.1 1.5.6.3 1 .2 1.7.7a6 6 0 0 1 2.8 4.8q-.1 1.2-.5 2.2c-1.8 6.5-6.3 8.6-10.8 14.3-2 2.4-3.5 4.3-3.5 7.4 0 .7 1 2.1 1.3 2.7-.2-1.4.5-3.2 2-3.3a4 4 0 0 1 4 3.6l-.3 1.8a10 10 0 0 1 4-1.4h1.9c3.3 0 7 1.9 9.3 3.8a21 21 0 0 1 7.3 16.8c-.8 5.2-.3 14.8-13.8 18.6 2.5 1 4.2 3 4.2 5.2a4.5 4.5 0 0 1-4.4 4.7 4 4 0 0 1-3.5-1.4c-2.8 2.8-3.3 5.7-3.3 9.7 0 2.4.4 3.8 1.4 6s1.8 3.5 3.7 5.1q1.3-2.4 4-2.6 2.7-.1 3.9 2.2c.2.5 0 .9.3 1.4.3.6.8.7 1.1 1.3.5 1 0 1.8.5 2.7.3.7.9.8 1.2 1.4.4 1 .5 1.6.5 2.7 0 3-2.7 5.2-5.7 5.2-1 0-1.4-.4-2.3-.3 1.7 1.7 3 2.5 4.3 4.5a18 18 0 0 1 3 10.3 22 22 0 0 1-2.8 11.2 20 20 0 0 1-7 8.5 35 35 0 0 1-16 6.4 74 74 0 0 1-11 1.4l-14.1.8c-7.2.4-12.2 1.5-17.3 6.6 2.4 1.7 4 3.5 4 6.4q-.2 4.7-4.7 6.2c-.7.2-1.2 0-1.9.4s-.7 1.3-1.4 1.7a6 6 0 0 1-3.8 1 8 8 0 0 1-6.4-2.5c-2.2 1.8-3 3.4-5.5 4.9-.8.4-1.2 1-2.1 1-1.5 0-2.2-1-3.4-1.8a23 23 0 0 1-4.4-4c-2.3 1.3-3.6 2.4-6.3 2.4a7 7 0 0 1-4-1c-.6-.5-.8-1.2-1.5-1.6s-1.3-.3-2.1-.7c-3-1.3-5-3.5-5-6.8 0-2.9 1.8-4.7 4.4-6-5-5-10-5.8-17-6.2l-14-.8c-4.4-.3-6.8-.7-11-1.4-3.3-.5-5.2-.7-8.2-2.1-10.2-4.8-16.8-11.3-18-22.5-.2-1-.2-1.5-.2-2.5 0-5.8 2.3-9.4 6.4-13.5-1-.3-1.7 0-2.8-.3-2.5-1-4.4-2.7-4.4-5.5q-.1-1.4.5-2.6c.4-.6 1-.7 1.2-1.4.2-1 0-1.6.4-2.5.3-.5.8-.6 1-1.2 1-1.9 2-3.4 4.1-3.4q2.7.1 3.8 2.5c1.8-.8 2.2-2.1 3.2-3.7a16 16 0 0 0 1.4-13.3c-.4-1.5-.6-2.5-1.8-3.7q-1.4 1.4-3.4 1.4c-2.9 0-5-2.5-5-5.3a5 5 0 0 1 3-4.6c-1.6-1.4-3-1.5-4.7-2.6-2.6-1.6-3.5-3.4-5.2-6-1.2-1.6-1.5-2.8-2-4.7a19 19 0 0 1-1-7.8c.6-5 1.5-8 4.6-11.9 1.8-2.3 3-3.7 5.8-4.9 2.3-1 3.7-1.7 6.2-1.7l2 .1a7 7 0 0 1 2.8.8c.4.2 1.1.9 1.1.4s-.3-.8-.3-1.3c0-2 1.5-4 3.6-4 1.5 0 2.1 1.4 2.9 2.7q.6-1 .7-2.3c0-3.4-1.9-5.2-4-7.9-4.7-5.8-10.5-8.5-10.5-16q0-3.2 3-4.9c.5-.3 1.3 0 1.8-.3s.4-1 .7-1.4q.7-.9 1.6-1.6c1-1 2-.6 3.1-1.5q.8-.7 1.2-1.4c1.3-1.6 2.5-2.4 4.6-2.4q1.3-.1 2.5.4l1 .5q.5-.5 1.5-1.1a4 4 0 0 1 2.2-.6c1.1 0 1.8.6 3 .6l.8-.6c1-.7 1.5-1 2.7-1s1.8.3 2.8 1c1 .5 1 1.3 2 1.8l1.5.4c2.6.9 4.5 2.6 4.5 5.3q.1 2.1-1.4 3.5c-.9.7-1.7.6-2.8 1a16 16 0 0 0 11.3 3.5c4.2 0 9.3-1.7 9.3-5.9 0-2-1-3-1.8-4.8a19 19 0 0 1-2.1-8.5c0-2.8.3-4.5 1.9-6.7s3.6-2.9 6.5-2.9"/>
<g fill="none" stroke="#703d29">
<path stroke-linejoin="round" stroke-width=".7" d="M272.4 159a4 4 0 0 0 2.4 2.4c.8.3 2.7.2 3.8-1.4 1-1.2 1-2.8.6-4a5 5 0 0 0-1.7-2.2z"/>
<path stroke-linecap="round" stroke-width=".7" d="M401 236.1c-1.2-2.9-4.3-1.6-4.4 0-.5 3.7 2.7 4.8 5 4.2a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.8-1.6-1.3-1.2q-1.2-.7-3.4-.6c-5.5 0-10.4 6.5-12 13.4-.6 2.2-1.3 7.3-.3 12a22 22 0 0 0 5.9 11.3 26 26 0 0 0 9.9 5.8 8 8 0 0 0 4 .1c3.2-.7 4.7-3.8 3-7-1.3-2.5-5.3-4-7.2-.6q-.3.5-.4 1.5c0 .9.4 2 1 2.4 1.5.9 3.8.6 3.7-2"/>
<path stroke-width=".8" d="M383.8 274a11 11 0 0 1 6.6-3.7q4.4-.4 8.2 2a19 19 0 0 1 10.8 17c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14-7.1 3-18 3.6-25.7 4-10.4.3-20 .7-25.5 7.6"/>
<g stroke-width=".7">
<path d="M386.4 285.7q-.4-1.5.8-3.3c1.2-1.6 3.7-2.1 6-1a7 7 0 0 1 2.5 2.2l1.1 1.6q1 1.7 1 2.5c2.5 7-1.4 14.5-6.5 17.6-4 2.4-8.7 3.4-14.4 4-2.5.4-4 .3-6.5.5h-16.8c-2.9.3-5 .4-7.6.8q-2.4.3-5.4 1-.9 0-1.8.4l-1.2.3q-5.5 1.6-9.8 4.2-1.3.7-2.5 1.7l-1.3 1.2c-2 2-3.9 4-4.4 6.7v1.6c0 1.8 1.4 4.3 5.4 5m5.5-170c.8 1.4 1.3 2.3.8 3.9q-.9 2.7-3.6 2.8c-4 0-6.3-4.8-4.5-7.8 3.2-5.3 9.3-2.3 15 .3-.3-1.3-.8-1.8-.7-3.5.1-4.2 3.2-6 4.5-10 .7-2.3 1-4.3-.7-6q-2.2-1.8-5.1-.6c-3.8 1.5-8.5 5.9-16.6 6-8.2-.1-12.8-4.5-16.7-6q-3-1.2-5.1.7c-1.7 1.6-1.4 3.6-.7 6 1.3 3.8 4.4 5.7 4.5 10 0 1.6-.4 2-.7 3.4 5.7-2.6 12-5.9 15-.3 1.7 3.2-.5 7.7-4.5 7.7q-2.7 0-3.6-2.7-.5-2.2.8-4"/>
<path stroke-linecap="round" d="M314.6 159.9a5 5 0 0 1 2.4 5c-.2 2.5-.8 3.1-2.8 4.5m2.4-3.8q0 2.2-2.3 3.1"/>
</g>
<path fill="#c7b37f" stroke="none" d="m276.7 153.3.7.5.8.8.5 1 .2.8v1.9l-.2.8-.5.6-.6.6-.9.5-1 .2-1 .2-1-.5-.9-.6-.5-.8-.4-1v-.4z"/>
<path stroke-linecap="round" stroke-width=".7" d="M275.2 157.2c-.3-1.7-2.2-2-3-1-1.1 1.5-.3 4 2 4.7a4 4 0 0 0 3.9-1.4c1-1.3.9-2.8.5-4a5 5 0 0 0-1.7-2.2c-2.7-2-7.1-1.6-8.6 2-1.8 4.4 2.2 7.8 6 10.3 4.6 3.2 10 3.8 14 3.8 9.2-.1 16.2-4.5 20.7-7q1.7-.9 2.7.2a2 2 0 0 1-.3 2.7"/>
<path stroke-width=".7" d="m248 281.2-2 .7-2 1.6-1 1.3-1.1 2-.5 1.5-.4 1.8-.2 1.4m19-10.1-.1 1.8-.3 1.2-1 2.2-1.3 1.8-1.5 1.2-1.1.5-1.6.4"/>
<path stroke-width=".8" d="M319.7 329.1c-.3 1.7-1.9 3.6-5.3 4.2l-.6.2"/>
<path stroke-width=".9" d="M404.2 276.2a18 18 0 0 1 5.6 13.5c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14a85 85 0 0 1-25.6 4c-10.3.3-19.8.7-25.4 7.3"/>
<path stroke-width=".6" d="M387.5 282.9c.8-1 3.5-2.4 5.8-1.1a6 6 0 0 1 2.3 2"/>
<path stroke-width=".9" d="m401.6 273.8 1.4.5a7 7 0 0 0 4 0c2.8-.8 4.6-3.4 3.2-6.9a6 6 0 0 0-1.8-2.1"/>
<path stroke-linecap="round" stroke-width=".7" d="M240.3 199.8c-2 1.1-3.3 1.4-4.8 3.1a28 28 0 0 0-2.6 6.8m46-51.7q-.1 2.7-3 3.2"/>
<path stroke-width=".6" d="M397.1 192a19 19 0 0 1 18.6 19.8c0 16-9.9 18.5-13.8 19.6"/>
<path stroke-width=".7" d="M398.4 192c8.1-.3 16.5 5.7 16.9 20.7.3 11.7-8 17-12 18"/>
<path stroke-width=".6" d="m393.8 248.4.1-1.6.6-2.5.7-2 .9-1.6 1-1.3m7.8-3.4v1.5l-.5 1-.7 1.1-.8.6-1.2.5h-1.1l-.8-.1m-14.3-52.8.3-1.7.8-1.6 1-1.5 1.6-2.2 1.4-1.4 2-2.2 2-1.9 1.1-1.3 1.5-1.9 1.4-2 .8-1.7.5-2.2.1-2.7-.2-.8m-12.3 128.2 1.6-.4 1.2-.6.7-.7.5-.8.3-1.2v-.9m-158.2-12.1h2.7l1.6-.6m5-36.5-.2 1.4-.4.6-.4.6-.7.5-.7.3-1 .1h-.6m9.9-15.5-.3 2.1-.5 1-.8 1.2-1.2.9-1.2.6-2.3.5m15.3-39.7-.5 1.3-.5 1-.8 1-1 1-1.2.5-1.1.3-.6-.1m.3-6.2v1"/>
<g stroke-width=".6">
<path stroke-linecap="round" d="M254.3 224a7 7 0 0 1-2.1 1.4m150.5 44.8.5.2c1.4.8 4.2-.2 3.4-2.4"/>
<path d="M397.8 239.6c1 1.3 2.9 1.7 4.4 1.3a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.9-1.6-1.3-1.5-.4-.2m6.4 34 .1-.7a4 4 0 0 0-1.3-3l-.8-.8m.4.5c0-1.8-1.5-3.2-3.4-3.5m-4.2 2.8-1.3-1a16 16 0 0 1-4.3-10.7c0-4.2 1.6-8.4 3.6-10M341.2 324l1.8-1.6 1.2-1 2.3-1.4 2.2-1 1.6-.5 3-.6 3.6-.6m-29.5 19.4a17 17 0 0 1-7.6 6.1 18 18 0 0 1-7.6-6.1"/>
<path stroke-linecap="round" d="M314.4 332.6a10 10 0 0 1-2.2 4.2"/>
<path d="m314.7 330.5-.4 2.2M312 337l-1 1-1.7.9-2 .6m-5.6-177.8c.3-.8.5-1.4.5-2.6-.1-4.2-3.2-6.1-4.5-10-.7-2.3-1-4.3.7-6q2.2-2 5-.6c4 1.5 8.6 5.8 16.7 6-8.1-.2-12.8-4.5-16.6-6-2-.8-3.8-1-5.3.5-1.7 1.6-1.2 3.8-.5 6.1 1.3 3.9 4.2 5.8 4.3 10q-.1 1.6-.5 2.6M320 148c8-.4 14.9-5.8 17.1-6.3 2-.4 3-.2 4.5 1.1-1.4-1.3-3-1.2-5-.5-3.8 1.5-8.4 5.8-16.6 6m79.6 112.9a16 16 0 0 1-6.2-12.4c0-4.1 1.7-8.4 3.6-10m-70 97.6c-1.3 2-4.3 5-7.6 6.2a18 18 0 0 1-7.6-6.2"/>
<path stroke-linecap="round" d="m306.7 163.7 2.3-1.3c1-.6 2.3-.5 2.9.2s.7 2-.2 2.8"/>
<path d="M294.7 169.3c5.5-1.2 10-3.6 13.4-5.5M340.3 328c.5.3.8 1 .8 1l.3.8c.3 1.5-.7 2.4-2 2.6-1.7.2-3-.8-3.5-2M294.4 169c5.5-1.1 10-3.6 13.4-5.5m97.6 106.9c-1 .4-1.6.3-3-.2l-1.8-1a21 21 0 0 1-8.4-9 19 19 0 0 1-1.7-4.6 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.7-15.3c1.1-1.6 2.1-2.5 4.2-2.6m-143.7-39.3a7 7 0 0 1 2.7 5.7c0 3.1-2.6 8.2-9 10a8 8 0 0 1-6.3-.8"/>
<path d="M256.3 205.6q1.6 1 1.6 3.3 0 1.6-1.9 3.7a12 12 0 0 1-8.8 4q-3 .1-6-1.7a9 9 0 0 1-3.8-5.4"/>
<path d="M256.2 212.3q1.8 1.8 1.7 4.6.1 3.9-3.7 7-.8.8-2 1.5m129.5-22.1v3.5m-.3-4.4v5m.3-15.8v6.6m-.3-8v8.9m-1.9 82a19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.8 4.1 25 25 0 0 1-6.6 2.2 33 33 0 0 1-6.8.9c-2.5 0-3.9 0-6.4-.2s-4-.6-6.7-.8c-2.2-.2-3.4-.4-5.6-.3a28 28 0 0 0-11 1.8c-2.6 1-5.7 3-6.3 3.8a22 22 0 0 0-6.4-3.8 22 22 0 0 0-5.1-1.4c-2.3-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.4.2a33 33 0 0 1-13.4-3 20 20 0 0 1-6.4-4.8m42.1 53.4 1.8-.2m30.3-2.4 1.8-.1 1.7-.7 1.2-.8 1.7-2 .3-.6.3-1.7v-.8m47-136.7c.7-2.6-.2-5.4-2.8-5.3m-132 46.5a8 8 0 0 1-3.5 4.7m3.6-46.7a7 7 0 0 1-3.6 4c-1.9.8-4 0-5.2-.8"/>
<path stroke-linecap="round" d="M243.8 202.4c1.5.8 3.1-.4 2.8-2.4a3 3 0 0 0-2.5-2.2"/>
<path d="M250.2 286.6q.3.5.8.8c.7.2 1.2.4 1.9-.5.8-1.1.3-2.8-.5-3.9a5 5 0 0 0-5.8-1q-1.2.6-2.6 2.2l-1.1 1.6q-1 1.7-1.1 2.4c-2 5.9.4 12 4.1 15.7"/>
<path stroke-linecap="round" d="m340.2 327.8.7.8.2.9c.3 1.5-.7 2.4-2 2.6-1.6.2-2.8-.8-3.3-2"/>
<path d="M389.4 154.8a7.4 7.4 0 0 1 6.3 7c0 4.4-1.5 6-3.8 9.2-2.5 3.4-10.7 9.6-10.7 16.7q-.2 6.4 4.3 8.4c2 1 4.3 0 5.4-1 2.6-2.4 1.5-6.5-1.2-7-3.2-.6-3.9 4.6-.7 4.3m17.9 69a4 4 0 0 0-3.6-3 3.7 3.7 0 0 0-3.7 3.7q0 1.6 1 2.6"/>
<path d="M383.9 195.1a7 7 0 0 0-2.7 5.7c0 3.1 2.6 8.2 9 10 2.4.7 4.8.6 6.2-.3m-156-10.3a9 9 0 0 0-4.8 3.5 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.2 1.2 1m64 92c4.9 2.1 8.4 3.7 11.4 8.5a10 10 0 0 1 1.2 4.9c0 2.7-1 5.7-3.3 7.6a8 8 0 0 1-6.7 2c-1.9-.2-3.7-1.6-4-2.6M254 224.1c2.7 2.2 3.9 4.2 3.9 7.5a8 8 0 0 1-4 7.5"/>
<path stroke-linecap="round" d="M251.5 236.4c4 5.1 6.3 8.1 6.4 14.1.1 5.7-1.7 9.6-5 13.7"/>
<path d="M329.8 169.3a4 4 0 0 0 1.5-2.2q.8-2.2-.2-4 1.3 2 .7 4c-.1 1-.8 1.5-1.6 2.3m51.5 86.1v16.2l-.1 2.5-.3 1.7"/>
<path d="M381.4 254v19.9l-.5 2.6m.5-43v14.6m.3-13.4v11.8m0-26.8v8.8m-.3-9.9v11m.3-19v3.5m-.3-4.2v5m-1.8 65.2-.4.7a19 19 0 0 1-4.1 5.7 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.2c-2.7.6-4.2.8-6.9.9-2.5 0-3.9 0-6.3-.2-2.7-.2-4.1-.5-6.8-.8-2.2-.2-3.4-.3-5.6-.3s-3.5 0-5.7.4a22 22 0 0 0-5.2 1.4c-2.7 1.1-5.7 3-6.4 3.8-.6-.8-3.7-2.7-6.3-3.8a22 22 0 0 0-5.2-1.4c-2.2-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.3.2a33 33 0 0 1-13.5-3 20 20 0 0 1-5.8-4.1l-2.5-2.8m-2-3.2a10 10 0 0 1-2.3 7.7c-.8.9-2.6 2.6-5 2.6-3.7 0-4.8-2.5-5-3.2"/>
<path d="M255.6 278.9q1 1 1.9 2.5c1 1.8.6 4.8-.1 6.2l-.3.4m-20.3 18q3.3 3.8 10.9 7.1c7.1 3 18.1 3.6 25.7 4 10 .3 19.3.7 25 7m17.3-4a12 12 0 0 1 4 5.5m-7.3 11.5-.7.7a8 8 0 0 1-6.6 2c-2-.2-3.8-1.6-4.3-2.6m-5.4-2.9.3.4a8 8 0 0 0 5.1 2.4m27 0a18 18 0 0 1-7.7 6.1 18 18 0 0 1-7.6-6.1l-.3-.5m15.6.4.7.7a8 8 0 0 0 6.7 2 6 6 0 0 0 4-2.5l.5-.7"/>
<path d="m339 336.6-.7 1.2-1.1 1-1.7.7h-1.6"/>
<path d="M343 325.3a8 8 0 0 1 2.4 2.9q.4 1 .5 2.3a6 6 0 0 1-1.5 4.2 8 8 0 0 1-5.4 2.4h-.4m.2-.2a7 7 0 0 1-5.2-2.2m63.7-67.9a24 24 0 0 1-4.8-6.4 19 19 0 0 1-1.7-4.5 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.6-15.3c.7-.8 1.4-1.8 2.1-2.2m-1.3-75.9c2.5.2 4.8 3 4.8 5.7 0 3.8-1.3 5.5-4.4 9.3-2.6 3.2-10.6 9-10.3 14.5q.1 1.6 1.1 2.8m-3.2 3.5a7 7 0 0 0 2 1.4 5 5 0 0 0 4.3-.3M369 153a6 6 0 0 1 2.2 2.6c1.8 4.5-2.2 7.9-6 10.4a21 21 0 0 1-8.3 3.3"/>
<path d="M364.6 161.6a4 4 0 0 1-3.1-1.5l-.7-1m-15 4.9-1.2-1q-1.7-1.4-.8-4.4c.6-1.9 3.7-7.2 3.8-10.9.2-5.6-2-9-5.3-10.2"/>
<path stroke-linecap="round" d="m347.3 146.5-.1 2-.6 2.2-1 3-1 1.9-.8 1.9-.4 1.3-.2 1 .1.9m38 126.3.6.8c.7 1 3.2 3 5.5 3 3.7 0 4.6-2.6 4.7-3.2.5-2.9-.5-3.6-2-4.5 0 0-.8-.4-1.9-.2"/>
<path d="M237 274.4a7 7 0 0 1-3.7 0c-2.9-.9-5.2-3.6-4-7m13.4-31.8q.4.5.4 1c.4 3.8-2.8 4.8-5 4.2a6 6 0 0 1-3-2.3 5 5 0 0 1-.7-2.3m22-23.6q.9.7 1.3 1.7m-1.1-8.5q.8.7 1.1 1.3"/>
<path stroke-linecap="round" d="M257.9 210.5a9 9 0 0 1-1.6 2.4 12 12 0 0 1-8.8 4q-3 .1-6-1.7a10 10 0 0 1-4-5.6"/>
<path d="M255.4 195.3a8 8 0 0 1 2.4 3.4"/>
<path stroke-linecap="round" d="M257.8 203.2c-.9 3-3.5 6.6-8.6 7.9-2.4.6-5.6-.2-6.6-1"/>
<path d="M240 202.6c.3 2.6 2 4.6 5.4 4.6 4.7.1 7.6-6.7 3.4-11.5"/>
<path stroke-linecap="round" d="M229.4 225.5q1 1.3 2.4 2.4a17 17 0 0 0 6 3.3m5.2.5c4.2-.5 6.6-3.7 6-7.3-.3-2.8-2.8-5-4.6-5.1"/>
<path d="M249.8 188.1c1.9 0 3 1.6 2.9 3"/>
<path stroke-linecap="round" d="M249.4 163a12 12 0 0 0 5 5.9m144.2 31c1.7 2.3.6 7-4 7a5 5 0 0 1-4.5-2.5"/>
<path d="M381.7 169.1V185"/>
<path stroke-linecap="round" d="M243.8 202.3c1.4 1 3.3-.7 2.5-2.6-.5-1.2-2.2-2.6-4.7-.9-2.8 1.9-2 7.8 3.2 7.9 4.7 0 7.6-6.8 3.4-11.6-4-4.6-11.3-3.6-16 .2A21 21 0 0 0 225 207a23 23 0 0 0 0 9.2 21 21 0 0 0 3 7.5l1.3 1.7c.8.8 1 1.2 2 2a15 15 0 0 0 10.4 3.7c4.6-.2 7.3-3.4 6.8-7.3-.4-3.8-4.2-5.7-6.7-3.9-1.7 1.2-2.3 4.9.7 5.8 1.6.5 3.1-1.7 2-3M374 150.9q4-2.2 6.3 1a10 10 0 0 1 1.6 7.2 9 9 0 0 1-3.5 5.8"/>
<path stroke-linecap="round" d="M380.5 152c3.1-2 6.5-1.1 8.3 1.6 1.3 2 1.7 3.6 1.6 6.1a11 11 0 0 1-5.7 9.2"/>
<path d="M395 159.2c2.6.2 4.6 2.5 4.6 5.1 0 3.8-1 5.5-4 9.3-2.7 3.3-10.6 9-10.4 14.6 0 2.1 1.8 4 3.3 4.2"/>
<path stroke-linecap="round" d="M395.4 202.3c-1.5 1-3.3-.6-2.5-2.4.5-1.2 2.2-2.8 4.7-1.1 2.7 1.9 2 7.8-3.3 7.9-4.7 0-8-6.6-3.4-11.6 4-4.6 11.7-3.7 16.5.1 2 1.6 6.1 6 7 12 1 7 .9 15.6-6.4 21-3 2.1-7 3.1-10.6 3-4.6-.2-7.3-3.5-6.8-7.4.5-3.8 4-5.4 6.7-3.9s2.3 5.4-.7 5.8c-1.7.2-3.1-1.7-2-3"/>
<path d="M392.9 199.9c.8-3.5 3.7-3.8 6.2-3.8 6.5.1 11.1 8 11.2 15.5 0 9.5-4 15.2-11 15.5-1.9 0-5-.8-5-3"/>
<path stroke-linecap="square" d="M397 198.3c6.9 1.6 9.3 7.8 9.3 13.8 0 4.9-.5 11.6-10 13.9"/>
<path d="M408.4 265.3a3.9 3.9 0 1 0-6.3 2.4"/>
<path stroke-linecap="round" d="M394.4 259.4c1.4 2 3 4.1 6.3 6m-1.3 10.5c-3.2-2.2-9.5-5-15-2.2a8 8 0 0 0-4.4 4.4 10 10 0 0 0 1.8 9.5c.9 1 2.7 2.6 5 2.7 3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.1-.7-.3-2.8-.2-3.2 1.3q-.3.9.2 2"/>
<path stroke-linecap="round" d="M340.5 328.4c1 2.2-.2 3.2-1.6 3.4-2.2.3-3.3-1.4-3.4-3a4.4 4.4 0 0 1 4.3-4.7c2.3 0 4.1 1.5 5 3.5q.5 1 .5 2.4a6 6 0 0 1-1.4 4.1 8 8 0 0 1-5.4 2.5c-4.2.1-7.5-3.8-7.5-7.8 0-7.7 11.4-12 16-13a84 84 0 0 1 17.9-2.4c3.5-.1 6.2 0 10.1-.5 3.5-.3 5.4-.5 9-1.3a27 27 0 0 0 12.6-6.4c2.9-2.7 4.5-4.5 5.9-8.2a17 17 0 0 0-1.3-13.9 14 14 0 0 0-10.3-6.8c-3.7-.5-7 1.1-9 4.8-1 1.8-.6 4.8.1 6.2a6 6 0 0 0 4.8 3c3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.2-.7-.2-2.8-.1-3.2 1.4q-.3.9.2 2"/>
<path stroke-linecap="round" d="M337.2 316.2c-4.8 2.1-8.4 3.7-11.4 8.5a10 10 0 0 0-1.2 4.9c0 2.7 1.1 5.7 3.3 7.6a8 8 0 0 0 6.7 2c2-.2 3.7-1.6 4-2.6"/>
<path d="M385.1 224.1c-2.3.8-3.9 4.2-3.9 7.5a8 8 0 0 0 4 7.5"/>
<path stroke-linecap="round" d="M387.6 236.4c-4 5.1-6.3 8.1-6.4 14.1 0 5.7 1.7 9.6 5.1 13.7"/>
<path d="m365.9 152 .3-.5c1.7-2.4 4.7-3.1 6.9-1.5 2.6 2 3.3 5.4 2.6 9q-.9 3.3-4 5.5"/>
<path stroke-linecap="round" d="M265.1 150.8q-3.9-1.9-6.3 1a9 9 0 0 0-1.6 7.2c.6 2.7 1.4 3.8 3.5 5.8"/>
<path d="M258.6 152a6 6 0 0 0-8.3 1.6 9 9 0 0 0-1.6 6.1c.2 4.2 2.8 7.6 5.8 9.2"/>
<path d="M249.7 154.8a7 7 0 0 0-6 6.6c0 4.5 1 6.3 3.5 9.6 2.5 3.4 10.7 9.6 10.7 16.7q.2 6.4-4.3 8.4c-2 1-4.3 0-5.4-1-2.6-2.4-1.5-6.5 1.2-7 3.3-.6 3.9 4.6.7 4.3"/>
<path d="M244 159.2c-2.5.2-5 2.3-5 5 0 3.8 1.5 5.6 4.6 9.4 2.6 3.3 10.1 9 9.9 14.5 0 2-1.5 4.6-2.9 4.3"/>
<path stroke-linecap="round" d="M238 236.1c1.3-2.9 4.4-1.6 4.6 0 .4 3.7-2.8 4.8-5.1 4.2a4 4 0 0 1-2.5-2 5 5 0 0 1-.4-3.7l.9-1.6 1.2-1.2q1.3-.7 3.4-.6c5.5 0 10.4 6.5 12 13.4.6 2.2 1.3 7.3.3 12a22 22 0 0 1-5.8 11.3 26 26 0 0 1-10 5.8 7 7 0 0 1-3.9.1c-2.8-.9-4.6-3.5-3.2-7 1.2-2.6 5.4-4 7.3-.6q.3.5.4 1.5c0 .9-.4 2-1 2.4-1.4.9-3.7.6-3.6-2"/>
<path d="M233.8 270.4c1 .4 1.6.3 2.9-.2l1.8-1c2.6-1.5 5.6-3.8 8.4-9.1a19 19 0 0 0 1.7-4.5q.4-1.6.6-3.3a26 26 0 0 0-4.8-15.3c-1.1-1.6-2-2.5-4.2-2.6m-9.5 31a3.9 3.9 0 1 1 6.3 2.3"/>
<path d="M232.2 261.4a4 4 0 0 1 3.7-3 3.7 3.7 0 0 1 3.6 3.7 4 4 0 0 1-1 2.6"/>
<path d="M239.4 261.3a16 16 0 0 0 6.2-12.4c0-4.1-1.6-8.4-3.6-10"/>
<path stroke-linecap="round" d="M244.7 259.4a17 17 0 0 1-6.3 6"/>
<path d="M254.6 273.7q-1.4-3.2-5.8-3.5-4.3-.3-8.2 1.9a19 19 0 0 0-10.8 17 25 25 0 0 0 2 9.5c.9 1.6 3 9 15.3 14a86 86 0 0 0 25.7 3.9c10.4.4 20 .8 25.6 7.6"/>
<path stroke-linecap="round" d="M239.7 275.9c3.3-2.2 9.5-5 15.1-2.2a8 8 0 0 1 4.3 4.4 10 10 0 0 1-1.8 9.5c-.9 1-2.7 2.6-5 2.7-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.4.9-.2 2"/>
<path d="M252.7 285.7q.6-1.6-.8-3.3a5 5 0 0 0-6-1q-1.1.6-2.4 2.2-.7.7-1.2 1.6-1 1.7-1 2.5c-2.5 7 1.5 14.4 6.5 17.6 4.4 2.8 8.8 3.6 14.4 4 2.5.3 4 .3 6.5.5h16.8c3 .3 5.1.4 7.6.8q2.5.3 5.4 1 .9 0 1.8.4l1.2.3q5.5 1.6 9.8 4.2 1.3.7 2.5 1.7l1.3 1.2c2 2 4 4 4.4 6.7v1.6c0 1.8-1.4 4.3-5.3 5"/>
<path d="M298.6 328.4c-1 2.2.2 3.2 1.6 3.4 2.2.3 3.3-1.4 3.5-3a4.4 4.4 0 0 0-4.4-4.7 6 6 0 0 0-5 3.5 7 7 0 0 0-.5 2.4 6 6 0 0 0 1.4 4.1 8 8 0 0 0 5.4 2.5c4.2.1 7.5-3.8 7.5-7.8 0-7.7-11.4-12-16-13a84 84 0 0 0-17.9-2.4c-3.5-.1-6.2 0-10.1-.5-3.5-.3-5.4-.5-9-1.3a27 27 0 0 1-12.5-6.4 17 17 0 0 1-4.7-22 14 14 0 0 1 10.3-6.9q5.9-.7 9 4.8c1 1.8.6 4.8-.1 6.2a6 6 0 0 1-4.8 3c-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.3.9-.2 2"/>
<path stroke-linecap="round" d="m273.3 152-.4-.5c-1.7-2.4-4.7-3.1-6.9-1.5-2.6 2-3.3 5.4-2.5 9a9 9 0 0 0 4 5.5"/>
<path d="M366.8 159.6c-4 4.4-8.1 5.8-14.1 6-2 0-5.5-.6-7.6-2.1-1.3-1-2.8-2.6-1.9-5.5.6-1.9 3.7-7.2 3.8-10.9.3-5.6-1.9-8.7-5.3-9.9-6.2-2.2-13 4-17 5.4-2.1.7-3.2.8-5.1.8-2 0-3-.1-5.2-.8-4-1.4-10.7-7.6-17-5.4-3.4 1.2-5.5 4.3-5.3 10 .1 3.6 3.2 9 3.8 10.8 1 2.9-.5 4.5-1.9 5.5-2 1.5-5.7 2.1-7.5 2-6-.1-10.1-1.5-14.1-5.9"/>
<path stroke-linecap="round" d="M297.3 314.4c.8.3.2-.2 5.3 2a22 22 0 0 1 11.3 8.9 11 11 0 0 1 .9 7.3"/>
<path d="M297.7 336a8 8 0 0 0 3.2.9c4.2.1 7.5-3.8 7.5-7.8 0-2.8-1.5-5.2-3.6-7"/>
<path stroke-linecap="round" d="M298.6 328.4c-1 2.3.4 3.5 1.8 3.7 2.2.2 3.4-1.4 3.6-3a5 5 0 0 0-2.2-4.2"/>
<path d="M390.1 154.8c3.2 0 6 3.6 6 7.2 0 4.3-2.2 6.9-3.9 8.8q-1.9 2.3-4.4 4.7"/>
<path stroke-linecap="round" d="M386.3 151.4a9 9 0 0 1 2.8 2.4c1.3 2 1.7 3.7 1.6 6.2-.2 4.2-3.2 7.1-6 9m-4.7-17.6.6.7c1.9 2.2 2 5.4 1.6 7.2a8 8 0 0 1-3.8 5.4m-5-14.4c2.6 2 3.4 5.4 2.5 9q-1 3.6-4.2 5.2m11.1 41.1c.3 1 .9 1.3 1.5 2a14 14 0 0 0 6.2 3.5q3.7.9 6.3-.9m-163 54q2 .1 3.3 2.3.3.4.4 1.5 0 1.5-1 2.2c-1.5 1-4 .5-4-2"/>
<path d="M241.5 231.3c5 1 9.7 6.9 11.2 13.3.6 2.3 1.3 7.3.3 12a22 22 0 0 1-6 11.4l-2.1 1.9-1 .7m-8-12.1c2 0 3.8 1.9 3.8 4a4 4 0 0 1-1 2.6"/>
<path d="M234.6 260.7c2.1 0 4.1 2 4.1 4.2a4 4 0 0 1-1.4 3"/>
<path stroke-linecap="round" d="M254 239.5a18 18 0 0 1 3.8 7.7m0 8.5a17 17 0 0 1-1.5 4 18 18 0 0 1-3.6 4.7"/>
<path d="M254.3 224.3q2.7 2.2 3.5 4.8"/>
<path stroke-linecap="round" d="M257.9 219.5a10 10 0 0 1-3.4 4.6m-9.2-17.2 2.2-.6 1.3-1 .8-1.1.7-1.8.3-1.5"/>
<path d="m241 199.3-2.5.8a9 9 0 0 0-3.5 3 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.4c1.4 1.3 2.6 2 4.6 1.7"/>
<path stroke-linecap="round" d="M253 189.8c-.3 1.3-1 2.9-3 2.7"/>
<path d="M245.7 198.5c-2-1.9-6-2.4-10.1.2L234 200l-1.4 1.6a18 18 0 0 0-2.4 5c-.7 3-.7 5.6-.6 6.3q0 1.5.3 2.7 1 4.2 2.3 6.2c.9 1.5 3 5 7.7 5.4 1.8.1 4.8-.7 5-3"/>
<path stroke-linecap="round" d="M363.8 157c.3-1.6 2.3-1.9 3-1 1.2 1.6.4 4.2-2 4.9a4 4 0 0 1-3.8-1.4c-1-1.3-.9-2.8-.5-4q.4-1.2 1.7-2.2c2.7-2 7.1-1.6 8.6 2 1.8 4.4-2.2 7.8-6 10.3-4.6 3.2-10 3.8-14 3.7-9.2 0-16.1-4.4-20.7-7q-1.7-.7-2.7.3a2 2 0 0 0 .3 2.7"/>
<path stroke-linecap="round" d="M365.6 155.5c1 0 1.2.4 1.5.8 1.2 1.5.3 4.1-2 4.9m17.8 51.5c-3.5 3.8-.2 10.3 2.4 11.8.9.7 1.3.3 2 .7"/>
<path d="M383.1 205.4q-1.5 1-1.6 3.3a5 5 0 0 0 1.4 4 14 14 0 0 0 9.3 3.7q3 .1 6-1.7a9 9 0 0 0 3.8-5.4m-20.8 61.8-.2 2.5a19 19 0 0 1-2 7 19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.3 44 44 0 0 1-13.2.6c-2.7-.2-4.1-.5-6.8-.8-2.2-.1-3.4-.3-5.6-.3a28 28 0 0 0-10.9 1.9c-2.7 1-5.7 3-6.4 3.8-.6-.9-3.7-2.8-6.3-3.8a22 22 0 0 0-5.2-1.5c-2.2-.4-3.5-.4-5.8-.4s-3.4.2-5.6.4c-2.6.2-4 .6-6.7.7-2.5.2-3.9.3-6.3.2a33 33 0 0 1-7-.8 25 25 0 0 1-6.5-2.2 20 20 0 0 1-5.8-4.1 19 19 0 0 1-4.2-5.7 19 19 0 0 1-2-6.9c-.2-1-.2-2.5-.2-2.5V169.3h123.2z"/>
</g>
<g fill="#c7b37f" stroke="#c7b37f">
<path stroke-width=".3" d="M248 285.6a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0zM232.5 268q.2-2.1 1.8-2.3c1.6-.2 1.7 1 1.7 2.3q-.2 2-1.7 2.2-1.6-.2-1.8-2.2z"/>
<path stroke="none" d="M241.3 223.6q.2-1.7 1.7-1.8 1.6.2 1.7 1.8c.1 1.6-.7 1.8-1.7 1.8s-1.7-.8-1.7-1.8M272 158c0-1 .5-2 1.4-2q1.5 0 1.8 1.6c0 1-.5 2-1.4 2q-1.4 0-1.8-1.6"/>
</g>
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".6">
<path d="M239.3 234q-.6.1-.8.5-.4.3-.6.9l-.2 1.2m4.7 26.7 1-1 .6-1 .5-1 .7-1.3m-1.3 14-1.5.7-1.1.6-1.3.8-1.2 1m15-37.9-.8-.8-1-.8-.9-.8"/>
<path stroke-linecap="butt" d="m254.2 225-1.2.5-1.5.3"/>
<path d="m237.4 208.4.5 1.5q.3 1 .9 1.7a8 8 0 0 0 2.6 2.7l1.5.8m-1-5.8 1.3.6a7 7 0 0 0 3 .6l1.8-.1m7.2-40.7-2-1.2q-1.2-.7-2-1.5l-1.1-1.3-.8-1.3m7.5-4.6.6 1.7 1.4 2c1 1 1.7 1.3 2.8 2.2m1.4-6 .7 1.6.8 1.2 1.3 1.3q1 .7 2 1.1"/>
</g>
<path fill="#703d29" stroke-width=".2" d="M333.3 151.6c0-1.7-1.7-1.8-2.4-1.8-1.8 0-2.3 1.1-4.6 2.3a12 12 0 0 1-6.7 2 12 12 0 0 1-6.7-2c-2.3-1.2-2.7-2.3-4.6-2.3a2.3 2.3 0 0 0-2.2 2.4v.9l.3.2q0-1 .5-1.7a2 2 0 0 1 1.6-.8c1.8 0 2.5 1.2 4.8 2.4 3 1.6 4.2 1.9 6.7 2a12 12 0 0 0 6.8-2c2.3-1.2 3-2.5 4.8-2.5q.9 0 1.3 1v.9l.2.1z"/>
</g>
<g fill="#703d29">
<path d="M264.4 294c.5-.5.9-.3 1-.6q0-.2-.3-.3l-.9-.2-.8-.4h-.5c-.1.4 1 .4.6 1.4l-.8 1.2-2.6 3-.2.1v-4.3l.1-1.8c.2-.4.8 0 .9-.4q0-.2-.3-.3t-1.1-.3l-1-.5h-.6l.1.3q.5.2.5 1v7.4q0 .7.2.7l.4-.3z"/>
<path d="M267.5 295.2c.3-1.1 1-.4 1-.8q.1-.2-.2-.3l-1.3-.4-1.2-.4h-.4c-.1.5 1.1.5.8 1.5l-1.7 5.5c-.3 1-1 .6-1.1 1v.1l1.2.4 1.6.5h.3c.2-.4-1.2-.3-.7-1.7zm3.7 1q.3-.7.9-.4 1.6.6 1 2.5c-.2.6-.4 1.2-2 .8q-.6-.1-.6-.5l.7-2.3zm-2.8 5c-.5 1.4-1.2.8-1.3 1.2q0 .2.3.3l1.6.4.8.3h.4c.1-.5-1-.3-.7-1.5l.6-2q0-.6.6-.3.8.1.8.8l.3 2q.1 1.4 1 2 1 .1 1.4-.4l-.2-.2h-.3s-.3 0-.3-.3l-.7-3.6q.1-.2.8-.3a2 2 0 0 0 1-1.3c.1-.5.4-2.2-1.8-2.9l-2.1-.5-1.2-.4h-.3c-.1.5 1.1.4.7 1.7zm8.4 2.5c-.4 1.4-1.4.5-1.5 1q0 .3.3.3l1.5.3 1.4.4q.4.2.6-.1c0-.3-1.3-.3-1-1.8l1.3-5.2q0-.8.6-.5l1 .2c1.1.3.5 1.5 1 1.6q.2-.1.2-.6l.1-1v-.4l-3.3-.7-3.2-.8q-.2 0-.2.2l-.5 1.5q-.2.3 0 .4c.5.1.5-1.5 1.7-1.2l.9.2q.6 0 .4.8zm12.7-3.3c.4-.6.8-.5.9-.7q0-.2-.4-.3h-.9l-.9-.3q-.3-.1-.4.1c-.1.4 1 .2.8 1.3q0 .3-.6 1.3l-2 3.3-.3.2v-.2l-.7-4-.1-1.8c0-.5.7-.2.7-.5q.1-.2-.4-.3l-1.1-.1q-.6 0-1-.3-.4-.1-.6.1l.1.2q.7.2.7.9l1.3 7.3.3.7.4-.3zm.6 6.8q0 .3.2.5l1.7.7c1.4.2 2.6-.7 2.8-2.2.3-1.5-.3-2.1-1.4-2.9-1.3-.9-1.8-1.1-1.7-2q.3-1 1.4-1c1.8.3 1.6 2.6 1.8 2.6q.4 0 .3-.4l.2-1.6v-.4h-.6c-.4 0-.7-.5-1.6-.7q-2-.1-2.5 2-.1 1.6 1.2 2.4c1.6 1.1 2.2 1.4 2 2.4q-.3 1.5-1.7 1.3c-1.2-.2-1.6-1.4-1.8-2.6q0-.3-.2-.3-.2.1-.2.5v1.7zm15.8-4.5c.3-.7.8-.6.8-.9q0-.2-.4-.2h-.9l-.9-.1q-.3 0-.4.2c0 .4 1 0 1 1.1q0 .3-.5 1.4l-1.8 3.5-.1.3-.1-.3-1.1-4-.3-1.6c0-.5.7-.3.7-.6q.1-.2-.4-.2h-1.2l-1-.2q-.4-.1-.6.1l.2.2q.6.2.7.8l2.1 7.1.4.7q.2.1.3-.4z"/>
<path d="M307.6 308.5c0 1.2-1 1-1 1.5q0 .2.3.1h2.2l.4-.1c0-.6-1.4.2-1.4-2v-4.2l.1-.1.2.1 5.1 6.3.3.1.2-.3v-6.7c0-1.3 1-1 1-1.3l-.3-.2h-2.3q-.2 0-.2.2c0 .4 1.3.2 1.3 1.3v4l-.1.4-.4-.3-4.2-5.3q-.1-.4-.4-.3h-1.8l-.2.1c0 .6 1.2-.2 1.2 2.1zM318 303c0-1.1.8-.7.8-1.1q.1-.2-.4-.2h-2.6s-.3 0-.3.2c0 .4 1.1 0 1.1 1.2v5.7c0 1.1-.8.8-.8 1.2l.2.2h2.8q.3 0 .3-.2c0-.4-1.2.2-1.2-1.3zm4.5 5.5c0 1.5-1.2 1-1.2 1.4q0 .3.4.2h3q.5 0 .5-.3c0-.3-1.4 0-1.4-1.4V303q-.1-.7.5-.6h1c1.2-.1.8 1.2 1.3 1.2q.2-.1.1-.6l-.1-1q0-.3-.2-.4l-3.3.1h-3.3l-.2.3-.1 1.6.1.4c.5 0 .2-1.6 1.4-1.6h.9q.5-.1.6.6v5.6zm6.3-2.2h-.4l.1-.5.7-2.2v-.2l.2.1 1 2.1.2.4q0 .3-.4.2zm1.8.5c.3 0 .3 0 .8 1l.2.8c0 .7-.7.6-.7 1q0 .2.4 0h1.2l1.3-.1q.4 0 .4-.2c0-.4-.6 0-1-.7l-3.4-7-.3-.4q-.2 0-.3.4L327 309c-.2.7-.8.7-.7 1h2.3q.4 0 .5-.3c.1-.3-1.2 0-1.3-.9l.2-1q.3-1 .6-.8l2.1-.2zm8.3-5c-.1-.8 0-.8 1.2-1 2-.2 1.4 1.3 2 1.2q.2-.1 0-.6l-.1-1.1q0-.2-.3-.2-1.4 0-2.4.3l-2.8.4q-.3 0-.3.2c.1.5 1.3 0 1.4 1l.7 5.5c.2 1.5-.7 1-.6 1.5h.2l1.4-.1 1.2-.1q.5 0 .5-.3c0-.3-1.2.1-1.4-1.2l-.2-1.7q-.2-1 .3-1h.8c1.1-.2 1 1.1 1.3 1q.4-.2.1-.5l-.3-2.1q-.1-.4-.2-.3c-.3 0-.1 1.1-1 1.2l-.7.1q-.6.2-.6-.5zm4 2.8c.4 2.3 2.1 3.7 4.2 3.3 3.4-.7 3.5-3.6 3.2-5.3-.5-2.5-2.3-3.7-4.4-3.3-2.5.5-3.5 2.7-3 5.3m1.1-1c-.3-1.6 0-3.4 1.7-3.7 1.4-.3 3 .8 3.4 3.4.3 2 0 3.6-1.8 4s-3-2-3.3-3.6zm8.3-4.1q0-.9.6-.9 1.6-.2 2.1 1.6c.2.7.3 1.4-1.3 1.8q-.5.1-.8-.2l-.5-2.3zm0 5.7c.4 1.4-.5 1.3-.5 1.6q.2.3.4.1l1.6-.4 1-.2q.3 0 .2-.2c0-.4-1 .3-1.3-1l-.5-2c0-.4-.2-.4.4-.5q.6-.3 1.1.3l1.3 1.6c.5.6 1 1.3 1.8 1.1q.9-.2 1-.9l-.2-.1-.3.1s-.3.1-.4 0l-2.4-2.9.5-.6q.4-.6.2-1.6c-.1-.5-.7-2.1-3-1.6l-2.1.6-1.2.2q-.3 0-.2.2c0 .5 1.1-.2 1.4 1zm8.7-2c.3 1.4-1 1.2-.9 1.6q.1.3.5.2l1.4-.5 1.5-.3q.5.1.4-.4c0-.3-1.3.4-1.7-1l-1.3-5.3q-.2-.7.3-.7l1-.2c1.1-.4 1.1 1 1.5.9s0-.5 0-.7l-.4-1s0-.3-.2-.2l-3.2.9-3.2.7v.3l.1 1.6q0 .4.3.4c.5-.1-.3-1.6 1-1.9l.8-.2q.6-.2.7.5zm5.5-7.3c-.3-1 .6-.9.4-1.3h-.3l-1.4.4-1.2.3s-.3 0-.3.2c.1.4 1.2-.2 1.5.8l1.6 5.6c.2 1-.6 1-.5 1.3q0 .2.2.1l1.1-.3 1.6-.4q.4 0 .3-.3c-.1-.3-1.1.5-1.5-.9zm2.3 2.7c.7 2.3 2.6 3.4 4.7 2.7 3.2-1.1 3-4.1 2.4-5.7-.8-2.4-2.8-3.3-4.8-2.7-2.4.9-3.2 3.2-2.3 5.7m1-1c-.6-1.7-.6-3.5 1.1-4 1.3-.5 3 .4 3.9 2.9.6 1.8.5 3.6-1.2 4.2-1.8.6-3.2-1.5-3.8-3.2zm7.6-5.5q-.2-.9.4-1 1.7-.3 2.4 1.4c.2.6.4 1.3-1.1 1.9q-.6.2-.8 0zm.8 5.6c.6 1.4-.4 1.4-.2 1.7q0 .3.4.1l1.5-.7.9-.2q.3-.1.2-.3c-.2-.4-1 .4-1.4-.8l-.8-1.9q-.4-.5.3-.7.6-.3 1.1.3l1.6 1.4c.5.5 1.1 1.1 2 .8.3-.2.9-.7.7-1l-.2-.1-.2.2h-.5l-2.8-2.5.4-.7a2 2 0 0 0 0-1.6c-.1-.6-1-2-3.1-1.2l-2 .9-1.2.4-.2.2c.2.4 1.1-.4 1.6.8l2 5z"/>
</g>
<g fill="#fedf00" transform="matrix(.64 0 0 .64 0 16)">
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13 13 0 0 1-3.8 4q-3 1.9-6 1.6c-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6q-2 1.7-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6 6 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5q-3.5-.1-5 3c-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a9 9 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8s7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
</g>
<g fill="#d52b1e">
<path fill="#fedf00" d="M322.3 175.5h52.6V228h-52.6z"/>
<path d="M329.7 175.5h7.8V228h-7.8zm15 0h7.8V228h-7.8zm15 0h7.9V228h-7.9z"/>
</g>
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
<path fill="#fedf00" stroke="none" d="M264.3 273.5q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4z"/>
<path stroke-width=".3" d="m270.4 283.1 2.5 1.5 3.4 1.2v-52.2h-5.9zm29.2 2.4v-51.9h-5.8v52.8l5.8-.7zm11.7-51.9h-5.8v52.1l5.8 1zm-23.4 0V287s-3.8.2-5.8 0v-53.4z"/>
</g>
<g transform="matrix(.64 0 0 .64 0 16)">
<path fill="#fedf00" d="M585.5 402.4a21 21 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26 26 0 0 1-13 7 52 52 0 0 1-16.6 1.6q-6.3-.4-10.3-1c-3.8-.6-6.7-.9-11-1h-6.2a83 83 0 0 0-18.3 4.2V340h82.2v58.5z"/>
<g id="ad-b">
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8q-.5.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1q-.1.5-.6 1t-1 1l-1.2 1-.3.1h-.6l-.8.8.3.6.8 1.4q.2.6.5.8.7.3 1.3.1l2 .5 1.5.8q.6.4 1.3.5h1.8v.3l2 1-.1.4q-.2.5-.1.8 1 2.9 1.5 3.2.8.4 1.1 1.5l-.3.3q-1 .8-1.7 1.8c-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4.8 2q.3 1 .3 2l1 .3.7-.6.6-1.2v-1q-.3-.2-.2-.7c0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9q-.1-.3.4-.7l2-1.8q.4.2 1 .1l1.3.4h1.6l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7q0 .6-.5 1t-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5.9-.1q.2-.3 0-.8 0-.8-.3-1.6l-.1-2.8q0-.8.2-1.5c.1-1 .4-1.4.6-2.2l.4-2.5a24 24 0 0 0 10.1-.6q1.2 1 2.7 1.6v1q0 .4.2.7l.3.3q.4 0 .7-.2t.2-.7v-.7h1.8v1.1q.2.4.5.4h.6q.3-.4.3-1v-.7l1-.4v.9l-.3.9c-.2.6-.5.8-.8 1.4q-.4.8-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2q0-.5.3-.8t.7-.4q.6 0 1-.2.4-.6.7-1.5a13 13 0 0 1 3-3.9l1.7-1.4q.4-.4.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9q0 1.1-.3 2.3 0 1-.5 2a4 4 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7q.6 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6q-.7-.9-.7-1.5v-5.7l.4-.2c1.2-.7 1.7-.9 2.4-2.5l.3-1.5v-1l-.4-1-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1l-3.8.2-2.2-.7c.9-.3 1.1-.5 1.5-1s.2-.7.6-1.1l.7-1-.9-.4h-1l-1.2.3-.8.6-2.2-1.2a9 9 0 0 0-3-.9zm2 11.8"/>
<g fill="none" stroke="#fedf00" stroke-linecap="round">
<path d="m568.8 359.5-.8.3q-1.2.5-2.6.5c-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6l-1.7.2.5-1s-1.2.3-2 .3l-1.6-.2 1-1-1.3-.2-1-.7 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3q1-.5 1.1-1.8 0-1.2-.8-1.8l-1.1-.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6q-.7 0-1.3.3-.6.5-1 1.1.7.3 1.2.3t.8-.5q.4-.5.4-1.2z"/>
<path d="m536 363.8 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8 8 0 0 0 2.7-1.8"/>
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2q.4 0 .7.2.2 0 .3.3t.1.5q0 .3-.6.4-.3 0-.5-.3v-.4a1 1 0 0 1-.9 0z"/>
</g>
<path fill="#0065bd" d="m525.1 364.2-2-.9 1-.5.5-1.3q.1-1 .7-1.4t1.1-.1q.7.1.9.7 0 .8-.3 1.5l-.2 1.4q0 .6.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2l-.6-1.2-.3-1.2v-2q0-.5-.2-.9c0-.2-.4-.3-.3-.4h.4q.5 0 1 .4t.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9 9 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2q-1.2 2-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a9 9 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2h-2.3l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1v.4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8q-1-.6-.6-1.6l.6-.4q.2-.4 0-.8l-.1-1-.2-1q-.1-.8.4-1.6.5-.5.8-.6.2.3 0 .8 0 .6.3 1.2l.7 1.3.4 1.4-.2 1.2-.6.8-.8.4z"/>
</g>
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
</g>
<path fill="none" stroke="#703d29" stroke-width=".5" d="M264.1 175.5h52.6V228h-52.6zm58.2 0h52.6V228h-52.6zm-58 98q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4zm110.4 0a13 13 0 0 1-1.4 4.3c-1 1.5-.6 1.4-2.7 3.8a15 15 0 0 1-4 2.9c-1.3.7-2.3 1-4.4 1.6a33 33 0 0 1-10.6 1q-4-.3-6.5-.7l-7.2-.6H334q-2.2 0-4.7.6c-3.5.7-7 2-7 2v-54.8H375v37.5l-.2 2.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 30 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 640 480">
<path fill="#00732f" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#000001" d="M0 320h640v160H0z"/>
<path fill="red" d="M0 0h220v480H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+81
View File
@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#000001" d="M0 0h640v480H0z"/>
<path fill="#090" d="M426.7 0H640v480H426.7z"/>
<path fill="#bf0000" d="M213.3 0h213.4v480H213.3z"/>
</g>
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(1 27.3)scale(1.06346)">
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2q-1.4-.1-1.9-1.3c-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4q-.6 1.3-2 1.6c-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6q0 3 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
<path d="M315.4 210.3h1v7.1h-1z"/>
<g id="af-a">
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7v5.8c-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6 6 0 0 0-2.4-1.8 5 5 0 0 1-2.4-1.5l-.8-1.5v-2l-.3.3c-2.3 1.6-4 5.9-4 8.7v2.3q.2.8.6 1.3l1.1.8 2.7.7a7 7 0 0 1 2.6 2 11 11 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2s3.7 3 4.5 4.8"/>
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3s-1 4.3-.6 6.1q.1 1 .5 1.5.3.5.6.5.5 0 .7-.3l.2-.8q-.2-3 .3-5.4a8 8 0 0 1 3-4.4q.4-.3.5-.7l-.3-.7q-.7-.5-1.5-.1m.2.4q.6-.2 1 .1l.1.2-.3.4a8 8 0 0 0-3.1 4.6 17 17 0 0 0-.3 5.6l-.2.6s0 .1-.2 0l-.4-.3-.4-1.2q-.4-2.9.7-6 1.1-2.9 3-4z"/>
<path fill="#bd6b00" stroke="none" d="M295.2 157.7q-2.3 1.2-3 4.2a14 14 0 0 0-.3 5.9q.5 2 1.6 2 .5.2.8-.3t.2-1q-.6-2.5-.3-5.1.4-2.6 2.2-4.1.5-.4.5-.8l-.2-.6q-.7-.5-1.5-.2m.2.5q.6-.2 1 0l.1.3-.3.4a7 7 0 0 0-2.4 4.4q-.4 2.8.2 5.2v.8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7q.8-2.9 2.8-3.8"/>
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17 17 0 0 0-4.9 6z"/>
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1s.5-4.6 2-5.2c1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a15 15 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7s-2.5-2.7-4-3.8a18 18 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
<path stroke="none" d="M220.6 183q-1.8-2 1-1.9c1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5q5.3 1 8.9 5.3a94 94 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5 5 0 0 1 0-3.4 8 8 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4s.7 2.8.2 3.7q-.7 2.2-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1s2.4.4 1.2 1.2c-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4s-2-.8-1.3.9c.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5q.6-2.9 0-5.3c-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a10 10 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a9 9 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1 1 0 0 1 .7-1.2l2.6-.8c1-.3 1.6.4 1.6.9v2q0 .9-.7.9-1.2 0-2.4.7-1 .5-1.5-.5zm10.6 0q0-1-.6-1.2a5 5 0 0 0-2.4-.4q-1.3 0-1.1.6v2.1c0 .8 0 .8.4 1q1.3-.1 2.5.6.9.4 1.1-.6z"/>
</g>
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
<g stroke="none">
<path d="M328.5 286.6q-.1 1.8 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a7 7 0 0 0-5-2.9zm3.8-21.7q-2-.7-4 1.4c-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6s1.5-3.4 2.4-3.5c2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a67 67 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2 2 0 0 0-1.5 0q-.8.2-1.3.7a116 116 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1q.8-.3 1.3-1l.1-.8-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4q-.5-.7-1.5-.5zm0 .5q.9 0 1.1.3c3 4.3 7.7 7 11.9 10.5l.4.7v.4q-.3.5-1 .7c-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4q6.4-4.9 11.8-10.4l1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3l1.5-.4z"/>
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a7 7 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7s-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8q-.6-.1-.4-.7zm4.3.3a8 8 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7q-.3.5.1.8 1 .3 2 0c.6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a34 34 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5s-3.2 0-4.8.6c-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2 1.5-1.8c.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3s2.4 0 2.4-1.2c0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3l-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47 47 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17 17 0 0 0-5-6.3"/>
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3q-1.8 3.8-7.5 3.2"/>
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1 1 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6q-.8 1.1-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4l-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a6 6 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3s-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3l-.1 1.6q-.4.4-1 .2c-.2-.2 0-.7-.6-1q-.4-.1-.8.2c-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2s-3-2.7-3.7-4c-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2zm4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a4 4 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a4 4 0 0 0-1.2 2q.1.5.5.5a3 3 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4s-1.5-1-1.7-.7c-1 1.3.8 1 .5 1.4q-.8 1.3-1.3 2.6c-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4s-1.9-5.9-2.7-8z"/>
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25 25 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3q1.5-.9 2.4-.4.5.3.2.7"/>
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2s-.9 2-1.7 2-2-1.5-1.3-1.5c2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7s-.5 1.7-1.3 1.6c-.4-.1.2-1.9-.2-2.4h-.7c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5-.2 2.3c0 .5 1 .2 1 .1 0-.8.2-2 0-2.3q-.5-.3-.8-.1"/>
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6q7.5.1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
</g>
</g>
<path fill="#fff" d="m311.3 295-.3 2.6h-.4l-.1-1.8-.5-1.6-.5-1.3-1-1.4.8-2.2a7 7 0 0 1 1.5 2.4 9 9 0 0 1 .5 3.2m7-4.2q0 1-.5 1.5-.3.5-1.3.7l.4 1.5v2l-.1 1.3h-.4l-.1-1.3-.2-1-.4-1-.7-1.4-1-1.7.6-2 1 1q.4.3 1 .3 1.2 0 1.2-1.3h.4v1.4m6.4 4.8-.5 2.1q-.6 0-.8-.7l-.4-1.3-.1-1.7-1 .2a2 2 0 0 1-1.3-.4 1 1 0 0 1-.5-1q0-1.4.7-2.3.8-1 1.5-1.1.7 0 1 .4l.3.9v2q0 1.3.3 1.9 0 .4.8 1m-2-3.5q0-.9-.8-.8l-.6.1q-.2.1-.2.3 0 .5 1 .5zm8.7 3-.3 2.6q-.8-.5-1.4-2l-1.3-4.1-1.8 5.5-.8.7v-2.5q.9-1 1-1.5l.8-1.7.5-2.7h.4l.9 2.7q.3 1 .9 1.6l1 1.4"/>
<path fill="#bf0000" d="M350.8 319.4q.6.6.7 1.2l.4 1.6-.8.1-1-1.5-1.1-1.2-1.7-1.5-2-1.7q-.6-.3-.6-.5l-.3-.8-.2-1.6 2.7 2.2 2.5 2.2zm-9.5-5.8-.2 2H338l.3-2zm8.4 8.9-7.6 2.3-1.3-2 6.5-2-.7-.8-.9-.6a1 1 0 0 1-.4 1l-1 .6a3 3 0 0 1-1.8 0 2 2 0 0 1-1.3-.7 4 4 0 0 1-.7-2.2q0-1.5.9-1.8 1.1-.3 3 .7a8 8 0 0 1 3 2.4zm-5.8-4-.8-.3h-.6l-.5.3v.6l.5.2h.6l.4-.3zm-8-1.6-.5 2-3.2-.3.5-2zm7.5 7.7-1.7.4H340l-1.5-.4q-.5.8-1.5 1.2l-1.6.6-1.2.3-1-2 1.1-.3 1.3-.4.9-.5-1-.5h-.9l-.2.3h-.5q-.8-1.2-.3-2c.5-.8.9-.8 2-1a7 7 0 0 1 2.6-.2q1.2.1 1.5.9.2.3.2.7l-.4 1.2h1.1l1.7-.3zm-8 1.8-1.6.3a3 3 0 0 1-2.2-.4 6 6 0 0 1-1.7-2.6l-.8-2.2a2 2 0 0 0-.8-1l-.9-.5.6-2.1q.9.4 1.4 1l1 1.7.5 1.5 1.1 2.2q.5.4 1 .3l1.7-.2zm-7-7.5-1 1.9-3-.7 1-1.9zm1.8 8.4-7.5.7-.4-2 6.2-.7-.6-.8-1-.6.5-2q1 .6 1.6 1.3.5.8.8 2.1zm-6 1-2.2-.2-1.7-.5-1.3.4h-3.7l-1.2-.3q-.4-.3-.8-1a4 4 0 0 1-1.5 1l-1.7.1h-1.7l.2-2.1h1.7q1.2.1 2.1-.4a2 2 0 0 0 1.3-1.8l.7.1-.1 1.3q0 .4.3.7.4.3 1 .3h1.5q1.5 0 2-.2.9-.2 1-1.1l.1-.4s.3 0 .5-.2l.5-.2v.7l-.3 1.1 2 .5q.1-.3-.1-.7l-.3-.6.1-.3.3-.2 1-.9.5 1v1zm-11.3-8.7-2 1.3-1.3-.9-1.4 1-1.9-1 1.8-1.3 1.5.8 1.5-1 1.8 1m-3 8.2-7.3-1.2.8-2 6.2 1q0-.6-.2-1l-.5-.8 1.6-1.7q.6.8.7 1.6t-.5 2.1zm-6.1-1-1.6-.3q-1.3-.3-1.5-1.2-.3-.9.8-2.8l1.2-2q.4-.7.3-1.2l-.3-.7 2.2-1.6q.4.8.3 1.4 0 .8-.7 1.8l-.8 1.4a6 6 0 0 0-.9 2.2q0 .6.5.7l1.6.4zm-3.8-8-2.5 1.1-1.8-1.7 2.6-1zm-1 6.6-1.6 1.4-1.7.6-2.4-.1-2.8-.7a8 8 0 0 1-3.4-2q-.9-1.2 0-2.2a7 7 0 0 1 2-1.6q1.1-.7 3.8-1.6l.4.5-2.8 1.2q-.8.4-1.3 1t.2 1.6a11 11 0 0 0 6.3 2.2q1.8 0 2.3-.7.4-.4.5-1l.2-1.6 2.5-1.5-.1 1.5a4 4 0 0 1-1 1.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 640 480">
<defs>
<clipPath id="ag-a">
<path fill-opacity=".7" d="M-79.7 0H603v512H-79.7z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(74.7)scale(.9375)">
<path fill="#fff" d="M-79.7 0H603v512H-79.7z"/>
<path fill="#000001" d="M-79.6 0H603v204.8H-79.7z"/>
<path fill="#0072c6" d="M21.3 203.2h480v112h-480z"/>
<path fill="#ce1126" d="M603 .1V512H261.6L603 0zM-79.7.1V512h341.3L-79.7 0z"/>
<path fill="#fcd116" d="M440.4 203.3 364 184l64.9-49-79.7 11.4 41-69.5-70.7 41L332.3 37l-47.9 63.8-19.3-74-21.7 76.3-47.8-65 13.7 83.2L138.5 78l41 69.5-77.4-12.5 63.8 47.8L86 203.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 743 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 640 480">
<path fill="red" d="M0 0h640v480H0z"/>
<path id="al-a" fill="#000001" d="M272 93.3c-4.6 0-12.3 1.5-12.2 5-13-2.1-14.3 3.2-13.5 8q2-2.9 3.9-3.1 2.5-.3 5.4 1.4a22 22 0 0 1 4.8 4.1c-4.6 1.1-8.2.4-11.8-.2a17 17 0 0 1-5.7-2.4c-1.5-1-2-2-4.3-4.3-2.7-2.8-5.6-2-4.7 2.3 2.1 4 5.6 5.8 10 6.6 2.1.3 5.3 1 8.9 1s7.6-.5 9.8 0c-1.3.8-2.8 2.3-5.8 2.8s-7.5-1.8-10.3-2.4c.3 2.3 3.3 4.5 9.1 5.7 9.6 2 17.5 3.6 22.8 6.5a37 37 0 0 1 10.9 9.2c4.7 5.5 5 9.8 5.2 10.8 1 8.8-2.1 13.8-7.9 15.4-2.8.7-8-.7-9.8-2.9-2-2.2-3.7-6-3.2-12 .5-2.2 3.1-8.3.9-9.5a274 274 0 0 0-32.3-15.1c-2.5-1-4.5 2.4-5.3 3.8a50 50 0 0 1-36-23.7c-4.2-7.6-11.3 0-10.1 7.3 1.9 8 8 13.8 15.4 18s17 8.2 26.5 8c5.2 1 5.1 7.6-1 8.9-12.1 0-21.8-.2-30.9-9-6.9-6.3-10.7 1.2-8.8 5.4 3.4 13.1 22.1 16.8 41 12.6 7.4-1.2 3 6.6 1 6.7-8 5.7-22.1 11.2-34.6 0-5.7-4.4-9.6-.8-7.4 5.5 5.5 16.5 26.7 13 41.2 5 3.7-2.1 7.1 2.7 2.6 6.4-18.1 12.6-27.1 12.8-35.3 8-10.2-4.1-11 7.2-5 11 6.7 4 23.8 1 36.4-7 5.4-4 5.6 2.3 2.2 4.8-14.9 12.9-20.8 16.3-36.3 14.2-7.7-.6-7.6 8.9-1.6 12.6 8.3 5.1 24.5-3.3 37-13.8 5.3-2.8 6.2 1.8 3.6 7.3a54 54 0 0 1-21.8 18c-7 2.7-13.6 2.3-18.3.7-5.8-2-6.5 4-3.3 9.4 1.9 3.3 9.8 4.3 18.4 1.3s17.8-10.2 24.1-18.5c5.5-4.9 4.9 1.6 2.3 6.2-12.6 20-24.2 27.4-39.5 26.2-6.7-1.2-8.3 4-4 9 7.6 6.2 17 6 25.4-.2 7.3-7 21.4-22.4 28.8-30.6 5.2-4.1 6.9 0 5.3 8.4-1.4 4.8-4.8 10-14.3 13.6-6.5 3.7-1.6 8.8 3.2 9 2.7 0 8.1-3.2 12.3-7.8 5.4-6.2 5.8-10.3 8.8-19.9 2.8-4.6 7.9-2.4 7.9 2.4-2.5 9.6-4.5 11.3-9.5 15.2-4.7 4.5 3.3 6 6 4.1 7.8-5.2 10.6-12 13.2-18.2 2-4.4 7.4-2.3 4.8 5-6 17.4-16 24.2-33.3 27.8-1.7.3-2.8 1.3-2.2 3.3l7 7c-10.7 3.2-19.4 5-30.2 8l-14.8-9.8c-1.3-3.2-2-8.2-9.8-4.7-5.2-2.4-7.7-1.5-10.6 1 4.2 0 6 1.2 7.7 3.1 2.2 5.7 7.2 6.3 12.3 4.7 3.3 2.7 5 4.9 8.4 7.7l-16.7-.5c-6-6.3-10.6-6-14.8-1-3.3.5-4.6.5-6.8 4.4 3.4-1.4 5.6-1.8 7.1-.3 6.3 3.7 10.4 2.9 13.5 0l17.5 1.1c-2.2 2-5.2 3-7.5 4.8-9-2.6-13.8 1-15.4 8.3a17 17 0 0 0-1.2 9.3q1.1-4.6 4.9-7c8 2 11-1.3 11.5-6.1 4-3.2 9.8-3.9 13.7-7.1 4.6 1.4 6.8 2.3 11.4 3.8q2.4 7.5 11.3 5.6c7 .2 5.8 3.2 6.4 5.5 2-3.3 1.9-6.6-2.5-9.6-1.6-4.3-5.2-6.3-9.8-3.8-4.4-1.2-5.5-3-9.9-4.3 11-3.5 18.8-4.3 29.8-7.8l7.7 6.8q2.3 1.5 3.8 0c6.9-10 10-18.7 16.3-25.3 2.5-2.8 5.6-6.4 9-7.3 1.7-.5 3.8-.2 5.2 1.3 1.3 1.4 2.4 4.1 2 8.2-.7 5.7-2.1 7.6-3.7 11s-3.6 5.6-5.7 8.3c-4 5.3-9.4 8.4-12.6 10.5-6.4 4.1-9 2.3-14 2-6.4.7-8 3.8-2.8 8.1 4.8 2.6 9.2 2.9 12.8 2.2 3-.6 6.6-4.5 9.2-6.6 2.8-3.3 7.6.6 4.3 4.5-5.9 7-11.7 11.6-19 11.5-7.7 1-6.2 5.3-1.2 7.4 9.2 3.7 17.4-3.3 21.6-8 3.2-3.5 5.5-3.6 5 1.9-3.3 9.9-7.6 13.7-14.8 14.2-5.8-.6-5.9 4-1.6 7 9.6 6.6 16.6-4.8 19.9-11.6 2.3-6.2 5.9-3.3 6.3 1.8 0 6.9-3 12.4-11.3 19.4 6.3 10.1 13.7 20.4 20 30.5l19.2-214L320 139c-2-1.8-8.8-9.8-10.5-11-.7-.6-1-1-.1-1.4s3-.8 4.5-1c-4-4.1-7.6-5.4-15.3-7.6 1.9-.8 3.7-.4 9.3-.6a30 30 0 0 0-13.5-10.2c4.2-3 5-3.2 9.2-6.7a86 86 0 0 1-19.5-3.8 37 37 0 0 0-12-3.4zm.8 8.4c3.8 0 6.1 1.3 6.1 2.9s-2.3 2.9-6.1 2.9-6.2-1.5-6.2-3c0-1.6 2.4-2.8 6.2-2.8"/>
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 640 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 640 480">
<path fill="#d90012" d="M0 0h640v160H0z"/>
<path fill="#0033a0" d="M0 160h640v160H0z"/>
<path fill="#f2a800" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 640 480">
<path fill="#74acdf" d="M0 0h640v480H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<g id="ar-c" transform="translate(-64)scale(.96)">
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23s3.8 13.2 5 16.5c1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8s-4.2-6-3.4-16.3-4.2-12.7-3-22"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
</g>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 320 240)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 320 240)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 320 240)"/>
<circle cx="320" cy="240" r="26.7" fill="#f6b40e" stroke="#85340a" stroke-width="1.4"/>
<path id="ar-h" fill="#843511" stroke-width="1" d="M329 234.3c-1.7 0-3.5.8-4.5 2.4 2 1.9 6.6 2 9.7-.2a7 7 0 0 0-5.1-2.2zm0 .4c1.8 0 3.5.8 3.7 1.6-2 2.3-5.3 2-7.4.4q1.6-2 3.8-2z"/>
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(18.1)"/>
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<path fill="#85340a" d="M316 243.7a1.8 1.8 0 1 0 1.8 2.9 4 4 0 0 0 2.2.6h.2q1 0 2.3-.6.5.7 1.5.7a1.8 1.8 0 0 0 .3-3.6q.8.3.8 1.2a1.2 1.2 0 0 1-2.4 0 3 3 0 0 1-2.6 1.7 3 3 0 0 1-2.5-1.7q-.1 1.1-1.3 1.2-1-.1-1.2-1.2c-.2-1.1.3-1 .8-1.2zm2 5.4c-2.1 0-3 2-4.8 3.1 1-.4 1.8-1.2 3.3-2s2.6.2 3.5.2 2-1 3.5-.2l3.3 2c-1.9-1.2-2.7-3-4.8-3q-.7 0-2 .6z"/>
<path fill="#85340a" d="M317.2 251.6q-1.1 0-3.4.6c3.7-.8 4.5.5 6.2.5 1.6 0 2.5-1.3 6.1-.5-4-1.2-4.9-.4-6.1-.4-.8 0-1.4-.3-2.8-.2"/>
<path fill="#85340a" d="M314 252.2h-.8c4.3.5 2.3 3 6.8 3s2.5-2.5 6.8-3c-4.5-.4-3.1 2.3-6.8 2.3-3.5 0-2.4-2.3-6-2.3"/>
<path fill="#85340a" d="M323.7 258.9a3.7 3.7 0 0 0-7.4 0 3.8 3.8 0 0 1 7.4 0"/>
<path id="ar-e" fill="#85340a" stroke-width="1" d="M303.4 234.3c4.7-4.1 10.7-4.8 14-1.7a8 8 0 0 1 1.5 3.4q.6 3.6-2.1 7.5l.8.4q2.4-4.7 1.6-9.4l-.6-2.3c-4.5-3.7-10.7-4-15.2 2z"/>
<path id="ar-d" fill="#85340a" stroke-width="1" d="M310.8 233c2.7 0 3.3.6 4.5 1.7 1.2 1 1.9.8 2 1 .3.2 0 .8-.3.6q-.7-.2-2.5-1.6c-1.8-1.4-2.5-1-3.7-1-3.7 0-5.7 3-6.1 2.8-.5-.2 2-3.5 6.1-3.5"/>
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-18.4)"/>
<circle id="ar-f" cx="310.9" cy="236.3" r="1.8" fill="#85340a" stroke-width="1"/>
<path id="ar-g" fill="#85340a" stroke-width="1" d="M305.9 237.5c3.5 2.7 7 2.5 9 1.3 2-1.3 2-1.7 1.6-1.7s-.8.4-2.4 1.3c-1.7.8-4.1.8-8.2-.9"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-at" viewBox="0 0 640 480">
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#c8102e" d="M0 0h640v160H0zm0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
<path fill="#00008B" d="M0 0h640v480H0z"/>
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ax" viewBox="0 0 640 480">
<defs>
<clipPath id="ax-a">
<path fill-opacity=".7" d="M106.3 0h1133.3v850H106.3z"/>
</clipPath>
</defs>
<g clip-path="url(#ax-a)" transform="matrix(.56472 0 0 .56482 -60 -.1)">
<path fill="#0053a5" d="M0 0h1300v850H0z"/>
<g fill="#ffce00">
<path d="M400 0h250v850H400z"/>
<path d="M0 300h1300v250H0z"/>
</g>
<g fill="#d21034">
<path d="M475 0h100v850H475z"/>
<path d="M0 375h1300v100H0z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 556 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-az" viewBox="0 0 640 480">
<path fill="#3f9c35" d="M.1 0h640v480H.1z"/>
<path fill="#ed2939" d="M.1 0h640v320H.1z"/>
<path fill="#00b9e4" d="M.1 0h640v160H.1z"/>
<circle cx="304" cy="240" r="72" fill="#fff"/>
<circle cx="320" cy="240" r="60" fill="#ed2939"/>
<path fill="#fff" d="m384 200 7.7 21.5 20.6-9.8-9.8 20.7L424 240l-21.5 7.7 9.8 20.6-20.6-9.8L384 280l-7.7-21.5-20.6 9.8 9.8-20.6L344 240l21.5-7.7-9.8-20.6 20.6 9.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ba" viewBox="0 0 640 480">
<defs>
<clipPath id="ba-a">
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#ba-a)" transform="translate(80)scale(.9375)">
<path fill="#009" d="M-85.3 0h682.6v512H-85.3z"/>
<path fill="#FC0" d="m56.5 0 511 512.3V.3z"/>
<path fill="#FFF" d="M439.9 481.5 412 461.2l-28.6 20.2 10.8-33.2-28.2-20.5h35l10.8-33.2 10.7 33.3h35l-28 20.7zm81.3 10.4-35-.1-10.7-33.3-10.8 33.2h-35l28.2 20.5-10.8 33.2 28.6-20.2 28 20.3-10.5-33zM365.6 384.7l28-20.7-35-.1-10.7-33.2-10.8 33.2-35-.1 28.2 20.5-10.8 33.3 28.6-20.3 28 20.4zm-64.3-64.5 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9l28.2 20.5-10.8 33.2 28.6-20.2 27.9 20.3zm-63.7-63.6 28-20.7h-35L220 202.5l-10.8 33.2h-35l28.2 20.4-10.8 33.3 28.6-20.3 28 20.4-10.5-33zm-64.4-64.3 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9L138 192l-10.8 33.2 28.6-20.2 27.9 20.3-10.4-33zm-63.6-63.9 27.9-20.7h-35L91.9 74.3 81 107.6H46L74.4 128l-10.9 33.2L92.1 141l27.8 20.4zm-64-64 27.9-20.7h-35L27.9 10.3 17 43.6h-35L10.4 64l-11 33.3L28.1 77l27.8 20.4zm-64-64L9.4-20.3h-35l-10.7-33.3L-47-20.4h-35L-53.7 0l-10.8 33.2L-35.9 13l27.8 20.4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-bb" viewBox="0 0 640 480">
<path fill="#00267f" d="M0 0h640v480H0z"/>
<path fill="#ffc726" d="M213.3 0h213.4v480H213.3z"/>
<path id="bb-a" fill="#000001" d="M319.8 135.5c-7 19-14 38.6-29.2 53.7 4.7-1.6 13-3 18.2-2.8v79.5l-22.4 3.3c-.8 0-1-1.3-1-3-2.2-24.7-8-45.5-14.8-67-.5-2.9-9-14-2.4-12 .8 0 9.5 3.6 8.2 1.9a85 85 0 0 0-46.4-24c-1.5-.3-2.4.5-1 2.2 22.4 34.6 41.3 75.5 41.1 124 8.8 0 30-5.2 38.7-5.2v56.1H320l2.5-156.7z"/>
<use xlink:href="#bb-a" width="100%" height="100%" transform="matrix(-1 0 0 1 639.5 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 628 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bd" viewBox="0 0 640 480">
<path fill="#006a4e" d="M0 0h640v480H0z"/>
<circle cx="280" cy="240" r="160" fill="#f42a41"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

Some files were not shown because too many files have changed in this diff Show More