refactor: Rewrite to typescript
All checks were successful
Remote Deploy / deploy (push) Successful in 14s
All checks were successful
Remote Deploy / deploy (push) Successful in 14s
This commit is contained in:
247
scrape/utils/parseAbsence.ts
Normal file
247
scrape/utils/parseAbsence.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Jakub Žitník
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
const LAST_HOUR = 10;
|
||||
|
||||
// -------------------------------
|
||||
// Helpers
|
||||
// -------------------------------
|
||||
export const cleanInput = (input: string | null | undefined): string => (input ?? "").trim().replace(/\s+/g, " ");
|
||||
export const isTeacherToken = (t: string): boolean => /^[A-Za-z]+$/.test(t);
|
||||
|
||||
interface Spec {
|
||||
kind: "range" | "single";
|
||||
value: { from: number; to: number } | number;
|
||||
}
|
||||
|
||||
interface TeacherMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface AbsenceResult {
|
||||
teacher: string | null;
|
||||
teacherCode: string | null;
|
||||
type: string;
|
||||
hours: { from: number; to: number } | number | null;
|
||||
zastupuje?: { teacher: string | null; teacherCode: string | null };
|
||||
original?: string;
|
||||
}
|
||||
|
||||
export const parseSpec = (spec: string | null): Spec | null => {
|
||||
if (!spec) return null;
|
||||
let m;
|
||||
|
||||
// Handle "6,7"
|
||||
if ((m = spec.match(/^(\d+),(\d+)$/))) {
|
||||
const from = Number(m[1]);
|
||||
const to = Number(m[2]);
|
||||
return from >= 1 && to >= from && to <= LAST_HOUR
|
||||
? { kind: "range", value: { from, to } }
|
||||
: null;
|
||||
}
|
||||
|
||||
if ((m = spec.match(/^(\d+)\+$/))) {
|
||||
const from = Number(m[1]);
|
||||
return from >= 1 && from <= LAST_HOUR
|
||||
? { kind: "range", value: { from, to: LAST_HOUR } }
|
||||
: null;
|
||||
}
|
||||
if ((m = spec.match(/^(\d+)-(\d+)$/))) {
|
||||
const from = Number(m[1]);
|
||||
const to = Number(m[2]);
|
||||
return from >= 1 && to >= from && to <= LAST_HOUR
|
||||
? { kind: "range", value: { from, to } }
|
||||
: null;
|
||||
}
|
||||
if ((m = spec.match(/^(\d+)$/))) {
|
||||
const hour = Number(m[1]);
|
||||
return hour >= 1 && hour <= LAST_HOUR
|
||||
? { kind: "single", value: hour }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveTeacher = (teacherCode: string, teacherMap: TeacherMap = {}): { code: string; name: string | null } => ({
|
||||
code: teacherCode,
|
||||
name: teacherMap?.[teacherCode.toLowerCase()] ?? null,
|
||||
});
|
||||
|
||||
const makeResult = (teacherCode: string, spec: Spec | null, teacherMap: TeacherMap): AbsenceResult => {
|
||||
const { name } = resolveTeacher(teacherCode, teacherMap);
|
||||
const type = spec ? (spec.kind === "range" ? "range" : "single") : "wholeDay";
|
||||
const hours = spec ? spec.value : null;
|
||||
return { teacher: name, teacherCode: teacherCode.toLowerCase(), type, hours };
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Teacher list processing (modular)
|
||||
// -------------------------------
|
||||
const processTeacherList = (teacherListStr: string, spec: Spec | null, teacherMap: TeacherMap): AbsenceResult[] => {
|
||||
let results: AbsenceResult[] = [];
|
||||
const teachers = teacherListStr.split(/[,;]\s*/).filter(Boolean);
|
||||
|
||||
if (teacherListStr.includes(";")) {
|
||||
teachers.forEach((t, i) => {
|
||||
const resSpec = i === teachers.length - 1 ? spec : null;
|
||||
results.push(makeResult(t, resSpec, teacherMap));
|
||||
});
|
||||
} else {
|
||||
results = teachers.map((t) => makeResult(t, spec, teacherMap));
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Main parser
|
||||
// -------------------------------
|
||||
export default function parseAbsence(input: string, teacherMap: TeacherMap = {}): AbsenceResult[] {
|
||||
const s = cleanInput(input);
|
||||
if (!s) return [];
|
||||
|
||||
const results: AbsenceResult[] = [];
|
||||
const consumed: [number, number][] = [];
|
||||
const markConsumed = (start: number, end: number) => consumed.push([start, end]);
|
||||
const isConsumed = (i: number) => consumed.some(([a, b]) => i >= a && i < b);
|
||||
|
||||
// 1. Teachers with specific hours (e.g. "Ab 1-4")
|
||||
const teacherListThenSpecRe =
|
||||
/([A-Za-z]+(?:[,;]\s?[A-Za-z]+)*)(?:\s*)(\d+(?:\+|-\d+|,\d+)?)(?:\.\s*h)?(?![A-Za-z])/g;
|
||||
|
||||
let m;
|
||||
while ((m = teacherListThenSpecRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = teacherListThenSpecRe.lastIndex;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
|
||||
const [_, teacherListStr, specStr] = m;
|
||||
const spec = parseSpec(specStr);
|
||||
if (!spec) continue;
|
||||
|
||||
results.push(...processTeacherList(teacherListStr, spec, teacherMap));
|
||||
markConsumed(matchStart, matchEnd);
|
||||
}
|
||||
|
||||
// 1a. Teachers with "-startHour-exk" suffix (e.g. "Sv-5-exk")
|
||||
const teacherStartExkRe = /([A-Za-z]+)-(\d+)-exk/gi;
|
||||
while ((m = teacherStartExkRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = teacherStartExkRe.lastIndex;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
|
||||
const teacherCode = m[1];
|
||||
const from = Number(m[2]);
|
||||
const { name } = resolveTeacher(teacherCode, teacherMap);
|
||||
|
||||
if (from >= 1 && from <= LAST_HOUR) {
|
||||
results.push({
|
||||
teacher: name,
|
||||
teacherCode: teacherCode.toLowerCase(),
|
||||
type: "exkurze",
|
||||
hours: { from, to: LAST_HOUR },
|
||||
});
|
||||
markConsumed(matchStart, matchEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Teachers with "-exk" suffix
|
||||
const teacherExkRe = /([A-Za-z]+)-exk/gi;
|
||||
while ((m = teacherExkRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = teacherExkRe.lastIndex;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
|
||||
const teacherCode = m[1];
|
||||
const { name } = resolveTeacher(teacherCode, teacherMap);
|
||||
results.push({
|
||||
teacher: name,
|
||||
teacherCode: teacherCode.toLowerCase(),
|
||||
type: "exkurze",
|
||||
hours: null,
|
||||
});
|
||||
markConsumed(matchStart, matchEnd);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. Substitution Pattern: "za Vn zastupuje Jk"
|
||||
// ---------------------------------------------------------
|
||||
const substitutionRe = /za\s+([A-Za-z]+)\s+zastupuje\s+([A-Za-z]+)/gi;
|
||||
while ((m = substitutionRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = substitutionRe.lastIndex;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
|
||||
const missingCode = m[1];
|
||||
const substituteCode = m[2];
|
||||
|
||||
const missingResolved = resolveTeacher(missingCode, teacherMap);
|
||||
const subResolved = resolveTeacher(substituteCode, teacherMap);
|
||||
|
||||
results.push({
|
||||
teacher: missingResolved.name,
|
||||
teacherCode: missingResolved.code.toLowerCase(),
|
||||
type: "zastoupen",
|
||||
hours: null,
|
||||
zastupuje: {
|
||||
teacher: subResolved.name,
|
||||
teacherCode: subResolved.code.toLowerCase()
|
||||
}
|
||||
});
|
||||
|
||||
markConsumed(matchStart, matchEnd);
|
||||
}
|
||||
|
||||
const teacherOnlyRe = /([A-Za-z]+(?:[,;]\s?[A-Za-z]+)*)/g;
|
||||
while ((m = teacherOnlyRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = teacherOnlyRe.lastIndex;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
|
||||
const tList = m[1].split(/[,;]\s*/).filter(Boolean);
|
||||
tList.forEach((t) => {
|
||||
const lowerT = t.toLowerCase();
|
||||
if (lowerT === 'za' || lowerT === 'zastupuje') return;
|
||||
|
||||
if (isTeacherToken(t)) results.push(makeResult(t, null, teacherMap));
|
||||
else
|
||||
results.push({
|
||||
type: "invalid",
|
||||
teacher: null,
|
||||
teacherCode: null,
|
||||
hours: null,
|
||||
original: t,
|
||||
});
|
||||
});
|
||||
markConsumed(matchStart, matchEnd);
|
||||
}
|
||||
|
||||
// 5. Bare specs without teacher → invalid
|
||||
const specOnlyRe = /\b(\d+(?:\+|-\d+|,\d+)?)\b/g;
|
||||
while ((m = specOnlyRe.exec(s)) !== null) {
|
||||
const matchStart = m.index;
|
||||
if (isConsumed(matchStart)) continue;
|
||||
if (m[1].trim() == "exk") continue;
|
||||
|
||||
results.push({
|
||||
type: "invalid",
|
||||
teacher: null,
|
||||
teacherCode: null,
|
||||
hours: null,
|
||||
original: m[1],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user