Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,355 @@
import { EdiHeaderForm, PSectResult } from "@/types/edi";
import i18n from "@/i18n";
const translate = (key: string, options?: Record<string, unknown>, 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<string, string> = {};
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<EdiHeaderForm>): 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<string>();
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();
};