import { parseWeekRanges } from "../shared/weeks.js"; // Rooms and teachers as Word cells write them, shared by the parser, the directory and the // diagnostics: room tokens, dated moves ("26.10.2026-333") and plans by week ("1-9н. 502н, // с 10н. – 359", "бел.н.504н/зел.н.524н", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э."). export const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu; export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim(); // "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits. export const ROOM_TOKEN = /[А-ЯA-Z]\d-\d{2}|\d{2,4}\/\d{2,4}|\d{2,4}[А-ЯA-Z]{0,2}/gu; const ROOM_TEXT = "[А-ЯA-Z]\\d-\\d{2}|\\d{2,4}(?:\\s?(?!и(?![а-яё]))[А-Яа-я]{1,2}(?![а-яё]))?"; // "(13.10-ауд.510н)", "14.10. -ауд. 333", "07.09. ауд. 414", "07.10-203мк", "26.10.2026-333": // on that date the lesson moves. export const ROOM_MOVE = new RegExp( `(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{4}|\\d{2})(?!\\d))?\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`, "giu", ); // Word files mix Latin look-alikes into room numbers ("12C" with a Latin C). const LATIN = { A: "А", B: "В", C: "С", E: "Е", H: "Н", K: "К", M: "М", O: "О", P: "Р", T: "Т", X: "Х" }; export const roomKey = (s) => (s || "") .replace(/\s+/g, "") .toUpperCase() .replace(/[ABCEHKMOPTX]/g, (c) => LATIN[c]) .replace(/\.$/, ""); export const tokens = (text) => (text || "") .toUpperCase() // "210 мк" → "210МК"; "129 и 130" stays two rooms. .replace(/(\d{2,4})\s+(?!И(?![А-ЯЁ]))([А-ЯЁ]{1,2})(?![А-ЯЁ])/gu, "$1$2") .match(ROOM_TOKEN) || []; const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12; const isoFor = (day, month, year, explicit) => { const m = Number(month), y = explicit ? Number(explicit.length === 2 ? `20${explicit}` : explicit) : m >= 8 ? Number(year) : Number(year) + 1; return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`; }; /** Dated moves in a text and the text without them: `{moves: [{date, room}], rest}`. */ export function roomMoves(text, year) { const moves = []; const rest = (text || "").replace(ROOM_MOVE, (match, d, m, y, room) => { if (!validDate(d, m)) return match; if (year || y) moves.push({ date: isoFor(d, m, year, y), room: roomKey(room) }); return " "; }).replace(/\(\s*\)/g, " "); // "402н (13.10-ауд.510н)" leaves "402н ( )": the brackets go with the move. return { moves, rest }; } // A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.". Not a room // with its week's colour: "32н-з.н." is room 32Н in green weeks. const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?(?![а-яё])(?!\.?\s*[-–]?\s*[бз](?:ел)?\.?\s*н)\.?/giu; /** A line cut where each week mark begins: "1-13н-332, 14-17н.-502н" → two parts. */ export function partsByWeek(text) { const starts = [...text.matchAll(WEEK_MARK)].map((m) => m.index); if (!starts.length || starts[0] > 0) starts.unshift(0); return starts.map((start, i) => text.slice(start, starts[i + 1]).trim()).filter(Boolean); } const PART_MARK = /^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/iu; // "бел.н.504н/зел.н.524н", "б.н.301Г/з.н.317Д": a room for white weeks and one for green ones. const BY_COLOR = /б(?:ел)?\.?\s*н(?:ед)?\.?\s*([^/\s]+)\s*\/\s*з(?:ел)?\.?\s*н(?:ед)?\.?\s*([^\s,;]+)/iu; // "ауд.533н-б.н.,420н-з.н.": the colour after each room. const ROOM_COLOR = new RegExp(`(${ROOM_TEXT})\\s*[-–]?\\s*(б|з)(?:ел)?\\.?\\s*н(?:ед)?\\.?(?![а-яё])`, "giu"); const roomsIn = (text) => tokens(text.replace(PERSON, " ").replace(/\([^)]*\)/g, " ")) .map(roomKey) .filter((r) => !/ДО$/.test(r)); /** * Rooms and teachers that depend on the week, from a line of a cell: entries * `{weeks: [[a, b]] | null, color: "white" | "green" | null, rooms, teachers}`. Empty when the * line names none of them by week. */ export function weekPlan(text) { const plan = []; for (const part of partsByWeek(text || "")) { const mark = part.match(PART_MARK)?.[0] || ""; const weeks = mark ? parseWeekRanges(mark) : null; if (mark && !weeks) continue; const rest = part.slice(mark.length); const teachers = [...rest.matchAll(PERSON)].map((m) => personName(m[0])); const color = rest.match(BY_COLOR); const byRoom = [...rest.matchAll(ROOM_COLOR)]; if (color) { plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers }); plan.push({ weeks, color: "green", rooms: roomsIn(color[2]), teachers }); } else if (byRoom.length) { for (const [name, letter] of [["white", "б"], ["green", "з"]]) { const rooms = byRoom.filter((m) => m[2].toLowerCase() === letter).map((m) => roomKey(m[1])); if (rooms.length) plan.push({ weeks, color: name, rooms, teachers }); } } else if (mark) { const rooms = roomsIn(rest); if (rooms.length || teachers.length) plan.push({ weeks, color: null, rooms, teachers }); } } // "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д": teachers by week, then the room of them all. const last = plan.at(-1); if (plan.length > 1 && last.rooms.length && plan.slice(0, -1).every((p) => !p.rooms.length && p.teachers.length && !p.color)) for (const p of plan.slice(0, -1)) p.rooms = [...last.rooms]; return plan; } /** * Whether a line names only rooms, teachers and weeks, with no subject: "1-9н. 502н", * "с 10н. – 533н", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д". It continues the lesson above. */ export function onlyPlace(text) { const left = (text || "") .replace(PERSON, " ") .replace(BY_COLOR, " ") .replace(ROOM_COLOR, " ") .replace(/(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/giu, " ") .replace(/ауд\.?|а\./giu, " ") .replace(/[А-ЯA-Z]\d-\d{2}|\d{2,4}\s?[А-Яа-я]{0,2}(?![а-яё])/gu, " "); // A word, or an abbreviation of a subject ("РБ"): not only a place. return !/[А-Яа-яЁё]{3,}|[А-ЯЁ]{2,}/u.test(left); }