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

View File

@@ -4,6 +4,7 @@
import { catalogDiagnostics } from "./diagnostics.js";
import { getCatalog, getCalendar } from "./source.js";
import { scheduleFor } from "./schedules.js";
import { PERSON, personName, roomKey, tokens, roomMoves } from "./rooms.js";
import {
semesterView,
lessonDates,
@@ -15,15 +16,6 @@ import {
let index = null,
building = null;
const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu;
// "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits.
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мк": on that date the lesson moves.
const OVERRIDE = new RegExp(
`(\\d{1,2})\\.(\\d{1,2})\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`,
"giu",
);
const DATE = /\d{1,2}\.\d{1,2}(?:\.\d{2,4})?\.?/g;
// Week lists inside a room note: "ауд.205;6,8н.-…", "1-9н.".
const WEEKS = /\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*н(?:ед)?\.?|\d{1,2}\s*н(?:ед)?\./giu;
@@ -31,46 +23,21 @@ const MENTION = /(?:ауд\.?|(?<![А-Яа-яЁё])а\.)\s*([^\n(]{0,40})/giu;
const HYPHEN_ROOM = /[А-ЯA-Z]\d-\d{2}/gu;
export const normalize = (s) =>
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s.]/g, "");
export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim();
// 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 { personName, roomKey };
/** Ordinary numbered rooms only: "129", "207Г". Distance learning "200(ДО)" is not a free room. */
export const isPhysicalRoom = (room) =>
/^(\d{2,4}[А-ЯA-Z]{0,2}|\d{2,4}\/\d{2,4}|[А-ЯA-Z]\d-\d{2})$/.test(room) &&
!/ДО$/.test(room);
const isoFor = (day, month, year) => {
const m = Number(month),
y = m >= 8 ? Number(year) : Number(year) + 1;
return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12;
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) || [];
/**
* Rooms in a cell or room field. Dates, week lists and dated moves are taken out
* first so "ауд. 432 14.10. -ауд. 333" gives room 432 and a move to 333 on 14 October.
* "200 (ДО)" is distance learning and occupies no room.
*/
export function parseRooms(text, year) {
let rest = (text || "").replace(/\u00a0/g, " ");
const overrides = [];
rest = rest.replace(OVERRIDE, (match, d, m, room) => {
if (!validDate(d, m)) return match;
if (year) overrides.push({ date: isoFor(d, m, year), room: roomKey(room) });
return " ";
});
const moved = roomMoves((text || "").replace(/\u00a0/g, " "), year);
const overrides = moved.moves;
let rest = moved.rest;
// "200 (ДО)" or "200ДО": distance learning.
const distance = /\(\s*до\s*\)|\d\s*до(?![а-яё])/i.test(rest);
rest = rest.replace(/\([^)]*\)?/g, " ").replace(DATE, " ").replace(WEEKS, " ");
@@ -105,17 +72,61 @@ export function peopleAndRooms(lesson, year) {
const overrides = [...cell.overrides, ...field.overrides].filter(
(o, i, all) => all.findIndex((x) => x.date === o.date && x.room === o.room) === i,
);
// Rooms and teachers by week ("1-9н. 502н / с 10н. – 359"): all of them belong to the lesson.
const plan = lesson.plan || [];
for (const p of plan) {
for (const r of p.rooms) rooms.add(r);
for (const t of p.teachers) teachers.add(personName(t));
}
return {
teachers: [...teachers],
rooms: cell.distance || field.distance ? [] : [...rooms],
overrides,
plan,
distance: cell.distance || field.distance,
};
}
/** Rooms a lesson uses on a date: a dated replacement wins over the usual room. */
export function roomsOn(entry, date) {
const inWeeks = (ranges, n) => !ranges || (n != null && ranges.some(([a, b]) => n >= a && (b === null || n <= b)));
/** The lesson's plan entries for a week `{n, color}`: by its number, its colour or both. */
const planFor = (entry, week) =>
week && entry.plan?.length ? entry.plan.filter((p) => inWeeks(p.weeks, week.n) && (!p.color || p.color === week.color)) : [];
/**
* Rooms a lesson uses on a date: a dated move wins, then the room for that week ("1-9н. 502н",
* "бел.н.504н/зел.н.524н"), then the lesson's rooms.
*/
export function roomsOn(entry, date, week = null) {
const moved = entry.overrides.filter((o) => o.date === date).map((o) => o.room);
return moved.length ? moved : entry.rooms;
if (moved.length) return moved;
const planned = [...new Set(planFor(entry, week).flatMap((p) => p.rooms))];
if (planned.length) return planned;
// A week the plan does not name: not the rooms it names only for other weeks.
if (week && entry.plan?.length) {
const only = new Set(entry.plan.flatMap((p) => p.rooms));
return entry.rooms.filter((r) => !only.has(r));
}
return entry.rooms;
}
/** Whether the lesson moves to another room on that date. */
export const movedOn = (entry, date) => entry.overrides.some((o) => o.date === date);
/** Teachers of a lesson in a week: "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э." names one each. */
export function teachersOn(entry, week = null) {
const planned = [...new Set(planFor(entry, week).flatMap((p) => p.teachers))];
if (planned.length) return planned;
if (week && entry.plan?.length) {
const only = new Set(entry.plan.flatMap((p) => p.teachers));
const usual = entry.teachers.filter((t) => !only.has(t));
if (usual.length) return usual;
}
return entry.teachers;
}
/** The week a date falls in for an entry's document, `{n, color}` or null. */
export function weekFor(entry, date, calendar, views = new Map()) {
const key = `${entry.doc.id}|${date}`;
if (!views.has(key)) views.set(key, semesterView(calendar, entry.doc.schedule, date));
const view = views.get(key);
return view ? view.weeks[weekIndexOf(view, date)] || null : null;
}
export function buildIndex(docs) {
@@ -285,18 +296,21 @@ export function dayOf(idx, calendar, { teacher, room }, date) {
const status = occurrence(entry, date, calendar, views);
if (status === "unknown") unknownDocs.add(entry.doc.id);
if (status !== "yes" && status !== "maybe") continue;
const rooms = roomsOn(entry, date);
const week = weekFor(entry, date, calendar, views);
const rooms = roomsOn(entry, date, week);
const teachers = teachersOn(entry, week);
if (wantRoom && !rooms.includes(wantRoom)) continue;
if (want && !teachers.some((t) => normalize(t) === want)) continue;
const l = entry.lesson;
const key = [l.start, normalize(l.subject), rooms.join(","), entry.teachers.join(",")].join("|");
const key = [l.start, normalize(l.subject), rooms.join(","), teachers.join(",")].join("|");
const item = merged.get(key) || {
start: l.start,
end: l.end,
subject: l.subject,
type: l.type,
teachers: entry.teachers,
teachers,
rooms,
moved: rooms !== entry.rooms,
moved: movedOn(entry, date),
distance: entry.distance,
groups: [],
documents: [],
@@ -329,7 +343,7 @@ export function freeRooms(idx, calendar, date, pair) {
const endOf = (l) =>
l.end ? minutes(l.end) : minutes(calendar.bells.find((b) => b.start === l.start)?.end || l.start) || minutes(l.start) + 80;
for (const entry of idx.entries) {
const rooms = roomsOn(entry, date).filter(isPhysicalRoom);
const rooms = roomsOn(entry, date, weekFor(entry, date, calendar, views)).filter(isPhysicalRoom);
if (!rooms.length) continue;
const l = entry.lesson,
start = minutes(l.start),