Compare commits

...
9 Commits
Author SHA1 Message Date
rouggy b8db653981 fix: ESC now stops the active CW engine (Icom/Flex), not just WinKeyer
The ESC abort keyed WinkeyerStop unconditionally, so it never aborted the Icom
CI-V or Flex CWX keyer. Route the stop through cwSource like the panel Stop
button does.
2026-07-20 15:51:32 +02:00
rouggy 9729ef62ba feat: FlexRadio CWX CW keyer (send/stop/speed) — no WinKeyer/SmartCAT needed
Adds a third CW engine alongside WinKeyer and Icom CI-V: FlexRadio's CWX keyer
over the existing SmartSDR CAT connection.

- cat.FlexController: SendCW (cwx send), StopCW (cwx clear); flex.go implements
  them, escaping the quoted text form. Speed reuses SetCWSpeed (cw wpm).
- App bindings FlexSendCW/FlexStopCW/FlexSetKeySpeed via FlexDo.
- Frontend: cwSource gains 'flex'; macros/auto-call/<LOGQSO>/send-on-type route to
  Flex when the CAT backend is a Flex (estimate-based timing like Icom, no busy
  echo). Keyer panel shows 'Flex CWX' + ready/offline; Settings adds the engine
  option with a CAT-backend guard.

Core send/stop/speed first; type-ahead buffer + mid-send backspace (cwx delete)
to follow.
2026-07-20 15:13:24 +02:00
rouggy cc6411a618 fix: Antenna Genius buttons ignored clicks while the CW decoder ran
PortBtn was defined inside AntGeniusPanel, so every render produced a new
component type and React unmounted/remounted every port button. Normally
re-renders are rare so it's invisible, but the CW decoder floods the app with
cw:text/cw:status events (re-render many times a second), tearing the buttons
down between mousedown and mouseup — the click never completed and the antenna
never switched. Move PortBtn to module scope (stable identity) and pass
onActivate/t as props.
2026-07-20 14:45:29 +02:00
rouggy 991831bdec chore: DDFM catalog file updated from Publish-to-catalog (address+QTH postal OR-rules, full department reference list) 2026-07-20 14:39:15 +02:00
rouggy 1b2da95ad4 ui: widen the Award management modal (max-w-6xl, 95vw) so the fuller footer fits 2026-07-20 14:35:18 +02:00
rouggy 10d86db50a feat: rename the database from Settings, keeping all config
Adds a Rename button to Settings -> Database: renames the current SQLite database
(e.g. opslog.db -> F4BPO.db) while preserving every setting/profile, unlike New
database which starts empty. Implemented as a VACUUM INTO copy + pointer switch;
the old file (open and locked now) is scheduled via config.json delete_pending
and removed, with its -wal/-shm sidecars, on the next launch.
2026-07-20 14:30:45 +02:00
rouggy 8538f48259 feat: Publish-to-catalog export for awards (edit in UI, version, ship to team)
Adds ExportAwardForCatalog(code, version): writes the selected award as a
catalog-format JSON ({"def":…,"references":…}) stamped with a version and with
user_edited cleared, ready to paste over internal/award/catalog/<code>.json. A
new release then propagates the change to the whole team via mergeCatalog —
unedited copies auto-upgrade, edited ones are offered the update.

UI: a version input + "Publish to catalog…" button in the award editor footer,
defaulting to one past the award's current version.
2026-07-20 14:20:30 +02:00
rouggy 0fa91c3d5f feat: ship DDFM v2 (postal-code OR-rule) via the award catalog so it reaches the team
DDFM now derives the French department from the address postal code, not only
from a note typed by hand — the catalog def gains the address OR-rule (\d{2}\d{3}
-> Dxx), France+Corsica scope, and version 2. On next launch every operator whose
DDFM is unedited auto-upgrades to it (user-edited copies are offered it); the
materialised award_refs then recompute so ~500+ French QSOs gain their department.

