A real record from their export settles it: <CALL:4>RL6M … <CNTY:5>RO-19 <APP_HAMLOG_R150COUNTRY:6>Russia <APP_HAMLOG_QSO_CFM:1>Y The confirmation lives in APP_HAMLOG_QSO_CFM. The four names guessed before a file was available — APP_HAMLOG_QSL and friends — were all wrong, which is the argument for reading one rather than reasoning about it. So that becomes the canonical key everywhere: the award source, the row colours, the grid column and the bulk editor. Importing a log downloaded from HAMLOG now carries its confirmations into OpsLog with nothing to rename. The older names, including the APP_OPSLOG_HAMLOG_QSL that OpsLog itself wrote in the meantime, are still honoured on read so nothing already stamped stops counting.
148 lines
6.6 KiB
TypeScript
148 lines
6.6 KiB
TypeScript
import { isQSLConfirmed } from '@/lib/qsl';
|
|
// Row colouring for the log grid, by QSL status.
|
|
//
|
|
// Four categories, each scoped to the channels the operator cares about —
|
|
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
|
// wins, because a contact is usually several of them at once.
|
|
|
|
export type RowColorRule = {
|
|
id: string;
|
|
color: string;
|
|
enabled: boolean;
|
|
channels?: string[]; // empty = every channel
|
|
};
|
|
export type RowColorSettings = {
|
|
enabled: boolean;
|
|
style?: 'bar' | 'tint' | 'both';
|
|
intensity?: number;
|
|
bandmap_lotw?: boolean;
|
|
recent_zebra?: string; // '' = alternate (default), 'off' = every row alike
|
|
recent_zebra_color?: string; // '' = follow the theme
|
|
rules: RowColorRule[];
|
|
};
|
|
|
|
// The banding the grid theme applies on its own. Repeated here because turning
|
|
// the option OFF means painting every row explicitly — the theme would
|
|
// otherwise keep striping underneath whatever we do per row.
|
|
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
|
|
|
|
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] 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' },
|
|
};
|
|
|
|
// HAMLOG.online has no column of its own: the ADIF standard names a field for
|
|
// hamlog.EU and none for hamlog.ONLINE, so its state lives in the extras — see
|
|
// internal/award/award.go, which reads the same keys for award confirmations.
|
|
// One vocabulary, two readers.
|
|
const HAMLOG_SENT = 'APP_OPSLOG_HAMLOG_SENT';
|
|
const HAMLOG_RCVD = ['APP_HAMLOG_QSO_CFM', 'APP_OPSLOG_HAMLOG_QSL', 'APP_HAMLOG_QSL', 'APP_HAMLOGONLINE_QSL', 'HAMLOG_QSL_RCVD'];
|
|
|
|
// hamlogState reads a row's extras. Any value that is not an explicit "no"
|
|
// counts, because their export could carry a date or a match id rather than Y.
|
|
function hamlogState(q: any, which: 'sent' | 'rcvd'): boolean {
|
|
const e = (q?.extras ?? {}) as Record<string, string>;
|
|
const keys = which === 'sent' ? [HAMLOG_SENT] : HAMLOG_RCVD;
|
|
return keys.some((k) => {
|
|
const v = String(e[k] ?? '').trim();
|
|
return v !== '' && v.toUpperCase() !== 'N' && v.toUpperCase() !== 'NO';
|
|
});
|
|
}
|
|
|
|
// 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 — a different state,
|
|
// and the one an operator looks for when deciding what to send.
|
|
// Y or V — see lib/qsl. A LoTW-verified contact is confirmed, and colouring it
|
|
// as unconfirmed is the same bug the band/mode matrix had.
|
|
const yes = (v: any) => isQSLConfirmed(v);
|
|
const owed = (v: any) => {
|
|
const s = String(v ?? '').trim().toUpperCase();
|
|
return s === 'R' || s === 'Q';
|
|
};
|
|
|
|
const chansOf = (r: RowColorRule): readonly string[] =>
|
|
r.channels && r.channels.length ? r.channels : CHANNELS;
|
|
|
|
function ruleMatches(q: any, r: RowColorRule): boolean {
|
|
const cs = chansOf(r);
|
|
switch (r.id) {
|
|
case 'confirmed':
|
|
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'rcvd') : yes(q[FIELDS[c]?.rcvd])));
|
|
case 'sent':
|
|
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'sent') : yes(q[FIELDS[c]?.sent])));
|
|
case 'to_send':
|
|
// Nothing to request from HAMLOG: a QSO is either uploaded or it is not,
|
|
// there is no "card asked for" state to be owed.
|
|
return cs.some((c) => c !== 'hamlog' && owed(q[FIELDS[c]?.sent]));
|
|
case 'worked':
|
|
// The catch-all: nothing sent, nothing asked for, nothing back.
|
|
return !CHANNELS.some((c) => (c === 'hamlog'
|
|
? hamlogState(q, 'rcvd') || hamlogState(q, 'sent')
|
|
: 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;
|
|
}
|
|
|
|
// The colour is never a fill by default.
|
|
//
|
|
// 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. The
|
|
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
|
// rowIndex is optional: only the Recent QSOs grid bands its rows, and the other
|
|
// callers have nothing to say about odd and even.
|
|
export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record<string, string> | undefined {
|
|
const out: Record<string, string> = {};
|
|
// Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL
|
|
// status, and an operator who wants plain rows should not have to turn the
|
|
// status colours off to get them.
|
|
if (typeof rowIndex === 'number') {
|
|
if (cfg?.recent_zebra === 'off') {
|
|
out.backgroundColor = 'var(--card)';
|
|
} else if (rowIndex % 2 === 1) {
|
|
out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT;
|
|
}
|
|
}
|
|
const empty = () => (Object.keys(out).length ? out : undefined);
|
|
if (!cfg?.enabled) return empty();
|
|
const rule = matchRowRule(q, cfg);
|
|
if (!rule?.color) return empty();
|
|
|
|
const style = cfg.style ?? 'bar';
|
|
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
|
if (style === 'tint' || style === 'both') {
|
|
// Overrides the banding on purpose: the QSL colour is information, the
|
|
// banding is only there to help the eye keep its line.
|
|
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
|
}
|
|
if (style === 'bar' || style === 'both') {
|
|
// A background gradient, not a border and not a shadow.
|
|
//
|
|
// A border would shift the cells three pixels on coloured rows only, and
|
|
// the columns would stop lining up. An inset box-shadow was the first fix
|
|
// for that and drew the stripe correctly — but on the grid's transformed,
|
|
// absolutely-positioned rows it also bled a hairline of the same colour
|
|
// along the row edges, so every coloured row got a horizontal rule it was
|
|
// never meant to have. A gradient paints inside the box and nothing else:
|
|
// three pixels of colour, then transparent, and no edge to bleed from.
|
|
out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`;
|
|
}
|
|
return out;
|
|
}
|