feat(appearance): four QSL categories, each scoped to chosen channels

Replaces the four fixed rules with the model Logger32 uses and the operator
asked for: Worked / Confirmed / QSL sent / To be sent, and inside each, which
channels count — paper QSL, LoTW, eQSL, QRZ.com. No ticks means every channel,
because a rule the operator has not narrowed must not quietly become a rule
about nothing.

"To be sent" is FIRST in the order, and that is the substantive decision here.
A contact can be confirmed on LoTW and still owe a paper card; the colour an
operator scans for is the one meaning "something is still owed". Placed after
"confirmed" that row goes green and the card never gets printed.

"Worked" is the catch-all — nothing sent, nothing requested, nothing back — and
is off by default, since turning it on paints every remaining row.

R and Q both count as owed: ADIF says requested and queued, and both mean the
card has not gone out.
This commit is contained in:
2026-08-13 09:46:52 +02:00
parent 6aff322be6
commit 1605e30f60
7 changed files with 184 additions and 53 deletions
+41 -8
View File
@@ -19,8 +19,16 @@ type RowColorRule struct {
ID string `json:"id"` ID string `json:"id"`
Color string `json:"color"` Color string `json:"color"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
//
// Empty means ALL of them — what an operator expects from a rule they have
// not narrowed, and what keeps a config written before this field meaningful.
Channels []string `json:"channels"`
} }
// The QSL channels a rule can be scoped to.
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
// RowColorSettings is the whole appearance block. // RowColorSettings is the whole appearance block.
type RowColorSettings struct { type RowColorSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -47,21 +55,30 @@ type RowColorSettings struct {
// The rule ids, in priority order. The frontend matches on these and holds the // 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. // labels, so a translated name never has to travel through the settings.
// The four categories, in the order they are tested — first match wins.
//
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
// still owe a paper card, and the colour an operator scans for is the one that
// means "there is something left to do here". Put after "confirmed", that row
// would go green and the card would never be printed.
//
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
// nothing back. Off by default — with it on, every remaining row is painted.
var rowColorOrder = []string{ var rowColorOrder = []string{
"confirmed_lotw", // LoTW confirmation received "to_send", // requested or queued on a watched channel, not gone out
"confirmed_paper", // card or eQSL received "confirmed", // a watched channel has a confirmation back
"sent_waiting", // sent by some route, nothing back yet "sent", // gone out on a watched channel, nothing back yet
"to_send", // a card is requested / queued and has not gone out "worked", // none of the above
} }
// Defaults: green for done, amber for waiting, blue for owed. Deliberately // 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 // 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. // value there reads as an error state rather than a status.
var rowColorDefaults = map[string]string{ var rowColorDefaults = map[string]string{
"confirmed_lotw": "#16a34a", "to_send": "#a855f7",
"confirmed_paper": "#0ea5e9", "confirmed": "#16a34a",
"sent_waiting": "#f59e0b", "sent": "#f59e0b",
"to_send": "#a855f7", "worked": "#64748b",
} }
// hexColor guards what reaches the stylesheet. The value is interpolated into a // hexColor guards what reaches the stylesheet. The value is interpolated into a
@@ -94,12 +111,28 @@ func normRowColors(s RowColorSettings) RowColorSettings {
} else if out.Intensity > 45 { } else if out.Intensity > 45 {
out.Intensity = 45 out.Intensity = 45
} }
ok := map[string]bool{}
for _, c := range rowColorChannels {
ok[c] = true
}
for _, id := range rowColorOrder { for _, id := range rowColorOrder {
r := byID[id] r := byID[id]
r.ID = id r.ID = id
if !hexColor.MatchString(strings.TrimSpace(r.Color)) { if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
r.Color = rowColorDefaults[id] r.Color = rowColorDefaults[id]
} }
// Keep only channels we know, in the canonical order, de-duplicated.
want := map[string]bool{}
for _, c := range r.Channels {
want[strings.ToLower(strings.TrimSpace(c))] = true
}
chans := []string{}
for _, c := range rowColorChannels {
if want[c] {
chans = append(chans, c)
}
}
r.Channels = chans
out.Rules = append(out.Rules, r) out.Rules = append(out.Rules, r)
} }
return out return out
+47 -10
View File
@@ -6,8 +6,8 @@ import "testing"
// that is not plainly a hex colour has to be refused rather than passed on. // that is not plainly a hex colour has to be refused rather than passed on.
func TestRowColorsRefuseAnythingButHex(t *testing.T) { func TestRowColorsRefuseAnythingButHex(t *testing.T) {
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{ in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
{ID: "confirmed_lotw", Color: "#123abc", Enabled: true}, {ID: "confirmed", Color: "#123abc", Enabled: true},
{ID: "sent_waiting", Color: "red; background:url(x)", Enabled: true}, {ID: "sent", Color: "red; background:url(x)", Enabled: true},
{ID: "to_send", Color: "", Enabled: true}, {ID: "to_send", Color: "", Enabled: true},
}} }}
got := normRowColors(in) got := normRowColors(in)
@@ -16,11 +16,11 @@ func TestRowColorsRefuseAnythingButHex(t *testing.T) {
for _, r := range got.Rules { for _, r := range got.Rules {
byID[r.ID] = r byID[r.ID] = r
} }
if byID["confirmed_lotw"].Color != "#123abc" { if byID["confirmed"].Color != "#123abc" {
t.Errorf("a valid colour was rewritten: %q", byID["confirmed_lotw"].Color) t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
} }
if byID["sent_waiting"].Color != rowColorDefaults["sent_waiting"] { if byID["sent"].Color != rowColorDefaults["sent"] {
t.Errorf("an injection attempt survived: %q", byID["sent_waiting"].Color) t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
} }
if byID["to_send"].Color != rowColorDefaults["to_send"] { if byID["to_send"].Color != rowColorDefaults["to_send"] {
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color) t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
@@ -34,7 +34,7 @@ func TestRowColorsKeepPriorityOrder(t *testing.T) {
// Saved in a jumbled order, as a hand-edited settings row could be. // Saved in a jumbled order, as a hand-edited settings row could be.
got := normRowColors(RowColorSettings{Rules: []RowColorRule{ got := normRowColors(RowColorSettings{Rules: []RowColorRule{
{ID: "to_send", Color: "#111111"}, {ID: "to_send", Color: "#111111"},
{ID: "confirmed_lotw", Color: "#222222"}, {ID: "confirmed", Color: "#222222"},
}}) }})
if len(got.Rules) != len(rowColorOrder) { if len(got.Rules) != len(rowColorOrder) {
t.Fatalf("got %d rules, want every one present", len(got.Rules)) t.Fatalf("got %d rules, want every one present", len(got.Rules))
@@ -44,9 +44,13 @@ func TestRowColorsKeepPriorityOrder(t *testing.T) {
t.Errorf("rule %d is %q, want %q", i, 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. // The saved colours survived the reordering: each stayed with its own rule.
if got.Rules[0].Color != "#222222" { byID := map[string]string{}
t.Errorf("confirmed_lotw lost its colour: %q", got.Rules[0].Color) for _, r := range got.Rules {
byID[r.ID] = r.Color
}
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
t.Errorf("colours moved between rules: %v", byID)
} }
} }
@@ -75,3 +79,36 @@ func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
} }
} }
} }
// Channels are normalised to the canonical order, and anything unknown is
// dropped. Empty stays empty, because empty means "every channel" — a rule the
// operator has not narrowed must not silently become a rule about nothing.
func TestRowColorChannelsNormalise(t *testing.T) {
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
{ID: "sent", Color: "#222222"},
}})
byID := map[string][]string{}
for _, r := range got.Rules {
byID[r.ID] = r.Channels
}
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
}
if len(byID["sent"]) != 0 {
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
}
}
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
// that still owes a paper card must read as owing the card — put after
// "confirmed" it would go green and never be printed.
func TestToSendOutranksConfirmed(t *testing.T) {
got := normRowColors(RowColorSettings{})
if got.Rules[0].ID != "to_send" {
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
}
if got.Rules[len(got.Rules)-1].ID != "worked" {
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
}
}
+4 -2
View File
@@ -8,7 +8,8 @@
"Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.", "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.",
"LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.", "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.",
"FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.", "FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.",
"SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF." "SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF.",
"Appearance: row colouring is now Worked / Confirmed / QSL sent / To be sent, each scoped to the channels you pick — paper, LoTW, eQSL, QRZ.com."
], ],
"fr": [ "fr": [
"Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.", "Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.",
@@ -16,7 +17,8 @@
"Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.", "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.",
"LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.", "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.",
"FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.", "FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.",
"Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF." "Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF.",
"Apparence : la coloration des lignes devient Contacté / Confirmé / QSL envoyée / À envoyer, chacune limitée aux canaux choisis — papier, LoTW, eQSL, QRZ.com."
] ]
}, },
{ {
+31 -5
View File
@@ -17,10 +17,17 @@ const PALETTE = [
// The rule ids the backend orders; the labels live here so a translation never // The rule ids the backend orders; the labels live here so a translation never
// travels through the settings row. // travels through the settings row.
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
confirmed_lotw: 'appr.confirmedLotw', to_send: 'appr.ruleToSend',
confirmed_paper: 'appr.confirmedPaper', confirmed: 'appr.ruleConfirmed',
sent_waiting: 'appr.sentWaiting', sent: 'appr.ruleSent',
to_send: 'appr.toSend', worked: 'appr.ruleWorked',
};
// The channels a rule can be scoped to. "worked" is the catch-all — it means
// nothing on ANY channel — so narrowing it would say nothing.
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
const CHANNEL_LABELS: Record<string, string> = {
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
}; };
export function AppearancePanel() { export function AppearancePanel() {
@@ -37,7 +44,7 @@ export function AppearancePanel() {
setCfg(next); setCfg(next);
SaveRowColors(next as any).catch(() => {}); SaveRowColors(next as any).catch(() => {});
}; };
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean }>) => { const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
if (!cfg) return; if (!cfg) return;
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) }); save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
}; };
@@ -104,6 +111,25 @@ export function AppearancePanel() {
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span> <span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span> <span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
</label> </label>
{/* Which channels this category looks at. None ticked = all of
them, which is what an unnarrowed rule should mean. */}
{r.enabled && r.id !== 'worked' && (
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
{CHANNELS.map((c) => {
const on = !r.channels?.length || r.channels.includes(c);
return (
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={on} onCheckedChange={(v) => {
const cur = r.channels?.length ? r.channels : [...CHANNELS];
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
patchRule(r.id, { channels: next });
}} />
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
</label>
);
})}
</div>
)}
{r.enabled && ( {r.enabled && (
<div className="flex items-center gap-1.5 flex-wrap pl-6"> <div className="flex items-center gap-1.5 flex-wrap pl-6">
{PALETTE.map((c) => ( {PALETTE.map((c) => (
+2 -2
View File
@@ -114,7 +114,7 @@ const en: Dict = {
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', '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.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
'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': 'Queued to send (card, LoTW or eQSL)', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', '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.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', '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.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', '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', '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.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
'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': 'En attente d envoi (carte, LoTW ou eQSL)', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', '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.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', '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.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.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', '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',
+57 -26
View File
@@ -1,10 +1,15 @@
// Row colouring for the log grid, by QSL / LoTW status. // Row colouring for the log grid, by QSL status.
// //
// The rules are ORDERED and the first match wins: a contact is usually several // Four categories, each scoped to the channels the operator cares about —
// things at once, and one confirmed on LoTW and by card is confirmed, not // paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
// "sent, awaiting reply". // wins, because a contact is usually several of them at once.
export type RowColorRule = { id: string; color: string; enabled: boolean }; export type RowColorRule = {
id: string;
color: string;
enabled: boolean;
channels?: string[]; // empty = every channel
};
export type RowColorSettings = { export type RowColorSettings = {
enabled: boolean; enabled: boolean;
style?: 'bar' | 'tint' | 'both'; style?: 'bar' | 'tint' | 'both';
@@ -13,39 +18,65 @@ export type RowColorSettings = {
rules: RowColorRule[]; rules: RowColorRule[];
}; };
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
};
// ADIF QSL fields are single letters. Y is the only one that means "yes"; // ADIF QSL fields are single letters. Y is the only one that means "yes";
// R (requested) and Q (queued) mean it has not gone out yet, which is a // R (requested) and Q (queued) mean it has not gone out yet — a different state,
// different state and the one an operator looks for when deciding what to send. // and the one an operator looks for when deciding what to send.
const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y'; const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y';
const owed = (v: any) => { const owed = (v: any) => {
const s = String(v ?? '').trim().toUpperCase(); const s = String(v ?? '').trim().toUpperCase();
return s === 'R' || s === 'Q'; return s === 'R' || s === 'Q';
}; };
export function matchRowRule(q: any): string | null { const chansOf = (r: RowColorRule): readonly string[] =>
if (!q) return null; r.channels && r.channels.length ? r.channels : CHANNELS;
if (yes(q.lotw_rcvd)) return 'confirmed_lotw';
if (yes(q.qsl_rcvd) || yes(q.eqsl_rcvd)) return 'confirmed_paper'; function ruleMatches(q: any, r: RowColorRule): boolean {
if (yes(q.qsl_sent) || yes(q.lotw_sent) || yes(q.eqsl_sent)) return 'sent_waiting'; const cs = chansOf(r);
// Any route still queued, not just the paper card. LoTW marks a pending upload switch (r.id) {
// as R, which is the commonest "not gone out yet" state in a digital log. case 'confirmed':
if (owed(q.qsl_sent) || owed(q.lotw_sent) || owed(q.eqsl_sent)) return 'to_send'; return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
case 'sent':
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
case 'to_send':
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
case 'worked':
// The catch-all: nothing sent, nothing asked for, nothing back.
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
default:
return false;
}
}
// Walks the rules IN ORDER — the order is the priority, and the settings panel
// shows it numbered so it can be read rather than guessed.
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
if (!q || !cfg?.rules) return null;
for (const r of cfg.rules) {
if (r.enabled && ruleMatches(q, r)) return r;
}
return null; return null;
} }
// The colour is never a fill. // The colour is never a fill by default.
// //
// A log where nearly every contact has SOME QSL state ends up with every row // A log where nearly every contact has SOME QSL state ends up with every row
// painted, and colour that is always present stops being information — it // painted, and colour that is always present stops being information. The
// becomes a striped background with the data behind it. The default is a stripe // default is a stripe down the left edge; a tint is offered at a chosen strength.
// down the left edge: same signal, nothing lost to read it. A tint is offered
// for operators who want the block, at a strength they choose.
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined { export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
if (!cfg?.enabled) return undefined; if (!cfg?.enabled) return undefined;
const id = matchRowRule(q); const rule = matchRowRule(q, cfg);
if (!id) return undefined; if (!rule?.color) return undefined;
const rule = cfg.rules?.find((r) => r.id === id);
if (!rule?.enabled || !rule.color) return undefined;
const style = cfg.style ?? 'bar'; const style = cfg.style ?? 'bar';
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12)); const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
@@ -54,8 +85,8 @@ export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`; out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
} }
if (style === 'bar' || style === 'both') { if (style === 'bar' || style === 'both') {
// inset shadow rather than a border: a border would shift the cell layout by // inset shadow rather than a border: a border would shift the cells three
// three pixels on coloured rows only, and the columns would no longer line up. // pixels on coloured rows only, and the columns would stop lining up.
out.boxShadow = `inset 3px 0 0 ${rule.color}`; out.boxShadow = `inset 3px 0 0 ${rule.color}`;
} }
return out; return out;
+2
View File
@@ -2996,6 +2996,7 @@ export namespace main {
id: string; id: string;
color: string; color: string;
enabled: boolean; enabled: boolean;
channels: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new RowColorRule(source); return new RowColorRule(source);
@@ -3006,6 +3007,7 @@ export namespace main {
this.id = source["id"]; this.id = source["id"];
this.color = source["color"]; this.color = source["color"];
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.channels = source["channels"];
} }
} }
export class RowColorSettings { export class RowColorSettings {