Also: a catalog definition update now clears the award_refs backfill flag, so any
future catalog bump self-heals the stored columns without a manual flag bump.
2026-07-20 13:57:50 +02:00
rouggy c86d331bd9 fix: re-materialize award_refs so new matching rules reach existing QSOs
The one-shot award_refs backfill never revisited rows after it ran, so a QSO
materialized before an award gained a rule kept stale values — e.g. DDFM's
postal-code OR-rule matched nothing on the 500+ French QSOs whose department
comes from the address, only the handful with it typed in notes. Two fixes:
bump the backfill flag (awards.materialized.v2) so every operator re-materializes
once on next launch with their current definitions, and make RescanAwards (the
Awards tab Rescan button) also recompute award_refs for an on-demand refresh.
2026-07-20 13:41:14 +02:00
12 changed files with 1165 additions and 57 deletions
+168 -4
View File
@@ -699,6 +699,20 @@ func (a *App) startup(ctx context.Context) {
usingDefault = false
}
}
// A rename in a previous session left the OLD file to delete now that it's no
// longer open. Only ever delete a file that ISN'T the one we're about to use.
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
old := strings.TrimSpace(boot.DeletePending)
if old != a.dbPath {
for _, p := range []string{old, old + "-wal", old + "-shm"} {
if err := os.Remove(p); err == nil {
fmt.Printf("OpsLog: removed old database file %s\n", p)
}
}
}
boot.DeletePending = ""
_ = writeBootstrap(dataDir, boot)
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -1340,6 +1354,11 @@ func copyFileData(src, dst string) error {
type dbPointer struct {
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
// DeletePending is the previous database file to remove on the NEXT launch —
// set by a rename, which can't delete the still-open old file in-process. The
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
// one in use, then clears this.
DeletePending string `json:"delete_pending,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
@@ -1653,9 +1672,10 @@ func (a *App) PickSaveDatabase() (string, error) {
return "", fmt.Errorf("no app context")
}
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…",
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
Title: "Save the OpsLog database to…",
DefaultDirectory: filepath.Dir(a.dbPath),
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
@@ -1697,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error {
return writeDBPointer(a.dataDir, dest)
}
// RenameDatabase renames the current database file to dest — keeping ALL config
// (it is the same database under a new name), unlike "New database" which starts
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
// can't be removed in-process on Windows). dest must not already exist.
func (a *App) RenameDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
if a.db == nil {
return fmt.Errorf("database not open")
}
old := a.dbPath
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
return fmt.Errorf("that is already the current database name")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy database: %w", err)
}
boot := readBootstrap(a.dataDir)
boot.DBPath = dest
// Only schedule the old file for deletion when it's a real, on-disk file (a
// custom path or the default opslog.db) — never something we somehow share
// with the destination.
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
boot.DeletePending = old
}
return writeBootstrap(a.dataDir, boot)
}
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error {
@@ -2452,6 +2510,14 @@ func (a *App) migrateAwardDefs() {
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
a.reseedRefsFromCatalog(code)
}
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
// the materialised award_refs on existing QSOs are now stale. Clear the
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
// re-materialise every row against the updated definitions. This makes ANY
// future catalog bump self-heal the stored columns without a manual flag bump.
if len(updated) > 0 {
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
}
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
@@ -3415,6 +3481,10 @@ func (a *App) invalidateAwardStats() {
// flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error {
a.invalidateAwardStats()
// Also refresh the materialised award_refs on every QSO (the grid columns read
// from there). Manual trigger for when a definition/reference change should be
// re-applied to existing rows on demand rather than waiting for the next event.
a.recomputeAwardRefsAsync()
return nil
}
@@ -3821,7 +3891,12 @@ func (a *App) recomputeAwardRefsAsync() {
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
const keyAwardsMaterialized = "awards.materialized.v1"
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
// re-materialise award_refs once on next launch. Needed when the stored values
// could be stale against the CURRENT award definitions — e.g. rows materialised
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
// the one-shot backfill would otherwise never revisit.
const keyAwardsMaterialized = "awards.materialized.v2"
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
// (or were logged by an older client that didn't materialise them). It runs at
@@ -4186,6 +4261,65 @@ func (a *App) ExportAward(code string) (string, error) {
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
//
// The workflow it enables: edit an award in the UI, call this with the NEXT
// version number, and paste the file over that award's catalog JSON. A new OpsLog
// release then carries the change to the whole team: every operator whose copy is
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
// but are offered the update.
//
// Two things a plain export/mirror can't do and this does, both essential for a
// catalog file: it STAMPS the award's Version (the UI save deliberately never
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
// of every future catalog update.
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
if a.awardRefs == nil || a.ctx == nil {
return "", fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
var def award.Def
found := false
for _, d := range a.awardDefs() {
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
def, found = d, true
break
}
}
if !found {
return "", fmt.Errorf("unknown award %q", code)
}
def.Version = version
def.UserEdited = false // a catalog seed is not "the operator's edit"
def.Builtin = true
refs, err := a.awardRefs.List(a.ctx, code)
if err != nil {
return "", fmt.Errorf("load references: %w", err)
}
entry := struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
}{Def: def, References: refs}
b, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
DefaultFilename: strings.ToLower(code) + ".json",
Title: "Export " + code + " for the catalog",
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
})
if err != nil || strings.TrimSpace(path) == "" {
return "", err
}
if err := os.WriteFile(path, b, 0o644); err != nil {
return "", err
}
return path, nil
}
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil {
@@ -10225,6 +10359,36 @@ func (a *App) IcomSendCW(text string) error {
return err
}
// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
func (a *App) FlexSendCW(text string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
if err != nil {
applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
}
return err
}
// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
func (a *App) FlexStopCW() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
}
// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
// CW speed).
func (a *App) FlexSetKeySpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
if a.cat == nil {
+32 -13
View File
@@ -36,6 +36,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
@@ -775,7 +776,7 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
@@ -804,7 +805,9 @@ export default function App() {
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
@@ -2036,7 +2039,7 @@ export default function App() {
setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -2134,11 +2137,13 @@ export default function App() {
const keyed = resolved ? resolved + ' ' : resolved;
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') {
// The rig's keyer gives no busy echo back, so show the text we sent and,
// for <LOGQSO>, wait the estimated send duration before logging.
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
// the text we sent and, for <LOGQSO>, wait the estimated send duration
// before logging.
setWkSent(resolved);
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
}
@@ -2174,7 +2179,7 @@ export default function App() {
// the wait at the ESTIMATED send time (not the busy flag alone): over a
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
// true for tens of seconds, which made the next CQ fire ~120s late.
if (cwSourceRef.current === 'icom') {
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
@@ -2212,7 +2217,10 @@ export default function App() {
writeUiPref('opslog.wkAutoCallSecs', String(v));
}
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkSendRaw(chars: string) {
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
WinkeyerSend(chars).catch(() => {});
}
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
@@ -2880,7 +2888,14 @@ export default function App() {
const keyerLive = wkActiveRef.current;
// ESC aborts the current CW transmission AND the auto-call loop, so it
// won't resend after the gap — you must click a CQ macro to restart it.
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
// Route the abort to whichever engine is active (was WinKeyer-only, so
// ESC didn't stop the Icom or Flex keyer).
if (keyerLive) {
stopAutoCall();
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
if (!keyerLive || wkEscClearsRef.current) {
resetEntry();
callsignRef.current?.focus();
@@ -4406,8 +4421,8 @@ export default function App() {
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={cwSource === 'icom'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
status={cwSource === 'icom' || cwSource === 'flex'
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
: wkStatus}
ports={wkPorts}
port={wkPort}
@@ -4424,11 +4439,15 @@ export default function App() {
onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
onStop={() => { stopAutoCall();
if (cwSource === 'icom') IcomStopCW().catch(() => {});
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
+33 -23
View File
@@ -33,6 +33,37 @@ function pretty(name: string): string {
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
// would be a new component *type* on every render, so React would unmount and
// remount every port button each time the panel re-renders — harmless when that's
// rare, but with the CW decoder running the parent re-renders many times a second
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
// different element instances), so antenna changes silently did nothing.
function PortBtn({ port, index, active, tx, onActivate, t }: {
port: 1 | 2; index: number; active: boolean; tx: boolean;
onActivate: (port: number, antenna: number) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}) {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
</div>
);
})}
+32 -2
View File
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
ExportAwardForCatalog,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
@@ -37,7 +38,7 @@ export type AwardDef = {
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;
total: number; builtin?: boolean; version?: number;
};
type AwardOrRule = {
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// Version to stamp into a "publish for catalog" export — defaults to one past
// the selected award's current version whenever the selection changes.
const [catVer, setCatVer] = useState('1');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
// paste over internal/award/catalog/<code>.json. A new release then ships it to
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
async function exportForCatalog() {
setErr('');
if (!cur) return;
const v = Math.trunc(Number(catVer));
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
try {
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
{/* Publish the selected award to the catalog: stamp a version and write a
file to paste over internal/award/catalog/<code>.json, so a release
ships your change to the whole team. */}
{cur && (
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
<Button variant="outline" onClick={exportForCatalog}>
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+35 -2
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -28,7 +28,7 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
@@ -2807,6 +2807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent>
</Select>
@@ -2837,6 +2838,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</>
) : wk.engine === 'flex' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection no WinKeyer or SmartCAT needed. It reuses the connection set in Settings CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
</p>
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden>⚠</span>
<span>
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
</span>
</p>
)}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : (
<>
<div className="grid grid-cols-4 gap-3">
@@ -3978,6 +3999,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Rename the CURRENT database (keeps all config), unlike New database which
// starts empty. The old file is removed on the next launch.
async function renameDb() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await RenameDatabase(p);
await refreshDb();
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
@@ -4056,6 +4088,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div>
+7 -5
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' ? (
{source === 'icom' || source === 'flex' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
) : !connected ? (
<>
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')}
{source === 'winkeyer' && (
{(source === 'winkeyer' || source === 'flex') && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
+12 -4
View File
@@ -244,7 +244,7 @@ const en: Dict = {
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
@@ -275,7 +275,7 @@ const en: Dict = {
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': '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.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
@@ -307,6 +307,10 @@ const en: Dict = {
'awed.builtin': 'Built-in',
'awed.awardsFolder': 'Awards folder',
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
'awed.catalogPublish': 'Publish to catalog…',
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
'awed.protectedFlag': 'Protected',
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
@@ -540,7 +544,7 @@ const fr: Dict = {
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). Lancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
@@ -568,7 +572,7 @@ const fr: Dict = {
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
@@ -598,6 +602,10 @@ const fr: Dict = {
'awed.builtin': 'Intégré',
'awed.awardsFolder': 'Dossier awards',
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
'awed.catalogPublish': 'Publier au catalogue…',
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez lutilisateur — alors que tu las livré.',
'awed.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
+10
View File
@@ -168,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -192,6 +194,8 @@ export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSendCW(arg1:string):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
@@ -222,6 +226,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetKeySpeed(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -278,6 +284,8 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
@@ -676,6 +684,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
+20
View File
@@ -294,6 +294,10 @@ export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwardForCatalog(arg1, arg2) {
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
}
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
@@ -342,6 +346,10 @@ export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSendCW(arg1) {
return window['go']['main']['App']['FlexSendCW'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
@@ -402,6 +410,10 @@ export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function FlexSetKeySpeed(arg1) {
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
@@ -514,6 +526,10 @@ export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
@@ -1310,6 +1326,10 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
+791 -4
View File
@@ -4,11 +4,27 @@
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"ref_display": "name",
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"or_rules": [
{
"field": "address",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
},
{
"field": "qth",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
}
],
"dxcc_filter": [
227
227,
214
],
"confirm": [
"lotw",
@@ -18,6 +34,777 @@
"lotw"
],
"total": 96,
"builtin": true
}
}
"builtin": true,
"version": 2
},
"references": [
{
"code": "D01",
"name": "Ain",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D02",
"name": "Aisne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D03",
"name": "Allier",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D04",
"name": "Alpes-de-Haute-Provence",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D05",
"name": "Hautes-Alpes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D06",
"name": "Alpes-Maritimes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D07",
"name": "Ardèche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D08",
"name": "Ardennes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D09",
"name": "Ariège",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D10",
"name": "Aube",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D11",
"name": "Aude",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D12",
"name": "Aveyron",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D13",
"name": "Bouches-du-Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D14",
"name": "Calvados",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D15",
"name": "Cantal",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D16",
"name": "Charente",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D17",
"name": "Charente-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D18",
"name": "Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D19",
"name": "Corrèze",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D21",
"name": "Côte-d'Or",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D22",
"name": "Côtes-d'Armor",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D23",
"name": "Creuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D24",
"name": "Dordogne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D25",
"name": "Doubs",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D26",
"name": "Drôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D27",
"name": "Eure",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D28",
"name": "Eure-et-Loir",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D29",
"name": "Finistère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2A",
"name": "Corse-du-Sud",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2B",
"name": "Haute-Corse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D30",
"name": "Gard",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D31",
"name": "Haute-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D32",
"name": "Gers",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D33",
"name": "Gironde",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D34",
"name": "Hérault",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D35",
"name": "Ille-et-Vilaine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D36",
"name": "Indre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D37",
"name": "Indre-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D38",
"name": "Isère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D39",
"name": "Jura",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D40",
"name": "Landes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D41",
"name": "Loir-et-Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D42",
"name": "Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D43",
"name": "Haute-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D44",
"name": "Loire-Atlantique",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D45",
"name": "Loiret",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D46",
"name": "Lot",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D47",
"name": "Lot-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D48",
"name": "Lozère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D49",
"name": "Maine-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D50",
"name": "Manche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D51",
"name": "Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D52",
"name": "Haute-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D53",
"name": "Mayenne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D54",
"name": "Meurthe-et-Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D55",
"name": "Meuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D56",
"name": "Morbihan",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D57",
"name": "Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D58",
"name": "Nièvre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D59",
"name": "Nord",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D60",
"name": "Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D61",
"name": "Orne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D62",
"name": "Pas-de-Calais",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D63",
"name": "Puy-de-Dôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D64",
"name": "Pyrénées-Atlantiques",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D65",
"name": "Hautes-Pyrénées",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D66",
"name": "Pyrénées-Orientales",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D67",
"name": "Bas-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D68",
"name": "Haut-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D69",
"name": "Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D70",
"name": "Haute-Saône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D71",
"name": "Saône-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D72",
"name": "Sarthe",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D73",
"name": "Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D74",
"name": "Haute-Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D75",
"name": "Paris",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D76",
"name": "Seine-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D77",
"name": "Seine-et-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D78",
"name": "Yvelines",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D79",
"name": "Deux-Sèvres",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D80",
"name": "Somme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D81",
"name": "Tarn",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D82",
"name": "Tarn-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D83",
"name": "Var",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D84",
"name": "Vaucluse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D85",
"name": "Vendée",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D86",
"name": "Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D87",
"name": "Haute-Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D88",
"name": "Vosges",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D89",
"name": "Yonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D90",
"name": "Territoire de Belfort",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D91",
"name": "Essonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D92",
"name": "Hauts-de-Seine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D93",
"name": "Seine-Saint-Denis",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D94",
"name": "Val-de-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D95",
"name": "Val-d'Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
}
]
}
+5
View File
@@ -394,6 +394,11 @@ type FlexController interface {
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
// StopCW clears the buffer, aborting the send.
SendCW(string) error
StopCW() error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
+20
View File
@@ -1581,6 +1581,26 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
return nil
}
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
// characters fed while it's already sending append and key in order (the basis
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
func (f *Flex) SendCW(text string) error {
text = strings.TrimRight(text, "\r\n")
if strings.TrimSpace(text) == "" {
return nil
}
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
f.send(`cwx send "` + esc + `"`)
return nil
}
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
func (f *Flex) StopCW() error {
f.send("cwx clear")
return nil
}
func (f *Flex) SetCWSidetone(on bool) error {
f.mu.Lock()
f.tx.cwSidetone = on