157 lines
7.4 KiB
TypeScript
157 lines
7.4 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(';');
|
|
}
|