Files
OpsLog/frontend/src/lib/awardRefs.ts
T
rouggy 747c2b9105 feat(lookup): read QRZ's island reference and fill the IOTA award
QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and OpsLog
read past it. Wired end to end: the provider, the lookup cache (a column, like
web and zip before it), and the entry's award references.

It matters more here than the same field would for another award. There is no
live "who is on an island right now" feed anywhere — POTA has one and that is
what OpsLog matches spots against; SOTA has one behind conditions; IOTA
publishes only static lists. So the callbook record is the practical source, and
it is known BEFORE the contact is logged, which is when a reference is useful.

The reference goes in as an IOTA award reference, exactly as one picked by hand,
so the existing path carries it to the qso.iota column on save.

Two limits, both deliberate. A reference the operator typed or picked WINS: a
callbook entry can be years out of date, and the operator in front of the radio
has just been told where the station is. And the value must look like an IOTA
reference — two letters, a hyphen, three digits — because writing anything else
into the award makes a reference no list contains, which counts for nothing and
has to be found by hand later.
2026-08-16 23:22:37 +02:00

219 lines
11 KiB
TypeScript

// Shared helpers for per-QSO award references.
//
// In the UI a QSO's manually-assigned award references are edited as a single
// semicolon-delimited string of "CODE@REF" entries, e.g.
// "POTA@FR-11553;IOTA@EU-064"
// On save each entry is routed to the QSO field its award actually reads from
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
// and custom awards live in uppercase ADIF extras keys.
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
export function parseAwardRefs(v: string): Record<string, string> {
const out: Record<string, string> = {};
for (const entry of (v ?? '').split(';').filter(Boolean)) {
const at = entry.indexOf('@');
if (at <= 0) continue;
const code = entry.slice(0, at).toUpperCase();
const ref = entry.slice(at + 1).trim().toUpperCase();
if (!ref) continue;
out[code] = out[code] ? `${out[code]},${ref}` : ref;
}
return out;
}
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
// skipping any already present, so re-picking is idempotent.
function appendTokens(existing: string | undefined, refs: string): string {
let out = (existing ?? '').trim();
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
}
return out;
}
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
// text field — the inverse of appendTokens. Used when a picked reference is
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
// otherwise the ref is re-derived from the field and reappears on reopen.
function removeTokens(existing: string | undefined, refs: string): string {
let out = existing ?? '';
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
out = out.replace(re, ' ');
}
return out.replace(/\s{2,}/g, ' ').trim();
}
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
// reference can be assigned even for awards that scan a free-text field by
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
// the field wouldn't match.
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
// awards backed by a standard ADIF column also get that column written so the
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
const byCode = parseAwardRefs(awardRefs);
// References the operator REMOVED since the editor opened: for in-field awards
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
// ref doesn't get re-derived from the leftover token and reappear on reopen.
if (prevAwardRefs != null) {
const prev = parseAwardRefs(prevAwardRefs);
for (const [code, prevRef] of Object.entries(prev)) {
const field = fieldOf[code] || code.toLowerCase();
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
if (removed.length === 0) continue;
const rm = removed.join(',');
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
else payload.notes = removeTokens(payload.notes, rm);
}
}
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) {
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
overrides.push(`${code}@${r}`);
}
const field = fieldOf[code] || code.toLowerCase();
switch (field) {
case 'iota': payload.iota = ref; break;
case 'sota_ref': payload.sota_ref = ref; break;
case 'pota_ref': payload.pota_ref = ref; break;
// Predefined-list awards on a QSO field (WAS/RAC/WAJA on state, JCC on
// county): picking a reference writes it straight into that column.
case 'state': payload.state = ref; break;
case 'cnty': payload.cnty = ref; break;
case 'wwff':
extras['WWFF_REF'] = ref;
extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref;
break;
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
// "D06" in the note): append the code so the in-field matcher finds it too.
case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref);
break;
case 'comment':
payload.comment = appendTokens(payload.comment, ref);
break;
// address / qth / name / custom: the override above is authoritative; do
// not write a code into a free-text field the award matches by name.
default:
break;
}
}
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
else delete extras[MANUAL_REFS_KEY];
if (Object.keys(extras).length > 0) payload.extras = extras;
else if (payload.extras) delete payload.extras;
}
// awardRefValue reads a single award's stored reference from a QSO, inverse of
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
export function awardRefValue(qso: any, code: string, field: string): string {
switch (field) {
case 'iota': return (qso.iota ?? '').toUpperCase();
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
case 'state': return (qso.state ?? '').toUpperCase();
case 'cnty': return (qso.cnty ?? '').toUpperCase();
case 'wwff': {
const ex = qso.extras ?? {};
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
return '';
}
default: {
const ex = qso.extras ?? {};
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
}
}
}
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
// given pickable awards (code → field). Only awards with a stored value appear.
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
const out: string[] = [];
for (const { code, field } of pickable) {
const v = awardRefValue(qso, code, field);
// A multi-reference field (n-fer POTA "US-6544,US-0680") becomes one
// editor entry per reference, so each shows on its own removable line.
for (const ref of v.split(/[,;]/).map((s) => s.trim()).filter(Boolean)) {
out.push(`${code.toUpperCase()}@${ref}`);
}
}
return out.join(';');
}
// COMPUTED_AWARD_FIELDS mirrors isComputedAwardField in app.go: awards whose
// reference is derived from the callsign and cty.dat, never assigned by hand.
//
// State and county are deliberately absent, there as here — an operator sets
// those themselves and they drive real predefined-list awards (WAS, WAJA, JCC).
const COMPUTED_AWARD_FIELDS = new Set([
'dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4',
]);
// spotRefList turns a QSO's code→ref map into the strings a DX-cluster comment
// wants: ["SOTA HA/KM-018"].
//
// COMPUTED awards are dropped. A QSO's materialised references hold both kinds,
// and the derived ones — DXCC, WAC, WAZ, WPX — are exactly what every reader of
// the spot works out from the callsign anyway. Announcing "WAC EU WAZ 15 WPX HA5"
// spends the whole 30-character comment saying nothing, and buries the one
// reference worth chasing. Same rule as the QSO editor, which is why these are
// the references it lists on the LEFT and not in its read-only panel.
//
// fieldOf is award code → scanned field (from GetAwardDefs). A code missing from
// it is dropped rather than guessed: showing a reference on the air matters more
// than showing all of them.
//
// A code carrying several references (a two-fer park) yields one entry each, so
// a spot can take the one that fits instead of emitting a truncated pair.
// Shortest first: when they cannot all fit, more references beat fewer.
export function spotRefList(byCode: Record<string, string>, fieldOf: Record<string, string>): string[] {
const out: string[] = [];
for (const [code, refs] of Object.entries(byCode ?? {})) {
const field = fieldOf?.[code.toUpperCase()];
if (!field || COMPUTED_AWARD_FIELDS.has(field)) continue;
for (const ref of String(refs).split(',').map((s) => s.trim()).filter(Boolean)) {
out.push(`${code.toUpperCase()} ${ref.toUpperCase()}`);
}
}
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
}
// withIOTARef adds an island reference from the callbook to an entry's award
// references, unless the operator has already set one.
//
// QRZ sends <iota> for an operator on an island and OpsLog used to read past it.
// It matters more here than it would for another award: unlike POTA there is no
// live "who is on an island right now" feed anywhere, so the callbook record is
// the practical source — and it is known before the contact is logged, which is
// when it is useful.
//
// A reference the operator typed or picked wins. A callbook entry can be years
// old, and the operator in front of the radio has just been told where the
// station is.
export function withIOTARef(awardRefs: string, iota: string): string {
const ref = (iota || '').trim().toUpperCase();
// EU-048: two letters, a hyphen, three digits. Anything else is not an IOTA
// reference, and writing it into the award would make a reference that no
// list contains — which counts for nothing and has to be found by hand later.
if (!/^[A-Z]{2}-\d{3}$/.test(ref)) return awardRefs;
const byCode = parseAwardRefs(awardRefs);
if ((byCode['IOTA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
const sep = awardRefs.trim() === '' ? '' : ';';
return awardRefs + sep + 'IOTA@' + ref;
}