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:
23
README.md
23
README.md
@@ -140,6 +140,29 @@ npm run sync
|
||||
|
||||
Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, `9.00`, `18.35.`, интервал `11:10-11:30` или несколько времён (`17.00 и 18.35`, `18.35+20.10` — тогда это отдельные занятия), оно имеет приоритет; окончание не выдумывается. Дата в начале ячейки (`13.10`, `3.12`, `24.11.`, `12.09.26г.`) делает запись разовой. Запись вида `20.10` может быть и датой, и началом восьмой пары: если такая дата приходится на другой день недели, чем строка, или после неё идёт пометка недель, это время.
|
||||
|
||||
**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`):
|
||||
- **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`.
|
||||
- **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям.
|
||||
- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д».
|
||||
- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
|
||||
|
||||
**Ещё форматы ячеек:**
|
||||
- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
|
||||
- **Разный вид по неделям.** «1‑11н. ‑ л., 12‑14 – п.з. Защита населения…» — лекция на одни недели, практика на другие.
|
||||
- **Дисциплины по выбору и специализации.**
|
||||
- Заголовок «Дисциплины по выбору:» или «Дисциплина специализации: 4‑14 н. …» — не занятие, и он не скрывает недели.
|
||||
- Строка «Социология 1‑10 н. (л.) … 11‑16 н. (п.з.) …» с неделями после названия становится занятиями по видам.
|
||||
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
|
||||
- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
|
||||
- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
|
||||
- **Ячейка из одной точки** — не занятие.
|
||||
- **Время в начале ячейки:**
|
||||
- интервал с дефисом «17:00‑17:20‑информационный час»;
|
||||
- заголовок со временем «18.35+20.10 Новополоцк:» передаёт время занятиям под ним.
|
||||
- **Длинная запись под заголовком заглавными** («ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …») называется этим заголовком.
|
||||
|
||||
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
|
||||
|
||||
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
||||
|
||||
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseWeekRanges, weekday, DATE_LIST } from "../shared/weeks.js";
|
||||
import { roomMoves } from "./rooms.js";
|
||||
|
||||
// The parser never guesses: a cell it cannot read becomes a lesson "every week" or keeps its
|
||||
// whole text as the subject. These checks find such cells after parsing, so a new format or a
|
||||
@@ -53,7 +54,9 @@ export function diagnose(schedule) {
|
||||
add("weeks_out_of_range", lesson, { condition: lesson.condition });
|
||||
DATE_LIST.lastIndex = 0;
|
||||
}
|
||||
if (!dated && !lesson.condition && (MONTHS.test(raw) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(raw)))
|
||||
// A move to another room on a date ("412н 26.10.2026-333") is read, not a lesson date.
|
||||
const undated = roomMoves(raw).rest;
|
||||
if (!dated && !lesson.condition && (MONTHS.test(undated) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(undated)))
|
||||
add("dates_unread", lesson);
|
||||
for (const date of lesson.date ? [lesson.date] : lesson.dates || []) {
|
||||
if (weekday(date) !== lesson.day) add("date_weekday", lesson, { date });
|
||||
|
||||
@@ -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),
|
||||
|
||||
146
server/parser.js
146
server/parser.js
@@ -7,8 +7,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
|
||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 12;
|
||||
export const PARSER_VERSION = 13;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -24,19 +25,22 @@ const clean = (value) =>
|
||||
.replace(/[\t ]+/g, " ")
|
||||
.replace(/\s*\n\s*/g, "\n")
|
||||
.trim();
|
||||
// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "25-ИЯ(РА)",
|
||||
// "23-ИНС-1". Header cells also carry practice dates ("02.11-22.11-производственная практика",
|
||||
// "09.11 по 06.12"): a number right after a date's dot or another digit is never a group.
|
||||
// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "23-ИНС-1",
|
||||
// "24-ИЯ (РА)и-1" (a specialisation in brackets, then the group's letter and number). Header
|
||||
// cells also carry practice dates ("02.11-22.11-производственная практика", "09.11 по 06.12"):
|
||||
// a number right after a date's dot or another digit is never a group. Written as the university
|
||||
// writes it: "24-ИЯ (РА)и-1", with the space before the bracket.
|
||||
export const groupsIn = (value) =>
|
||||
[
|
||||
...value.matchAll(
|
||||
/(?<![\d.,:/])\b\d{2}\s*[-–]?\s*[А-ЯЁ][А-ЯЁа-яё]{1,7}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu,
|
||||
/(?<![\d.,:/])\b\d{2}\s*[-–]?\s*[А-ЯЁ][А-ЯЁа-яё]{1,7}(?:\s*\([А-ЯЁа-яё]+\)[а-яё]{0,3})?(?:\s*[-–]\s*\d)?/gu,
|
||||
),
|
||||
].map((m) =>
|
||||
m[0]
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/–/g, "-")
|
||||
.replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2"),
|
||||
.replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2")
|
||||
.replace(/(\S)\(/, "$1 ("),
|
||||
);
|
||||
function timesIn(value) {
|
||||
const times = [...value.matchAll(/\b([0-2]?\d)[.:]([0-5]\d)\b/g)].map(
|
||||
@@ -73,15 +77,20 @@ export function details(raw) {
|
||||
// Correspondence sessions: "Экзамен по учебной дисциплине «Базы данных» Скуковская А.А. ауд.219Д".
|
||||
const named = rest.match(/^по\s+(?:учебной\s+)?дисциплине\s*«([^»]+)»\s*/i);
|
||||
if (named) rest = named[1].trim() + " " + rest.slice(named[0].length);
|
||||
const kind = rest.match(
|
||||
/\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
|
||||
);
|
||||
// "Дисциплина по выбору: Социология": the elective's label is no part of its name.
|
||||
rest = rest.replace(/^дисциплин[аы]\s+(?:по\s+выбору|с[пц]е[цш]иализаци[ийя])\s*:\s*/iu, "");
|
||||
// "12н.2п/гр. Экономика…": the lesson of one subgroup, named in the cell.
|
||||
const own = rest.match(/^(\d)\s*п\.?\s*\/?\s*гр?\.?\s*/i);
|
||||
if (own) rest = rest.slice(own[0].length);
|
||||
const kind = rest.match(KIND);
|
||||
// Whole words only: "консультирования" in a subject name is not a consultation.
|
||||
const sessionWord = (leading?.[1] ||
|
||||
rest.match(/(?<![а-яё])(экзамен|зач[её]т|консультация)(?![а-яё])/i)?.[1] ||
|
||||
"").toLowerCase();
|
||||
const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u);
|
||||
const room =
|
||||
// "ауд. А1-06", "ауд. С0-03": a building letter and a number.
|
||||
rest.match(/(?:ауд\.?|(?<![А-Яа-яЁё])а\.)\s*([А-ЯA-Z]\d-\d{2})/u) ||
|
||||
rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) ||
|
||||
rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u) ||
|
||||
// A room with a name instead of a number: "ауд.Медиа".
|
||||
@@ -94,9 +103,13 @@ export function details(raw) {
|
||||
// A typo'd date before the subject ("19,11, 03.12 Физика") leaves a stray comma behind.
|
||||
subject = subject.replace(/^[\s,;.]+/, "");
|
||||
if (!subject) subject = rest;
|
||||
// A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"):
|
||||
// the heading names it; the whole text stays in the details.
|
||||
const heading = rest.split("\n")[0].trim();
|
||||
if (subject === rest && rest.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
|
||||
const defence = /^защита/i.test(leading?.[1] || "");
|
||||
const token =
|
||||
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
|
||||
kind?.[1].replace(/[.\s/]/g, "").toLowerCase() ||
|
||||
(defence ? "защ" : undefined) ||
|
||||
(sessionWord.startsWith("экз")
|
||||
? "экз"
|
||||
@@ -110,7 +123,9 @@ export function details(raw) {
|
||||
teacher: person?.[0] || "",
|
||||
room: room?.[1].trim().replace(/\s+/g, "") || "",
|
||||
type:
|
||||
token === "устл"
|
||||
token === "лпз" || token === "липз"
|
||||
? "Лекция и практика"
|
||||
: token === "устл"
|
||||
? "Установочная лекция"
|
||||
: token === "устпз"
|
||||
? "Установочное занятие"
|
||||
@@ -132,11 +147,97 @@ export function details(raw) {
|
||||
? "Консультация"
|
||||
: "Занятие",
|
||||
condition,
|
||||
...(own && { ownSubgroup: Number(own[1]) }),
|
||||
};
|
||||
}
|
||||
// Split only at an explicit new week condition. Keep unfamiliar wording intact.
|
||||
function alternatives(raw) {
|
||||
return raw.split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)})`, "i")).filter(Boolean);
|
||||
// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)".
|
||||
const KIND =
|
||||
/\((л\.?\s*(?:\/|и)\s*п\.?\s*з\.?|уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i;
|
||||
// Split only at an explicit new week condition. Keep unfamiliar wording intact. A line of an
|
||||
// elective's choice may put its weeks after the subject ("Социология 1-10 н. (л.) …"): it is
|
||||
// a lesson of its own, read with the weeks first.
|
||||
const SUBJECT_THEN_WEEKS = /^([А-ЯЁ][а-яё]+(?:[ -][а-яё]+){0,4})\s+((?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)\s*(?=\()/u;
|
||||
// "Дисциплина специализации: 4-14 н. Преступления…": the heading hides the weeks.
|
||||
const HEADING = /^[^\n:]{3,40}:\s*(?=(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н)/u;
|
||||
// "… (л.) Шидловский С.О. 11-16 н. (п.з.) Лапацуева А.С.": after a teacher, other weeks of
|
||||
// another kind begin on the same line.
|
||||
const NEXT_KIND = /(?<=[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.)\s+(?=(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s*\()/u;
|
||||
export function alternatives(raw) {
|
||||
return raw
|
||||
// Lookaheads without capturing groups: split() would return what they capture.
|
||||
.split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)}|${SUBJECT_THEN_WEEKS.source.slice(1).replace(/(?<!\\)\((?!\?)/g, "(?:")})`, "iu"))
|
||||
.filter(Boolean)
|
||||
.map((part) => part.replace(HEADING, "").replace(SUBJECT_THEN_WEEKS, "$2 $1 "))
|
||||
.flatMap((part) => part.split(NEXT_KIND))
|
||||
.reduce(
|
||||
(state, part) => {
|
||||
// "Дисциплины по выбору:" heads the lessons below; it is none itself. A heading with
|
||||
// times ("18.35+20.10 Новополоцк:") gives those times to the lessons under it.
|
||||
if (/^[^\n]{1,60}:\s*$/.test(part.trim())) state.times = part.match(HEADING_TIMES)?.[0] || state.times;
|
||||
else state.parts.push(state.times ? `${state.times} ${part}` : part);
|
||||
return state;
|
||||
},
|
||||
{ parts: [], times: "" },
|
||||
).parts;
|
||||
}
|
||||
const HEADING_TIMES = /^[0-2]?\d[.:][0-5]\d(?:\s*(?:\+|и|,)\s*[0-2]?\d[.:][0-5]\d)*/u;
|
||||
// "1-11н. - л., 12-14 – п.з. Защита населения… Дмитриченко Н.С.": one kind for some weeks,
|
||||
// another for the others.
|
||||
const WEEKS_TEXT = String.raw`(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)?`;
|
||||
const KIND_TEXT = String.raw`л|п\.?\s*з|л\.?\s*з|лаб|пр`;
|
||||
const BY_KIND = new RegExp(
|
||||
String.raw`^(${WEEKS_TEXT})\s*[-–—]\s*(${KIND_TEXT})\.?\s*,\s*(${WEEKS_TEXT})\s*[-–—]\s*(${KIND_TEXT})\.?\s+([\s\S]+)$`,
|
||||
"iu",
|
||||
);
|
||||
function byKind(text) {
|
||||
const m = text.match(BY_KIND);
|
||||
if (!m) return [text];
|
||||
const weeks = (w) => (/н/i.test(w) ? w.trim() : `${w.trim()}н.`);
|
||||
// The kind goes right after the subject, before the teacher and the room.
|
||||
const rest = m[5];
|
||||
const cut = [rest.search(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u), rest.search(/(?:ауд\.?|(?<![А-Яа-яЁё])а\.)\s*\S/u)]
|
||||
.filter((i) => i > 0)
|
||||
.reduce((a, b) => Math.min(a, b), rest.length);
|
||||
const at = (kind) => `${rest.slice(0, cut).trim()} (${kind}.) ${rest.slice(cut)}`.trim();
|
||||
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
|
||||
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
|
||||
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
|
||||
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
|
||||
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
|
||||
* other weeks. A stray "." is no lesson.
|
||||
*/
|
||||
export function entries(raw) {
|
||||
const out = [];
|
||||
for (const alternative of alternatives(raw).flatMap(byKind)) {
|
||||
if (!/[\p{L}\d]/u.test(alternative)) continue;
|
||||
const prev = out.at(-1);
|
||||
if (prev && onlyPlace(alternative)) {
|
||||
if (KIND.test(alternative)) {
|
||||
const head = details(prev.main);
|
||||
// "Дисциплина по выбору: Социология ауд. С0-03" over lines by kind: a heading, not a
|
||||
// lesson; its room goes to the lessons under it.
|
||||
if (!KIND.test(prev.main) && !head.teacher && !prev.plan.length) prev.heading = true;
|
||||
for (const part of partsByWeek(alternative)) {
|
||||
const mark = part.match(WEEK_MARK)?.[0] || "";
|
||||
const rest = part.slice(mark.length);
|
||||
const room = prev.heading && head.room && !/\d{2,}/.test(rest) ? ` ауд. ${head.room}` : "";
|
||||
const text = `${mark} ${head.subject} ${rest}${room}`.replace(/[ \t]+/g, " ").trim();
|
||||
out.push({ text, main: text, plan: [] });
|
||||
}
|
||||
} else {
|
||||
prev.text += "\n" + alternative;
|
||||
prev.plan.push(...weekPlan(alternative));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
|
||||
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
|
||||
}
|
||||
return out.filter((e) => !e.heading);
|
||||
}
|
||||
export function readDocx(buffer) {
|
||||
let zip;
|
||||
@@ -250,7 +351,7 @@ export function leadingDate(text, yearStart) {
|
||||
*/
|
||||
export function leadingTime(text, loose = false) {
|
||||
const hm = (h, m) => `${h.padStart(2, "0")}:${m}`;
|
||||
const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s)/);
|
||||
const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s|[-–—])/);
|
||||
if (range && Number(range[1]) <= 23 && Number(range[3]) <= 23)
|
||||
return { starts: [hm(range[1], range[2])], end: hm(range[3], range[4]), length: range[0].length };
|
||||
const T = String.raw`[0-2]?\d[.:][0-5]\d`;
|
||||
@@ -447,10 +548,11 @@ export function parseDocx(buffer, source = {}) {
|
||||
const spansBoth =
|
||||
cell.merge === "restart" &&
|
||||
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
||||
for (const alternative of alternatives(raw)) {
|
||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject.
|
||||
for (const { text: whole, main: alternative, plan } of entries(raw)) {
|
||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
|
||||
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
|
||||
const read = (text) => {
|
||||
const data = details(text.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
return data;
|
||||
};
|
||||
@@ -483,6 +585,10 @@ export function parseDocx(buffer, source = {}) {
|
||||
group: r.group,
|
||||
label: `Подгруппа ${cell.col - r.col + 1}`,
|
||||
}));
|
||||
// A subgroup named in the cell ("2п/гр.") when the column does not tell one.
|
||||
const own = data.ownSubgroup;
|
||||
delete data.ownSubgroup;
|
||||
if (own && !subgroups.length) subgroups.push(...targetGroups.map((group) => ({ group, label: `Подгруппа ${own}` })));
|
||||
// "17.00 и 18.35 Русский язык": one lesson at each time.
|
||||
for (const [n, start] of (lead?.starts || [null]).entries())
|
||||
lessons.push({
|
||||
@@ -511,7 +617,9 @@ export function parseDocx(buffer, source = {}) {
|
||||
!["auto", "FFFFFF"].includes(cell.color.toUpperCase())
|
||||
? cell.color
|
||||
: null,
|
||||
raw: alternative,
|
||||
raw: whole,
|
||||
// Rooms and teachers by week, or by the week's colour.
|
||||
...(plan.length && { plan }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
114
server/rooms.js
Normal file
114
server/rooms.js
Normal 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);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// third-party clients use it instead of re-implementing the white/green week rules.
|
||||
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
|
||||
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
|
||||
import { occurrence, peopleAndRooms, roomsOn } from "./directory.js";
|
||||
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
|
||||
import { apiError } from "./errors.js";
|
||||
|
||||
const MAX_DAYS = 62;
|
||||
@@ -10,9 +10,10 @@ const MAX_DAYS = 62;
|
||||
const MAX_SEMESTER_DAYS = 200;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function lessonOut(lesson, group, people, date, status, calendar) {
|
||||
// On a date: the room and teacher of that day and week ("1-9н. 502н", a move on 26.10).
|
||||
function lessonOut(lesson, group, people, date, status, calendar, week = null) {
|
||||
const bell = calendar?.bells.find((b) => b.start === lesson.start);
|
||||
const rooms = date ? roomsOn(people, date) : people.rooms;
|
||||
const rooms = date ? roomsOn(people, date, week) : people.rooms;
|
||||
return {
|
||||
key: lessonKey(lesson, group),
|
||||
start: lesson.start,
|
||||
@@ -20,9 +21,9 @@ function lessonOut(lesson, group, people, date, status, calendar) {
|
||||
pair: bell?.pair ?? null,
|
||||
subject: lesson.subject,
|
||||
type: lesson.type,
|
||||
teachers: people.teachers,
|
||||
teachers: date ? teachersOn(people, week) : people.teachers,
|
||||
rooms,
|
||||
roomMoved: Boolean(date && rooms !== people.rooms),
|
||||
roomMoved: Boolean(date && movedOn(people, date)),
|
||||
distance: people.distance,
|
||||
condition: lesson.condition,
|
||||
variant: lesson.variant,
|
||||
@@ -37,9 +38,23 @@ function lessonOut(lesson, group, people, date, status, calendar) {
|
||||
* days: one entry per date from `from`, with the lessons that take place that day.
|
||||
* weekly: the Word rows by weekday, for when dates cannot be calculated.
|
||||
*/
|
||||
const groupKey = (g) => g.toLowerCase().replace(/[\s\-–]/g, "");
|
||||
|
||||
export function timetable({ file, schedule, calendar, group, from, days, range, today, session: extra = [] }) {
|
||||
if (!schedule.groups.includes(group)) {
|
||||
throw apiError(404, "group_not_found", `В документе нет группы ${group}.`);
|
||||
// The same group written another way: "24-ИЯ(РА)и-1" for "24-ИЯ (РА)и-1".
|
||||
const same = schedule.groups.find((g) => groupKey(g) === groupKey(group));
|
||||
// A name parser 13 split into groups: a client may still keep "24-ИЯ(РА)".
|
||||
const split = schedule.groups.filter((g) => groupKey(g).startsWith(groupKey(group)));
|
||||
if (same) group = same;
|
||||
else
|
||||
throw apiError(
|
||||
404,
|
||||
"group_not_found",
|
||||
split.length
|
||||
? `Группа ${group} теперь разделена: ${split.join(", ")}. Выберите свою в списке групп.`
|
||||
: `В документе нет группы ${group}.`,
|
||||
);
|
||||
}
|
||||
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
|
||||
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
||||
@@ -90,7 +105,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
for (const entry of entries) {
|
||||
const status = occurrence(entry, date, calendar, views);
|
||||
if (status === "yes" || status === "maybe")
|
||||
dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar));
|
||||
dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar, week));
|
||||
}
|
||||
// Session events printed with their own date need no week table.
|
||||
for (const entry of extra)
|
||||
|
||||
117
test/formats.test.js
Normal file
117
test/formats.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { groupsIn, alternatives, entries, details, leadingTime } from "../server/parser.js";
|
||||
import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js";
|
||||
import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js";
|
||||
|
||||
// Cell formats found by the catalog's diagnostics (October 2026).
|
||||
|
||||
test("Группы с направлением в скобках: 24-ИЯ (РА)и-1 … и-6 — разные группы", () => {
|
||||
assert.deepEqual(groupsIn("24-ИЯ (РА)и-1 | 24-ИЯ (РА)и-2 | 24-ИЯ (РА)и-3"), ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2", "24-ИЯ (РА)и-3"]);
|
||||
assert.deepEqual(groupsIn("25 ИЯ (РА)и-1"), ["25-ИЯ (РА)и-1"]);
|
||||
assert.deepEqual(groupsIn("24-РГФи-1 26-ИТ-2"), ["24-РГФи-1", "26-ИТ-2"]);
|
||||
});
|
||||
|
||||
test("Перенос аудитории на дату, и с годом: «412н 26.10.2026-333»", () => {
|
||||
assert.deepEqual(roomMoves("Типология (л.) Полякова Г.В. 412н 26.10.2026-333", 2026).moves, [{ date: "2026-10-26", room: "333" }]);
|
||||
assert.deepEqual(roomMoves("Язык (п.з.) Горбач М.Н. 510н\n28.10.2026-504н", 2026).moves, [{ date: "2026-10-28", room: "504Н" }]);
|
||||
assert.deepEqual(roomMoves("ауд. 432 14.10. -ауд. 333", 2026).moves, [{ date: "2026-10-14", room: "333" }]);
|
||||
// The parser keeps the usual room; the move goes to that date only.
|
||||
const lesson = { raw: "Типология (л.) Полякова Г.В. 412н 26.10.2026-333", room: "412н", teacher: "Полякова Г.В." };
|
||||
const entry = peopleAndRooms(lesson, "2026");
|
||||
assert.deepEqual(roomsOn(entry, "2026-10-26"), ["333"]);
|
||||
assert.equal(movedOn(entry, "2026-10-26"), true);
|
||||
assert.deepEqual(roomsOn(entry, "2026-10-19"), ["412Н"]);
|
||||
assert.equal(movedOn(entry, "2026-10-19"), false);
|
||||
});
|
||||
|
||||
test("Строка с аудиторией по неделям продолжает занятие, а не становится новым", () => {
|
||||
const [one, ...rest] = entries("Язык профессионального общения (англ) (п.з.) Шабунина О.А.\n1-9н. 502н\nс 10н -359");
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(details(one.main).subject, "Язык профессионального общения (англ)");
|
||||
assert.deepEqual(one.plan, [
|
||||
{ weeks: [[1, 9]], color: null, rooms: ["502Н"], teachers: [] },
|
||||
{ weeks: [[10, null]], color: null, rooms: ["359"], teachers: [] },
|
||||
]);
|
||||
const entry = peopleAndRooms({ raw: one.text, room: "", teacher: "Шабунина О.А.", plan: one.plan }, "2026");
|
||||
assert.deepEqual(roomsOn(entry, "2026-10-20", { n: 8, color: "green" }), ["502Н"]);
|
||||
assert.deepEqual(roomsOn(entry, "2026-11-03", { n: 10, color: "green" }), ["359"]);
|
||||
});
|
||||
|
||||
test("Преподаватель по неделям и аудитория по цвету недели", () => {
|
||||
const teachers = weekPlan("2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д");
|
||||
// The room at the end is the room of both parts.
|
||||
assert.deepEqual(teachers, [
|
||||
{ weeks: [[2, 9]], color: null, rooms: ["319Д"], teachers: ["Клос О.Ю."] },
|
||||
{ weeks: [[10, 14]], color: null, rooms: ["319Д"], teachers: ["Конторов В.Э."] },
|
||||
]);
|
||||
const entry = peopleAndRooms({ raw: "2-14н. История литературы (п.з.)\n2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д", room: "", teacher: "", plan: teachers }, "2026");
|
||||
assert.deepEqual(teachersOn(entry, { n: 3, color: "white" }), ["Клос О.Ю."]);
|
||||
assert.deepEqual(teachersOn(entry, { n: 12, color: "green" }), ["Конторов В.Э."]);
|
||||
|
||||
const [lesson] = entries("с 10н. Дискурсивная практика (рус) (п.з.) Булах А.С.\nбел.н.504н/зел.н.524н");
|
||||
const byColor = peopleAndRooms({ raw: lesson.text, room: "", teacher: "", plan: lesson.plan }, "2026");
|
||||
assert.deepEqual(roomsOn(byColor, "2026-11-10", { n: 11, color: "white" }), ["504Н"]);
|
||||
assert.deepEqual(roomsOn(byColor, "2026-11-17", { n: 12, color: "green" }), ["524Н"]);
|
||||
assert.equal(onlyPlace("1-9н. 502н"), true);
|
||||
assert.equal(onlyPlace("13,15 н. РБ (л.з) Сивацкий Д.С.209н"), false);
|
||||
});
|
||||
|
||||
test("Строка с видом и неделями без предмета — тот же предмет в другие недели", () => {
|
||||
const list = entries("2-6н. Потребительское право (л.) Савицкая К.Д. ауд. А1-06\n7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) Савицкая К.Д. ауд. А1-06");
|
||||
assert.deepEqual(list.map((e) => [details(e.main).condition, details(e.main).subject, details(e.main).type]), [
|
||||
["2-6н.", "Потребительское право", "Лекция"],
|
||||
["7,9,11н.", "Потребительское право", "Практика"],
|
||||
["8,12,14н.", "Потребительское право", "Практика"],
|
||||
]);
|
||||
assert.equal(details(list[0].main).room, "А1-06");
|
||||
});
|
||||
|
||||
test("Дисциплины по выбору: заголовок, предмет перед неделями, виды по неделям", () => {
|
||||
const read = (raw) => entries(raw).map((e) => ((d) => `${d.condition} ${d.subject} · ${d.type}`)(details(e.main)));
|
||||
assert.deepEqual(read("Дисциплины по выбору: 1-16 н. Логика (л./п.з.) Магалинский И.В. ауд. С0-05\nСоциология 1-10 н. (л.) Шидловский С.О. 11-16 н. (п.з.) Лапацуева А.С.С0-03"), [
|
||||
"1-16 н. Логика · Лекция и практика",
|
||||
"1-10 н. Социология · Лекция",
|
||||
"11-16 н. Социология · Практика",
|
||||
]);
|
||||
assert.deepEqual(read("Дисциплина по выбору: Социология ауд. С0-03\n1-10 н. (л.) Шидловский С.О.\n11-18 н. (п.з.) Лапацуева А.С."), [
|
||||
"1-10 н. Социология · Лекция",
|
||||
"11-18 н. Социология · Практика",
|
||||
]);
|
||||
assert.deepEqual(read("Дисциплина сцециализации: 4-14 н. Преступления в сфере информационных технологий (л.) Чижик Е.В. ауд. В0-05"), [
|
||||
"4-14 н. Преступления в сфере информационных технологий · Лекция",
|
||||
]);
|
||||
assert.deepEqual(read("1-11н. - л., 12-14 – п.з. Защита населения Дмитриченко Н.С. ауд. 210м/к"), [
|
||||
"1-11н. Защита населения · Лекция",
|
||||
"12-14н. Защита населения · Практика",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Время в начале ячейки: интервал с дефисом, заголовок со временем", () => {
|
||||
assert.deepEqual(leadingTime("17:00-17:20-информационный час-Клос О.Ю. 319Д").starts, ["17:00"]);
|
||||
assert.equal(leadingTime("17:00-17:20-информационный час").end, "17:20");
|
||||
assert.deepEqual(alternatives("18.35+20.10 Новополоцк:\n1-5 н. Судебно-следственная психология (л.) Колесникова Е.В. ауд. 246"), [
|
||||
"18.35+20.10 1-5 н. Судебно-следственная психология (л.) Колесникова Е.В. ауд. 246",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Подгруппа в тексте ячейки, точка вместо занятия, заголовок заглавными", () => {
|
||||
const own = details("12н.2п/гр. Экономика организаций (л.з.) Лисиченок Е.П. ауд.331");
|
||||
assert.equal(own.subject, "Экономика организаций");
|
||||
assert.equal(own.ownSubgroup, 2);
|
||||
assert.deepEqual(entries("."), []);
|
||||
assert.equal(details("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ\nг. Полоцк, Полоцкий коллегиум\n26-ГЕО, 26-ИТ-1 Ректорский вход\n26-ИТ-2, 26-СТ обзорная экскурсия").subject, "ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ");
|
||||
// A two-line subject stays whole.
|
||||
assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true);
|
||||
});
|
||||
|
||||
test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => {
|
||||
const { timetable } = await import("../server/timetable.js");
|
||||
const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} };
|
||||
const file = { id: "doc", name: "24-ИЯ (РА)и-1_24-ИЯ (РА)и-2.docx" };
|
||||
assert.throws(
|
||||
() => timetable({ file, schedule, calendar: null, group: "24-ИЯ(РА)", from: "2026-10-19" }),
|
||||
(e) => e.code === "group_not_found" && /разделена: 24-ИЯ \(РА\)и-1, 24-ИЯ \(РА\)и-2/.test(e.message),
|
||||
);
|
||||
assert.equal(timetable({ file, schedule, calendar: null, group: "24-ИЯ(РА)и-1", from: "2026-10-19" }).group, "24-ИЯ (РА)и-1");
|
||||
});
|
||||
@@ -120,7 +120,7 @@ test("Даты практики в заголовке группы не стан
|
||||
["23-ТТиИ"],
|
||||
);
|
||||
assert.deepEqual(groupsIn("25-ИЯ(РА) 23 ИНС-1 24ДОи"), [
|
||||
"25-ИЯ(РА)",
|
||||
"25-ИЯ (РА)",
|
||||
"23-ИНС-1",
|
||||
"24-ДОи",
|
||||
]);
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
вт 13:40–15:05 | каждую неделю, нижняя строка | Информатика | Лекция | 362 | Василевский А.В. | 26-ПГС-1,26-ПГС-2
|
||||
вт 15:20–16:45 | каждую неделю | Химия | Лабораторная | | Аврамёнок А.Н. | 26-ПГС-1,26-ПГС-1/п2
|
||||
вт 15:20–16:45 | каждую неделю, верхняя строка | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-1,26-ПГС-1/п1
|
||||
вт 15:20–16:45 | нед. 13-17 | Иванов В.П. ауд.403 | Занятие | 403 | Иванов В.П. | 26-ПГС-1,26-ПГС-1/п2
|
||||
пн 10:05–11:30 | каждую неделю | История белорусской государственности | Лекция | 432 | Кордюкевич О.Г. | 26-ПГС-1,26-ПГС-2
|
||||
пн 11:45–13:10 | каждую неделю, верхняя строка | История белорусской государственности | Практика | 425 | Кордюкевич О.Г. | 26-ПГС-1
|
||||
пн 11:45–13:10 | каждую неделю, верхняя строка | Физика | Практика | 337н | Солдатенко П.Н. | 26-ПГС-2
|
||||
@@ -17,7 +16,6 @@
|
||||
пн 15:20–16:45 | каждую неделю | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-2,26-ПГС-2/п2
|
||||
пн 15:20–16:45 | каждую неделю | Химия | Лабораторная | | Аврамёнок А.Н. | 26-ПГС-1,26-ПГС-1/п1
|
||||
пн 15:20–16:45 | каждую неделю, нижняя строка | Инженерная геодезия | Лабораторная | 325н | Шевелев И.П. | 26-ПГС-1,26-ПГС-1/п3
|
||||
пн 15:20–16:45 | нед. 13-17 | Иванов В.П. ауд.403 | Занятие | 403 | Иванов В.П. | 26-ПГС-1,26-ПГС-1/п1
|
||||
пт 10:05–11:30 | каждую неделю, верхняя строка | Иностранный язык | Практика | 541н | Каменная В.А. | 26-ПГС-1,26-ПГС-1/п1
|
||||
пт 10:05–11:30 | каждую неделю, верхняя строка | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-1,26-ПГС-1/п2
|
||||
пт 10:05–11:30 | каждую неделю, верхняя строка | Физика | Лабораторная | 205н | Сивацкий Д.С. | 26-ПГС-2,26-ПГС-2/п1
|
||||
|
||||
Reference in New Issue
Block a user