diff --git a/app.go b/app.go index 63c9ca5..5103a4a 100644 --- a/app.go +++ b/app.go @@ -237,6 +237,7 @@ const ( keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band" keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150" keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts + keyRowColors = "appearance.row_colors" keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam) keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp) keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0) @@ -5831,13 +5832,34 @@ func (a *App) stampQSONumbers(list []qso.QSO) { } } +// countQSOForNumbering is Count with the nil-repo guard the numbering needs — +// it runs before the database is up on a fresh start. +func (a *App) countQSOForNumbering() (int64, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + return a.qso.Count(a.ctx) +} + // qsoNumberIndex returns id → chronological position, building it once and // keeping it until the log changes (invalidateAwardStats drops it). func (a *App) qsoNumberIndex() map[int64]int { a.qsoNumMu.Lock() defer a.qsoNumMu.Unlock() if a.qsoNumbers != nil { - return a.qsoNumbers + // Cheap consistency check rather than trusting every insert path to have + // remembered to invalidate. Several bulk paths — the POTA hunter import, + // the LoTW and QRZ "add contacts I was missing" passes — insert straight + // through the repo, and they add contacts with OLD dates, which lands them + // in the MIDDLE of the order and shifts every number after them. A stale + // map there is not merely incomplete, it is wrong. + // + // One indexed COUNT against a map length, versus rereading every id. + if n, err := a.countQSOForNumbering(); err == nil && int(n) != len(a.qsoNumbers) { + a.qsoNumbers = nil + } else { + return a.qsoNumbers + } } if a.qso == nil { return nil @@ -11991,6 +12013,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { return 0, fmt.Errorf("insert qso: %w", err) } q.ID = id + // Same as the manual path: give the contact its number without rereading the + // log. This insert bypasses AddQSO entirely, which is why UDP-logged contacts + // came out unnumbered while hand-logged ones did not. + a.noteQSONumbered(id, q.QSODate) a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async) // Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget // refresh immediately, then run the DB-heavy enrichment off the critical path diff --git a/appearance.go b/appearance.go new file mode 100644 index 0000000..ef62ed6 --- /dev/null +++ b/appearance.go @@ -0,0 +1,89 @@ +package main + +// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does +// and the reason an operator can tell at a glance what still needs sending. +// +// Rules are ORDERED and the first match wins, because a contact is usually +// several things at once: one confirmed on LoTW and by card is confirmed, not +// "sent, awaiting reply". Putting the order in the data rather than in a chain +// of ifs is what lets the settings panel show it in the same order it applies. + +import ( + "encoding/json" + "regexp" + "strings" +) + +// RowColorRule is one status and the colour it paints. +type RowColorRule struct { + ID string `json:"id"` + Color string `json:"color"` + Enabled bool `json:"enabled"` +} + +// RowColorSettings is the whole appearance block. +type RowColorSettings struct { + Enabled bool `json:"enabled"` + Rules []RowColorRule `json:"rules"` +} + +// The rule ids, in priority order. The frontend matches on these and holds the +// labels, so a translated name never has to travel through the settings. +var rowColorOrder = []string{ + "confirmed_lotw", // LoTW confirmation received + "confirmed_paper", // card or eQSL received + "sent_waiting", // sent by some route, nothing back yet + "to_send", // a card is requested / queued and has not gone out +} + +// Defaults: green for done, amber for waiting, blue for owed. Deliberately +// muted — they are composited at low opacity over a dark grid, and a saturated +// value there reads as an error state rather than a status. +var rowColorDefaults = map[string]string{ + "confirmed_lotw": "#16a34a", + "confirmed_paper": "#0ea5e9", + "sent_waiting": "#f59e0b", + "to_send": "#a855f7", +} + +// hexColor guards what reaches the stylesheet. The value is interpolated into a +// CSS color-mix() by the grid, so anything that is not plainly a hex colour is +// refused rather than passed through. +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func normRowColors(s RowColorSettings) RowColorSettings { + byID := map[string]RowColorRule{} + for _, r := range s.Rules { + byID[r.ID] = r + } + out := RowColorSettings{Enabled: s.Enabled} + for _, id := range rowColorOrder { + r := byID[id] + r.ID = id + if !hexColor.MatchString(strings.TrimSpace(r.Color)) { + r.Color = rowColorDefaults[id] + } + out.Rules = append(out.Rules, r) + } + return out +} + +// GetRowColors returns the row-colouring configuration, defaults included so the +// panel never has to invent one. +func (a *App) GetRowColors() RowColorSettings { + var s RowColorSettings + if raw := a.settingOr(keyRowColors, ""); raw != "" { + _ = json.Unmarshal([]byte(raw), &s) + } + return normRowColors(s) +} + +// SaveRowColors persists it. +func (a *App) SaveRowColors(s RowColorSettings) error { + b, err := json.Marshal(normRowColors(s)) + if err != nil { + return err + } + a.setSetting(keyRowColors, string(b)) + return nil +} diff --git a/appearance_test.go b/appearance_test.go new file mode 100644 index 0000000..5697197 --- /dev/null +++ b/appearance_test.go @@ -0,0 +1,51 @@ +package main + +import "testing" + +// The colour is interpolated into a CSS color-mix() by the grid, so anything +// that is not plainly a hex colour has to be refused rather than passed on. +func TestRowColorsRefuseAnythingButHex(t *testing.T) { + in := RowColorSettings{Enabled: true, Rules: []RowColorRule{ + {ID: "confirmed_lotw", Color: "#123abc", Enabled: true}, + {ID: "sent_waiting", Color: "red; background:url(x)", Enabled: true}, + {ID: "to_send", Color: "", Enabled: true}, + }} + got := normRowColors(in) + + byID := map[string]RowColorRule{} + for _, r := range got.Rules { + byID[r.ID] = r + } + if byID["confirmed_lotw"].Color != "#123abc" { + t.Errorf("a valid colour was rewritten: %q", byID["confirmed_lotw"].Color) + } + if byID["sent_waiting"].Color != rowColorDefaults["sent_waiting"] { + t.Errorf("an injection attempt survived: %q", byID["sent_waiting"].Color) + } + if byID["to_send"].Color != rowColorDefaults["to_send"] { + t.Errorf("an empty colour was kept: %q", byID["to_send"].Color) + } +} + +// Priority lives in the data, not in a chain of ifs: a contact confirmed on +// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows +// the rules in the order they apply, so that order must survive a round trip. +func TestRowColorsKeepPriorityOrder(t *testing.T) { + // Saved in a jumbled order, as a hand-edited settings row could be. + got := normRowColors(RowColorSettings{Rules: []RowColorRule{ + {ID: "to_send", Color: "#111111"}, + {ID: "confirmed_lotw", Color: "#222222"}, + }}) + if len(got.Rules) != len(rowColorOrder) { + t.Fatalf("got %d rules, want every one present", len(got.Rules)) + } + for i, id := range rowColorOrder { + if got.Rules[i].ID != id { + t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id) + } + } + // The saved colours survived the reordering. + if got.Rules[0].Color != "#222222" { + t.Errorf("confirmed_lotw lost its colour: %q", got.Rules[0].Color) + } +} diff --git a/changelog.json b/changelog.json index 3e43b6b..fdfc926 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,9 @@ "Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.", "Band openings: unticking a band now actually stops its announcements, and puts its badge out.", "Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup.", - "New selectable column \"QSO number\": position in the log, 1 for the oldest contact." + "New selectable column \"QSO number\": position in the log, 1 for the oldest contact.", + "New Appearance settings: colour whole log rows by QSL status, with your own colours from a palette.", + "The QSO number was missing on contacts logged from WSJT-X — that path bypassed the numbering." ], "fr": [ "Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.", @@ -16,7 +18,9 @@ "Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.", "Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.", "Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie.", - "Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien." + "Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien.", + "Nouveaux réglages Apparence : colorer les lignes entières du log selon le statut QSL, avec tes couleurs choisies dans une palette.", + "Le numéro de QSO manquait sur les contacts enregistrés depuis WSJT-X — ce chemin contournait la numérotation." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b44368..aa53e78 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -91,6 +91,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { applySpotDisplay, readSpotDisplayOptions, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; +import { GetRowColors } from '../wailsjs/go/main/App'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel'; @@ -1923,6 +1924,10 @@ export default function App() { // change). The grid reads them straight from the row — no per-page backend // recompute — so here we just parse the stored JSON string into the code→ref // object the award columns expect (keys are already upper-case). + // Row colouring by QSL status (Settings → Appearance). Reloaded when the + // settings dialog closes, which is the only place it changes. + const [rowColors, setRowColors] = useState(null); + useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]); const qsosWithAwards = useMemo( () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })), [qsos], @@ -5070,6 +5075,7 @@ export default function App() { // rewrote the other's widths. storageKey="mainpane" rows={qsosWithAwards as any} + rowColors={rowColors} myGrid={station.my_grid} total={total} awardCols={awardCols} @@ -6353,6 +6359,7 @@ export default function App() { = { + confirmed_lotw: 'appr.confirmedLotw', + confirmed_paper: 'appr.confirmedPaper', + sent_waiting: 'appr.sentWaiting', + to_send: 'appr.toSend', +}; + +export function AppearancePanel() { + const { t } = useI18n(); + const [cfg, setCfg] = useState(null); + + useEffect(() => { + (async () => { + try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ } + })(); + }, []); + + const save = (next: RowColorSettings) => { + setCfg(next); + SaveRowColors(next as any).catch(() => {}); + }; + const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean }>) => { + if (!cfg) return; + save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) }); + }; + + if (!cfg) return
; + + return ( +
+ + + {cfg.enabled && ( +
+ {/* Order matters and is shown: a contact is usually several of these at + once, and the first match wins. */} +

{t('appr.orderHint')}

+ {cfg.rules.map((r, i) => ( +
+ + {r.enabled && ( +
+ {PALETTE.map((c) => ( +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index c8eff7e..e735c58 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs'; import { useI18n } from '@/lib/i18n'; import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead'; +import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors'; // Register every Community feature once. v32+ requires explicit registration; // AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/ @@ -80,6 +81,8 @@ type Props = { // 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 }[]; + // Whole-row colouring by QSL / LoTW status (Settings → Appearance). + rowColors?: RowColorSettings | null; }; const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2'; @@ -303,7 +306,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] => return rest; }); -export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { +export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) { const { t } = useI18n(); const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); @@ -700,6 +703,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, animateRows={false} suppressCellFocus getRowId={(p) => String((p.data as any).id)} + getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 603e019..ff2c994 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -77,6 +77,7 @@ import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat' import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { OperatingPanel } from '@/components/OperatingPanel'; +import { AppearancePanel } from '@/components/AppearancePanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; type LookupSettings = LookupSettingsForm; @@ -176,6 +177,7 @@ interface Props { `disabled: true` greys them out and shows the "coming soon" placeholder. */ type SectionId = | 'general' + | 'appearance' | 'email' | 'station' | 'profiles' @@ -266,6 +268,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] { kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [ { kind: 'item', label: t('sec.general'), id: 'general' }, + { kind: 'item', label: t('sec.appearance'), id: 'appearance' }, { kind: 'item', label: t('sec.email'), id: 'email' }, { kind: 'item', label: t('sec.lookup'), id: 'lookup' }, { kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [ @@ -290,7 +293,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] // Map section id → i18n key (breadcrumb / placeholders). const SECTION_KEY: Partial> = { station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations', - 'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', + 'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp', adifmon: 'sec.adifmon', webpublish: 'sec.webpublish', @@ -6163,6 +6166,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // Map sections to their content + icon (for placeholder). const PANELS: Record JSX.Element> = { general: GeneralPanel, + appearance: () => , email: EmailPanel, station: StationPanel, profiles: ProfilesPanel, diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index a76a858..8edd673 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -114,7 +114,7 @@ const en: Dict = { 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services', - 'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', + 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.confirmedLotw': 'Confirmed on LoTW', 'appr.confirmedPaper': 'Confirmed by card or eQSL', 'appr.sentWaiting': 'Sent, no answer yet', 'appr.toSend': 'Card requested, not sent', 'appr.custom': 'Pick any colour', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor', @@ -541,7 +541,7 @@ const fr: Dict = { 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', - 'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", + 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.confirmedLotw': 'Confirmé sur LoTW', 'appr.confirmedPaper': 'Confirmé par carte ou eQSL', 'appr.sentWaiting': 'Envoyé, sans réponse', 'appr.toSend': 'Carte demandée, non envoyée', 'appr.custom': 'Choisir une couleur', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF', diff --git a/frontend/src/lib/rowColors.ts b/frontend/src/lib/rowColors.ts new file mode 100644 index 0000000..47161c0 --- /dev/null +++ b/frontend/src/lib/rowColors.ts @@ -0,0 +1,38 @@ +// Row colouring for the log grid, by QSL / LoTW status. +// +// The rules are ORDERED and the first match wins: a contact is usually several +// things at once, and one confirmed on LoTW and by card is confirmed, not +// "sent, awaiting reply". + +export type RowColorRule = { id: string; color: string; enabled: boolean }; +export type RowColorSettings = { enabled: boolean; rules: RowColorRule[] }; + +// ADIF QSL fields are single letters. Y is the only one that means "yes"; +// R (requested) and Q (queued) mean a card is owed, which is a different state +// and the one an operator is looking for when deciding what to post. +const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y'; +const owed = (v: any) => { + const s = String(v ?? '').trim().toUpperCase(); + return s === 'R' || s === 'Q'; +}; + +export function matchRowRule(q: any): string | null { + if (!q) return null; + if (yes(q.lotw_rcvd)) return 'confirmed_lotw'; + if (yes(q.qsl_rcvd) || yes(q.eqsl_rcvd)) return 'confirmed_paper'; + if (yes(q.qsl_sent) || yes(q.lotw_sent) || yes(q.eqsl_sent)) return 'sent_waiting'; + if (owed(q.qsl_sent)) return 'to_send'; + return null; +} + +// The colour is applied as a TINT, not a fill. The grid is dark and a solid +// user-picked colour behind white text is unreadable at exactly the moment it +// matters — Logger32 gets away with it because its grid is white. +export function rowStyleFor(q: any, cfg: RowColorSettings | null): { backgroundColor: string } | undefined { + if (!cfg?.enabled) return undefined; + const id = matchRowRule(q); + if (!id) return undefined; + const rule = cfg.rules?.find((r) => r.id === id); + if (!rule?.enabled || !rule.color) return undefined; + return { backgroundColor: `color-mix(in srgb, ${rule.color} 24%, transparent)` }; +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f4ed933..4e71409 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -492,6 +492,8 @@ export function GetRotatorHeading():Promise; export function GetRotators():Promise>; +export function GetRowColors():Promise; + export function GetSPEStatus():Promise; export function GetScpStatus():Promise; @@ -928,6 +930,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise; export function SaveRotators(arg1:Array):Promise; +export function SaveRowColors(arg1:main.RowColorSettings):Promise; + export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise; export function SaveStationDevices(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 80269b6..df8d091 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -926,6 +926,10 @@ export function GetRotators() { return window['go']['main']['App']['GetRotators'](); } +export function GetRowColors() { + return window['go']['main']['App']['GetRowColors'](); +} + export function GetSPEStatus() { return window['go']['main']['App']['GetSPEStatus'](); } @@ -1798,6 +1802,10 @@ export function SaveRotators(arg1) { return window['go']['main']['App']['SaveRotators'](arg1); } +export function SaveRowColors(arg1) { + return window['go']['main']['App']['SaveRowColors'](arg1); +} + export function SaveSelfSpotSettings(arg1) { return window['go']['main']['App']['SaveSelfSpotSettings'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9e10110..30ad0fb 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2992,6 +2992,54 @@ export namespace main { this.motorized = source["motorized"]; } } + export class RowColorRule { + id: string; + color: string; + enabled: boolean; + + static createFrom(source: any = {}) { + return new RowColorRule(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.color = source["color"]; + this.enabled = source["enabled"]; + } + } + export class RowColorSettings { + enabled: boolean; + rules: RowColorRule[]; + + static createFrom(source: any = {}) { + return new RowColorSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.rules = this.convertValues(source["rules"], RowColorRule); + } + + 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 ScpStatus { enabled: boolean; count: number;