import { EdiHeaderForm, PSectResult } from "@/types/edi"; import i18n from "@/i18n"; const translate = (key: string, options?: Record, fallback?: string) => { const value = i18n.t(key, options); if (fallback && (value === key || value === "")) return fallback; return value as string; }; export const PWWLO_REGEX = /^[A-R]{2}[0-9]{2}[A-X]{2}$/; export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const CALLSIGN_REGEX = /^(?:[A-Z0-9]{1,3}\/[A-Z0-9]{1,3}[0-9][A-Z0-9]{1,4}|[A-Z0-9]{1,3}[0-9][A-Z0-9]{1,4}(?:\/[A-Z0-9]{1,4})?)$/; export const normalizeContent = (content: string) => content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); export const hasEdiHeader = (content: string) => { const lines = normalizeContent(content) .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); return lines.length > 0 && lines[0].toUpperCase().startsWith("[REG1TEST"); }; export const extractHeaderMap = (content: string) => { const lines = normalizeContent(content).split("\n"); const headerMap: Record = {}; lines.forEach((line) => { const trimmed = line.trim(); const m = trimmed.match(/^([A-Za-z0-9]+)=(.*)$/); if (m) { headerMap[m[1]] = m[2].trim(); } }); return headerMap; }; export const normalizeCallsign = (raw: string) => { let value = raw.trim().toUpperCase().replace(/\s+/g, ""); if (!value) return ""; const firstSlash = value.indexOf("/"); if (firstSlash !== -1) { const before = value.slice(0, firstSlash + 1); const after = value.slice(firstSlash + 1).replace(/\//g, ""); value = before + after; } else { value = value.replace(/\//g, ""); } return value; }; export const validateCallsign = (raw: string) => { const normalized = normalizeCallsign(raw); const isValid = normalized !== "" && CALLSIGN_REGEX.test(normalized); return { normalized, isValid }; }; export const validateCallsignList = (raw: string) => { const tokens = raw.split(/[\s;,]+/).map((t) => t.trim()).filter((t) => t.length > 0); const results = tokens.map((t) => validateCallsign(t)); const invalidTokens = tokens.filter((_, idx) => !results[idx].isValid); const normalizedTokens = results.map((r) => r.normalized).filter((n) => n.length > 0); const hasValid = results.some((r) => r.isValid); return { tokens, invalidTokens, normalizedTokens, hasValid }; }; export type SpoweParse = { normalized: string; changed: boolean; valid: boolean }; export const normalizeSpowe = (raw: string): SpoweParse => { const trimmed = raw.trim(); if (!trimmed) return { normalized: "", changed: false, valid: false }; // remove unit suffixes and prepare numeric string const lower = trimmed.toLowerCase(); const compact = lower.replace(/\s+/g, ""); let cleaned = lower; let factor = 1; if (compact.endsWith("kw")) { cleaned = compact.slice(0, -2); factor = 1000; } else if (compact.endsWith("mw")) { cleaned = compact.slice(0, -2); factor = 0.001; } else if (compact.endsWith("w")) { cleaned = compact.slice(0, -1); } cleaned = cleaned.trim(); // allow comma or dot as decimal separator cleaned = cleaned.replace(/,/g, "."); // if nothing but digits/dot remains, parse float if (!/^[0-9]*\.?[0-9]+$/.test(cleaned)) { return { normalized: cleaned || trimmed, changed: cleaned !== trimmed, valid: false }; } const numeric = parseFloat(cleaned) * factor; if (Number.isNaN(numeric)) { return { normalized: cleaned || trimmed, changed: cleaned !== trimmed, valid: false }; } const normalized = factor === 1000 ? String(Math.round(numeric)) : String(numeric); const changed = normalized !== trimmed; return { normalized, changed, valid: true }; }; export const isSingleOp = (psect: string) => { const lower = psect.toLowerCase(); const patterns = ["single", "single-op", "single operator", "so", "so-lp", "sop", "sohp"]; return patterns.some((p) => lower.includes(p)); }; export const isMultiOp = (psect: string) => { const lower = psect.toLowerCase(); const patterns = ["multi", "multi-op", "multi operator", "mo", "mo-lp", "mohp"]; return patterns.some((p) => lower.includes(p)); }; export const hasPowerKeyword = (psect: string) => { const lower = psect.toLowerCase(); return /\b(qrp|lp)\b/.test(lower) || lower.includes("low power"); }; export const computeQsoCountWarning = (content: string, name: string): string | null => { const lines = normalizeContent(content).split("\n"); const nonEmpty = lines.map((l) => l.trim()).filter((l) => l.length > 0); let qsoStart = -1; nonEmpty.forEach((line, idx) => { if (line.startsWith("[QSORecords")) qsoStart = idx; }); if (qsoStart === -1) return null; const qsoHeader = nonEmpty[qsoStart]; const m = qsoHeader.match(/^\[QSORecords;(\d+)\]/); const expectedCount = m ? parseInt(m[1], 10) : null; const qsoLines: string[] = []; for (let i = qsoStart + 1; i < nonEmpty.length; i++) { const line = nonEmpty[i]; if (line.startsWith("[END")) break; if (line.startsWith("[") && !line.startsWith("[QSORecords")) break; qsoLines.push(line); } if (expectedCount !== null && qsoLines.length !== expectedCount) { return translate( "edi_warning_qso_count_mismatch", { name, expected: expectedCount, actual: qsoLines.length }, `${name}: Deklarovaný počet QSO (${expectedCount}) nesedí se skutečným (${qsoLines.length}).` ); } return null; }; export const updateContentWithHeaders = (content: string, updates: Partial): string => { const normalized = normalizeContent(content); const lines = normalized.split("\n"); const updatedLines = [...lines]; const regIndex = lines.findIndex((line) => line.trim().toUpperCase().startsWith("[REG1TEST")); const insertionIndex = regIndex >= 0 ? regIndex + 1 : lines.length; const handledKeys = new Set(); updatedLines.forEach((line, idx) => { const m = line.trim().match(/^([A-Za-z0-9]+)=(.*)$/); if (!m) return; const key = m[1]; if (key in updates) { updatedLines[idx] = `${key}=${updates[key as keyof EdiHeaderForm] ?? ""}`; handledKeys.add(key); } }); Object.entries(updates).forEach(([key, value]) => { if (handledKeys.has(key) || value === undefined) return; updatedLines.splice(insertionIndex, 0, `${key}=${value}`); }); return updatedLines.join("\n"); }; export const parsePSect = (raw: string, opts: { strict?: boolean; allow6h?: boolean } = {}): PSectResult => { const strict = opts.strict ?? false; const allow6h = opts.allow6h ?? false; const normalizedBase = raw .trim() .toUpperCase() .replace(/\b6\s*HOURS?\b/g, "6H") .replace(/\s+/g, " ") .replace(/[\/,;._]+/g, " "); const tokensRaw = normalizedBase .split(" ") .flatMap((t) => t.split("-")) .map((t) => t.trim()) .filter((t) => t.length > 0) .map((t) => t .replace(/^SINGLE$/, "SO") .replace(/^MULTI$/, "MO") .replace(/^LOWPOWER$/, "LP") .replace(/^HIGHPOWER$/, "HP") .replace(/^UNLIMITED$/, "A") .replace(/^CHECKLOG$/, "CHECK") ); let operatorClass: "SO" | "MO" | null = null; let powerClass: "QRP" | "N" | "LP" | "A" | null = null; let timeClass: "6H" | null = null; let isCheckLog = false; const unknownTokens: string[] = []; const errors: string[] = []; const warnings: string[] = []; const powerTokens = new Set(["QRP", "N", "LP", "A", "HP"]); const timeTokens = new Set(["6H"]); const operatorTokens = new Set(["SO", "MO", "SOLO", "MULTI"]); const optionalTokens = new Set(["CHECK", "SWL", "MGM", "CW", "SSB", "FM", "ALL", "FULL", "UNLIMITEDTIME", "12H", "24H", "OP"]); tokensRaw.forEach((token) => { if (operatorTokens.has(token)) { if (token === "SOLO") { operatorClass = operatorClass ?? "SO"; } else if (token === "MULTI") { operatorClass = operatorClass ?? "MO"; } else { operatorClass = operatorClass ?? (token as "SO" | "MO"); } return; } if (powerTokens.has(token)) { const mapped = token === "HP" ? "A" : token; if (powerClass && powerClass !== mapped) { errors.push( translate( "edi_error_psect_multiple_power", undefined, "PSect obsahuje více výkonových kategorií." ) ); } else { powerClass = mapped as typeof powerClass; } return; } if (timeTokens.has(token)) { if (!allow6h) { warnings.push( translate( "edi_warning_psect_time_not_allowed", undefined, "PSect obsahuje časovou kategorii 6H, která není pro toto kolo povolena." ) ); return; } if (timeClass && timeClass !== token) { errors.push( translate( "edi_error_psect_multiple_time", undefined, "PSect obsahuje více časových kategorií." ) ); } else { timeClass = token as "6H"; } return; } if (token === "CHECK") { isCheckLog = true; return; } if (optionalTokens.has(token)) { return; } unknownTokens.push(token); }); if (!operatorClass && !isCheckLog) { errors.push( translate( "edi_error_psect_missing_operator", undefined, "PSect musí obsahovat SO nebo MO." ) ); } if (isCheckLog) { const hasAnythingElse = operatorClass !== null || powerClass !== null || timeClass !== null || unknownTokens.length > 0; if (hasAnythingElse) { errors.push( translate( "edi_error_psect_check_extra", undefined, "PSect CHECK nesmí obsahovat žádné další tokeny." ) ); } } if (strict && unknownTokens.length > 0) { errors.push( translate( "edi_error_psect_unknown_tokens", { tokens: unknownTokens.join(", ") }, `PSect obsahuje neznámé tokeny: ${unknownTokens.join(", ")}.` ) ); } else if (unknownTokens.length > 0) { warnings.push( translate( "edi_warning_psect_unknown_tokens", { tokens: unknownTokens.join(", ") }, `Neznámé tokeny v PSect: ${unknownTokens.join(", ")}.` ) ); } const normalizedParts = [ operatorClass ?? "", powerClass ?? "", timeClass ?? "", isCheckLog ? "CHECK" : "", ...unknownTokens, ].filter((t) => t); return { operatorClass, powerClass, timeClass, isCheckLog, unknownTokens, normalized: normalizedParts.join(" "), errors, warnings, }; }; export const buildIaruPsect = (res: PSectResult): string | null => { if (res.isCheckLog) return "CHECK"; if (!res.operatorClass) return null; const parts: string[] = [res.operatorClass]; if (res.powerClass && res.powerClass !== "A") { parts[0] = `${parts[0]}-${res.powerClass}`; } let base = parts.join(" "); if (res.timeClass === "6H") { base = `${base} 6H`; } return base.trim(); };