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:
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 }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user