feat(parser): update group parsing to handle special cases and improve regex patterns

feat(parser): enhance details extraction for lessons, including handling of subgroups and room moves

feat(timetable): integrate room and teacher retrieval based on week context

test(parser): add tests for group parsing with special characters and room move scenarios

test(formats): implement tests for various lesson formats and their expected outputs

feat(rooms): introduce room management functions for handling moves and week-based plans
This commit is contained in:
2026-10-21 00:34:06 +03:00
parent 3ae7b38fca
commit 67bb2ea6fb
9 changed files with 468 additions and 76 deletions

114
server/rooms.js Normal file
View File

@@ -0,0 +1,114 @@
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н.".
const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\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;
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);
if (color) {
plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers });
plan.push({ weeks, color: "green", rooms: roomsIn(color[2]), 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(/(?:(?:с|со)\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);
}