Files
psu-schedule/server/parser.js
Zakhar Timoshenko 297091cb0e feat: Add Settings and Subjects screens with theme reveal functionality
- Implemented SettingsScreen for user preferences including theme selection, reminders, and notifications.
- Created SubjectsScreen to display subjects with upcoming lessons and associated details.
- Introduced ThemeReveal for a smooth transition between light and dark themes.
- Added support for managing notes and tasks related to subjects.
- Enhanced UI components for better user experience and accessibility.
2026-10-24 02:47:37 +03:00

941 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AdmZip from "adm-zip";
import { load } from "cheerio";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
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";
import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile);
export const PARSER_VERSION = 19;
const days = [
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
];
export const clean = (value) =>
value
.replace(/\u00a0/g, " ")
// "Ф и з и ч е с к а я к у л ь т у р а": letters spaced out for show, words apart by
// several spaces. Joined back before the spaces collapse: "Физическая культура".
.replace(/(?<![\p{L}\d])(?:\p{L} ){3,}\p{L}(?![\p{L}\d])/gu, (word) => word.replace(/ /g, ""))
// "со 2 по 14 нед.": the usual "2-14 нед.".
.replace(/(?<![\p{L}\d])(?:с|со)\s*(\d{1,2})\s*по\s*(\d{1,2})(?=\s*н(?:ед)?\.?)/giu, "$1-$2")
.replace(/[\t ]+/g, " ")
.replace(/«\s+/g, "«")
.replace(/\s+»/g, "»")
.replace(/\s*\n\s*/g, "\n")
.trim();
// 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*\([А-ЯЁа-яё]+\)[а-яё]{0,3})?(?:\s*[-–]\s*\d)?/gu,
),
].map((m) =>
m[0]
.replace(/\s+/g, "")
.replace(/–/g, "-")
.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(
(m) => `${m[1].padStart(2, "0")}:${m[2]}`,
);
return times.length >= 2
? { start: times[0], end: times.at(-1), original: value }
: null;
}
// One part of a week mark, "6-12" or "2,4", with its optional "н."; a mark is several parts
// joined by ";" or "," ("1-4н.;6-12н.", "2-5 н., 10-17 н.", "1-9;15,16н.") ending in "н".
const WEEK_PART = String.raw`\d{1,2}(?:\s*[-–,]\s*\d{1,2})*(?:\s*н(?:ед)?\.?)?`;
const WEEK_MARK = new RegExp(
String.raw`^(?:(?:с|со)\s*)?${WEEK_PART}(?:\s*[;,]\s*${WEEK_PART})*(?<=н|нед|н\.|нед\.)(?:\s*\.)?\s*`,
"i",
);
function conditionIn(value) {
const m = value.match(WEEK_MARK);
if (m) return m[0].trim();
// Some university cells omit the week suffix (for example 1-8,10-17).
const range = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*/i,
);
return range?.[0].trim() || "";
}
export function details(text) {
// "Новополоцк" on a line of its own, "246(Новополоцк)": the town is no part of the subject or room.
const { place, rest: raw } = lessonPlace(text);
const condition = conditionIn(raw);
let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
const leading = rest.match(
/^(экзамен|зач[её]т|консультация|защита\s+курсов(?:ой|ого)\s+(?:работы|проекта))(?![а-яё])[\s.:–—-]*/i,
);
if (leading) rest = rest.slice(leading[0].length);
// Correspondence sessions: "Экзамен по учебной дисциплине «Базы данных» Скуковская А.А. ауд.219Д".
const named = rest.match(/^по\s+(?:учебной\s+)?дисциплине\s*«([^»]+)»\s*/i);
if (named) rest = named[1].trim() + " " + rest.slice(named[0].length);
// "Дисциплина по выбору: Социология": 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: "ауд.Медиа".
rest.match(/(?:ауд\.\s*)([А-ЯЁ][а-яё]{2,})(?![а-яё])/u);
// "Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100": the kind may follow the
// teacher, and the name ends where the first of them begins.
const end = [kind?.index, person?.index].filter((i) => i != null);
let subject = (named ? named[1].trim() : rest
.slice(0, end.length ? Math.min(...end) : room?.index ?? rest.length))
.trim()
.replace(/[–—,\-]+$/, "")
.trim();
// 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 && text.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() ||
(defence ? "защ" : undefined) ||
(sessionWord.startsWith("экз")
? "экз"
: sessionWord.startsWith("зач")
? "зач"
: sessionWord.startsWith("конс")
? "конс"
: undefined);
return {
subject,
teacher: person?.[0] || "",
room: room?.[1].trim().replace(/\s+/g, "") || "",
type:
token === "лпз" || token === "липз"
? "Лекция и практика"
: token === "устл"
? "Установочная лекция"
: token === "устпз"
? "Установочное занятие"
: token === "защ"
? "Защита курсовой работы"
: token === "лз" || token === "лаб"
? "Лабораторная"
: token === "пз" || token === "пр"
? "Практика"
: token === "л" || token === "лек"
? "Лекция"
: token === "сем"
? "Семинар"
: token === "экз"
? "Экзамен"
: token === "зач"
? "Зачёт"
: token === "конс"
? "Консультация"
: "Занятие",
condition,
...(own && { ownSubgroup: Number(own[1]) }),
...(place && { place }),
};
}
// 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])}`];
}
// Who a lesson is for: "(для иностранных студентов)", "Русский язык как иностранный" are for
// foreign students only, "(кроме иностр.студ.)" for the others.
const FOR_FOREIGN = /\(\s*для\s+иностр[^)]*\)/iu;
const NOT_FOREIGN = /\(\s*кроме\s+иностр[^)]*\)/iu;
const FOREIGN_SUBJECT = /как\s+иностранн/iu;
export function audienceOf(text) {
if (NOT_FOREIGN.test(text)) return "local";
if (FOR_FOREIGN.test(text) || FOREIGN_SUBJECT.test(text)) return "foreign";
return null;
}
/**
* One cell, two lessons at once: Russian for foreign students while the others have another
* lesson. "Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных
* студентов) Семченко Е.Д. ауд.313н" or "Русский язык как иностранный (п.з.) Немирская В.А. 331Е
* ФИЗИЧЕСКАЯ КУЛЬТУРА". A line with only the teacher below the lesson ("Хаткевич Т.В. ауд. 323н")
* is no lesson of its own.
*/
// "Новополоцк" or "17.00" before a lesson is its town or time, not another lesson.
const aLesson = (text) =>
/(?:[А-ЯЁа-яё]\s?){3}/u.test(lessonPlace(text).rest.replace(/\d{1,2}[.:]\d{2}|(?:г\.\s*)?(?:ново)?полоцк[а-яё]*/giu, ""));
export function byAudience(text) {
const lines = text.split("\n");
const at = lines.findIndex(
(line, i) => i > 0 && audienceOf(line) === "foreign" && !/^\s*[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u.test(line),
);
const head = lines.slice(0, at).join("\n");
if (at > 0 && audienceOf(head) !== "foreign" && aLesson(head))
return [
{ text: lines.slice(0, at).join("\n").trim(), audience: "local" },
{ text: lines.slice(at).join("\n").trim(), audience: "foreign" },
];
// "… Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА": the others' lesson in capitals after it.
const tail = text.match(
/^([\s\S]*?(?:\d{2,3}\s*[А-ЯЁа-яё]?|[А-ЯЁ]\.))\s+((?:(?:с|со)\s*)?(?:\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s+)?[А-ЯЁ]{3,}(?:\s+[А-ЯЁ]{3,})+)\s*$/u,
);
if (tail && audienceOf(tail[1]) === "foreign" && !audienceOf(tail[2]))
return [
{ text: tail[1].trim(), audience: "foreign" },
{ text: tail[2].trim(), audience: "local" },
];
// "Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный …" on one line.
const inline = text.match(/^((?:[А-ЯЁа-яё]\s?){3}[^\n]*?)\s+((?:\d{2}-[^\s:]+:\s*)?Русский\s+язык\s+как\s+иностранн[\s\S]*)$/u);
if (inline && !audienceOf(inline[1]) && aLesson(inline[1]))
return [
{ text: inline[1].trim(), audience: "local" },
{ text: inline[2].trim(), audience: "foreign" },
];
return null;
}
// "25-ДОи", "24-ТНГи-2": a group of foreign students.
const FOREIGN_GROUP = /[А-ЯЁа-яё]и(?:-\d+)?$/u;
// A line that is a whole lesson: a subject, its kind in brackets and a teacher.
const WHOLE_LESSON = /^\s*[А-ЯЁ][^\n]*?\((?:л|п\.?\s*з|л\.?\s*з|лаб|пр|сем)\.?\)[^\n]*?[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u;
/**
* "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е":
* one cell over the whole group, two lessons at once for the parts of the group that study one
* or the other. A line without a subject of its own ("321Д") stays with the lesson above.
*/
export function parallel(text) {
const parts = [];
for (const line of text.split("\n")) {
if (!parts.length || WHOLE_LESSON.test(line)) parts.push(line);
else parts[parts.length - 1] += "\n" + line;
}
if (parts.length < 2 || !parts.every((p) => WHOLE_LESSON.test(p))) return [text];
return parts.map((p) => p.trim().replace(/[,;]+$/, ""));
}
/**
* 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. Lessons side by side for parts of the group ([parallel])
* also carry `choice`: the subjects to choose from.
*/
export function entries(raw) {
const out = [];
for (const part of alternatives(raw).flatMap(byKind)) {
// "…Третьяк З.И. 331Е,\n2-13н. …": the comma before the next lesson is not part of the room.
const alternative = part.replace(/[,;]\s*$/, "");
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;
}
const options = parallel(alternative);
// "Теория языка (кроме иностр.студ.) … / История языка (для иностр.студ.) …": not a choice,
// each lesson is for its own students.
const marks = options.map(audienceOf);
if (options.length > 1 && marks.every(Boolean)) {
options.forEach((o, i) => out.push({ text: o, main: o, plan: [], audience: marks[i] }));
continue;
}
const split = byAudience(alternative);
if (split) {
for (const p of split) out.push({ text: p.text, main: p.text, plan: weekPlan(p.text).filter((w) => w.color), audience: p.audience });
continue;
}
if (options.length > 1) {
const choice = options.map((o) => details(o).subject);
for (const o of options) out.push({ text: o, main: o, plan: [], choice });
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;
try {
zip = new AdmZip(buffer);
} catch {
throw new Error("Файл повреждён или не является документом Word.");
}
const entry = zip.getEntry("word/document.xml");
if (!entry)
throw new Error(
"В файле нет документа Word. Выберите .docx с таблицей расписания.",
);
if (entry.header.size > 20 * 1024 * 1024 || zip.getEntries().length > 2000)
throw new Error("Документ слишком большой для импорта.");
const xml = entry.getData().toString("utf8");
const $ = load(xml.replace(/(<\/?)(?:w:)/g, "$1"), { xmlMode: true });
const text = (node) =>
clean(
$(node)
.find("p")
.map((_, p) =>
$(p)
.find("t, br, tab")
.map((_, t) =>
t.name === "br" ? "\n" : t.name === "tab" ? " " : $(t).text(),
)
.get()
.join(""),
)
.get()
.join("\n"),
);
const tables = $("body > tbl")
.map((_, tbl) => {
const rows = $(tbl)
.children("tr")
.map((_, tr) => {
let col = Number(
$(tr).children("trPr").find("gridBefore").attr("w:val") || 0,
);
return [
$(tr)
.children("tc")
.map((_, tc) => {
const span = Number(
$(tc).children("tcPr").find("gridSpan").attr("w:val") || 1,
);
const vm = $(tc).children("tcPr").find("vMerge");
const cell = {
text: text(tc),
col,
span,
merge: vm.length ? vm.attr("w:val") || "continue" : null,
color:
$(tc).children("tcPr").find("shd").attr("w:fill") || null,
};
col += span;
return cell;
})
.get(),
];
})
.get();
return [{ rows }];
})
.get();
return {
tables,
text: text($("body")[0]),
notes: $("body > p")
.map((_, p) =>
clean(
$(p)
.find("t")
.map((_, t) => $(t).text())
.get()
.join(""),
),
)
.get()
.filter((s) => s && !/декан|начальник|_{3}|^утверждаю/i.test(s)),
};
}
/**
* Laboratory-examination sessions of correspondence students: the group is named only in the
* title ("группа 23-ИТзс") and the table holds several weeks side by side. Each block of
* columns is "Д.н." (the weekday with its date), "Время" and "Дисциплина, преподаватель,
* аудитория", whose columns split in two where the subgroups have separate laboratory classes.
*/
/**
* A date that opens a cell: "13.10 Химия", "3.12 Физика", "24.11. Графика", "12.09.26г. Алгебра",
* "09.09.Материалы". A one-digit day only below 7, so "9.10" stays ambiguous rather than a date.
*/
export function leadingDate(text, yearStart) {
const m = text.match(/^(\d{1,2})\.(\d{2})(?:\.(\d{4}|\d{2})(?!\d))?\.?(?:\s*г\.?)?(?=\s|[А-ЯЁа-яё])/u);
if (!m) return null;
const day = Number(m[1]), month = Number(m[2]);
if (day < 1 || day > 31 || month < 1 || month > 12 || (m[1].length === 1 && day >= 7)) return null;
const year = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : yearStart ? (month >= 8 ? Number(yearStart) : Number(yearStart) + 1) : null;
return {
text: m[0].trim(),
length: m[0].length,
date: year ? `${year}-${m[2]}-${m[1].padStart(2, "0")}` : null,
};
}
/**
* Times of their own that open a cell, for a curator's hour or a late pair: "16.50 Кураторский час",
* "9.00 Информационный час", "18.35. Налоговое право", "11:10-11:30 – информационный час",
* "17.00 и 18.35 Русский язык". With `loose`, also "20.10", which reads as a date as well.
*/
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|[-–—])/);
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`;
const list = text.match(new RegExp(String.raw`^${T}(?:\s*(?:и|\+|,)\s*${T})*\.?(?=\s|[А-ЯЁа-яё])`, "u"));
if (!list) return null;
const times = [...list[0].matchAll(/([0-2]?\d)([.:])([0-5]\d)/g)];
// One unmistakable time ("18.35+20.10") vouches for the others in the list.
const clear = ([, , sep, m]) => sep === ":" || Number(m) > 12 || m === "00";
if (!loose && !times.some(clear)) return null;
// Lessons run from 7 to 22: "3.12" next to a clear time is still not a time.
if (times.some((t) => Number(t[1]) > 23 || (!clear(t) && (Number(t[1]) < 7 || Number(t[1]) > 21)))) return null;
return { starts: times.map(([, h, , m]) => hm(h, m)), end: null, length: list[0].length };
}
function weekBlocks(rows) {
for (let i = 0; i < Math.min(rows.length, 4); i++) {
const cells = rows[i];
const heads = cells.filter((c) => /^(д\.?\s*н\.?|дни|день)$/i.test(c.text.trim()));
if (!heads.length) continue;
const blocks = heads.map((head, n) => {
const next = heads[n + 1]?.col ?? Infinity;
const time = cells.find((c) => c.col > head.col && c.col < next && /^время/i.test(c.text));
const subject = cells.find(
(c) => c.col > (time?.col ?? next) && c.col < next && /дисциплин/i.test(c.text),
);
return time && subject
? { day: head.col, time: time.col, from: subject.col, to: subject.col + subject.span }
: null;
});
if (blocks.every(Boolean)) return { header: i, blocks };
}
return null;
}
/** "8.30" alone: an exam's start without an end. */
function startOnly(value) {
const m = value.trim().match(/^([0-2]?\d)[.:]([0-5]\d)$/);
return m ? { start: `${m[1].padStart(2, "0")}:${m[2]}`, end: null, original: value } : null;
}
const isoDate = (m) => `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer);
const lessons = [],
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
places = [],
// Subjects that parts of a group choose between: the second foreign language, an elective.
choices = [],
groups = new Set(),
warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
const academicYear =
String(source.year || "").match(/^(20\d{2})/)?.[1] ||
document.text.match(/(20\d{2})\s*[/–-]\s*20\d{2}/)?.[1];
// Text dates like "15 октября" need the autumn year; fall back to the file date.
const modified = source.modified ? new Date(source.modified) : null;
const yearStart =
academicYear ||
(modified && Number.isFinite(modified.getTime())
? modified.getUTCFullYear() - (modified.getUTCMonth() < 7 ? 1 : 0)
: null);
let seenRows = 0,
skippedRows = 0;
// "Расписание зачетов": every row without its own label is a credit.
const credits = /расписание\s+зач[её]тов/i.test(document.text);
for (const { rows } of document.tables) {
let ranges = [],
header = -1;
for (let i = 0; i < rows.length; i++) {
const found = rows[i].flatMap((cell) =>
groupsIn(cell.text).map((group) => ({
group,
col: cell.col,
end: cell.col + cell.span,
})),
);
if (
found.length &&
rows[i].some((cell) => /^(дни|день|часы|пара|время)/i.test(cell.text))
) {
ranges = found;
header = i;
break;
}
}
if (header < 0) {
const layout = weekBlocks(rows);
const named = groupsIn(document.text.match(/групп[аы]\s+([^\n]+)/i)?.[1] || "");
if (!layout || !named.length) continue;
named.forEach((g) => groups.add(g));
for (const block of layout.blocks) {
let day = null,
date = null,
time = null;
for (let i = layout.header + 1; i < rows.length; i++) {
const row = rows[i];
const dayText = row.find((c) => c.col === block.day)?.text.toLowerCase() || "";
const weekday = days.findIndex((d) => dayText.startsWith(d));
if (weekday >= 0) {
day = weekday + 1;
const m = dayText.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/);
date = m ? isoDate(m) : null;
time = null;
}
if (!day) continue;
// A merged time cell keeps the time above; an empty one ends it.
const timeCell = row.find((c) => c.col === block.time);
if (timeCell?.text) time = timesIn(timeCell.text) || startOnly(timeCell.text);
else if (timeCell?.merge !== "continue") time = null;
if (!time) continue;
seenRows++;
let count = 0;
for (const cell of row) {
if (cell.col < block.from || cell.col >= block.to) continue;
if (cell.merge === "continue" || !cell.text) continue;
// The whole width is the group; the left or right part is one subgroup.
const whole = cell.col <= block.from && cell.col + cell.span >= block.to;
const label = `Подгруппа ${cell.col <= block.from ? 1 : 2}`;
lessons.push({
id: createHash("sha256")
.update([source.name, i, cell.col, cell.text].join("|"))
.digest("hex")
.slice(0, 16),
day,
date,
pair: null,
...time,
...details(cell.text),
groups: named,
subgroups: whole ? [] : named.map((group) => ({ group, label })),
variant: "all",
weekRanges: null,
dates: [],
sourceColor: null,
raw: cell.text,
});
count++;
}
if (!count) skippedRows++;
}
}
continue;
}
ranges.forEach((r) => groups.add(r.group));
let day = null,
date = null,
time = null,
pair = null,
timeCol = null,
// The day's first lesson time: a town note further down holds from its own row on.
dayStart = null;
// Session schedules use separate room, subject and teacher columns.
const session = rows[header].some((cell) => /пара/i.test(cell.text));
let sessionColumns = null;
if (session) {
const columns = rows.slice(header, header + 3).flat();
const room = columns.find((c) => /^ауд/i.test(c.text));
const subject = columns.find((c) => /название|дисциплин/i.test(c.text));
const teacher = columns.find((c) => /фамил|преподавател/i.test(c.text));
sessionColumns =
room && subject
? { room: room.col, subject: subject.col, teacher: teacher?.col }
: null;
}
for (let i = header + 1; i < rows.length; i++) {
const row = rows[i];
// "Понедельник 12.01.2026", or date first as in credit schedules: "27.10.2026 ⏎ вторник".
const weekdayOf = (text) => {
const t = text.toLowerCase().replace(/^\d{1,2}[.]\d{1,2}[.]\d{4}\s*(г\.?)?\s*/, "");
return days.findIndex((d) => t.startsWith(d)) + 1;
};
const dayCell = row.find((c) => c.col < ranges[0].col && weekdayOf(c.text));
if (dayCell) {
day = weekdayOf(dayCell.text);
const m = dayCell.text.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/);
date = m
? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`
: null;
time = null;
pair = null;
dayStart = null;
}
if (!day) continue;
// Credits and exams give only the start ("8.30"); the bell table supplies the end.
const timeCell = row.find(
(c) => c.col < ranges[0].col && (timesIn(c.text) || startOnly(c.text)),
);
if (timeCell) {
time = timesIn(timeCell.text) || startOnly(timeCell.text);
timeCol = timeCell.col;
pair = session ? row.find((c) => c.col === 1)?.text : null;
}
if (!time) continue;
dayStart ??= time.start;
seenRows++;
const continuedTime =
row.find((c) => c.col === timeCol)?.merge === "continue";
const pairedTime =
timeCell?.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === timeCol)?.merge === "continue";
const variant = continuedTime ? "lower" : pairedTime ? "upper" : "all";
const emit = (cell, targetGroups, raw, overrides = {}) => {
if (!raw.trim()) return;
const spansBoth =
cell.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
const columnSubgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
// "Занятия в Полоцке" over the day's first row is no lesson: the column's lessons that day
// are in that town. A lesson may follow it in the same cell.
const note = placeNote(raw);
if (note) {
// "Занятия в Полоцке" in the 13:40 row of 25-ИТ: the morning is in Novopolotsk.
const from = time.start > dayStart ? time.start : null;
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text, ...(from && { from }) });
raw = note.rest;
if (!raw.trim()) return;
}
for (const { text: whole, main, plan, choice, audience } of entries(raw)) {
if (choice) choices.push({ groups: targetGroups, options: choice });
// "24-КФ: Русский язык как иностранный": one of the cell's groups.
let alternative = main;
let lessonGroups = targetGroups;
const named = main.match(/^\s*([^:\n]{4,24}):\s+(?=\S)/u);
const only = named ? groupsIn(named[1]) : [];
if (only.length && only.every((g) => targetGroups.includes(g))) {
lessonGroups = only;
alternative = main.slice(named[0].length);
}
// "Единый день информирования 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) => {
// "(для иностранных студентов)" is who the lesson is for, no part of its name or room.
const plain = text.replace(FOR_FOREIGN, " ").replace(NOT_FOREIGN, " ");
const data = details(roomMoves(plain).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
if (credits && data.type === "Занятие") data.type = "Зачёт";
return data;
};
let data = read(alternative);
// "20.10" is 20 October or the eighth pair at 20:10. A date on another weekday than
// the row, or followed by a week mark, is the time.
let lead = leadingTime(alternative) || leadingDate(alternative, yearStart);
if (lead?.date) {
const after = alternative.slice(lead.length).trim();
const time = leadingTime(alternative, true);
if (time && (weekday(lead.date) !== day || WEEK_MARK.test(after))) lead = time;
}
const oneDate = lead?.date || null;
if (lead) {
// The rest of the cell is an ordinary entry: "17.00 1 нед. Математика (пр.) …".
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
if (oneDate) data.condition = lead.text;
}
const subgroups = [...columnSubgroups];
// 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({
id: createHash("sha256")
.update([source.name, i, cell.col, alternative, n || ""].join("|"))
.digest("hex")
.slice(0, 16),
day,
date: date || oneDate,
pair: pair || null,
...time,
...(start && { start, end: lead.end }),
...data,
...overrides,
groups: lessonGroups,
subgroups,
variant: spansBoth ? "all" : variant,
weekRanges:
date || oneDate ? null : parseWeekRanges(data.condition),
dates:
date || oneDate || data.condition
? []
: parseTextDates(alternative, yearStart),
sourceColor:
cell.color &&
!["auto", "FFFFFF"].includes(cell.color.toUpperCase())
? cell.color
: null,
raw: whole,
...(choice && { option: data.subject }),
// Groups of foreign students alone ("25-ДОи") have Russian as everyone's lesson.
...((audience || audienceOf(whole)) &&
!lessonGroups.every(FOREIGN_GROUP.test.bind(FOREIGN_GROUP)) && { audience: audience || audienceOf(whole) }),
// Rooms and teachers by week, or by the week's colour.
...(plan.length && { plan }),
});
}
};
if (sessionColumns) {
const cell = row.find((c) => c.col === sessionColumns.subject);
if (!cell || cell.merge === "continue" || !cell.text) continue;
const room = row.find((c) => c.col === sessionColumns.room)?.text || "";
const teacher =
row.find((c) => c.col === sessionColumns.teacher)?.text || "";
emit(
cell,
ranges.map((r) => r.group),
[cell.text, teacher, room].filter(Boolean).join("\n"),
{ room, teacher },
);
} else {
let count = 0;
for (const cell of row) {
if (
cell.merge === "continue" ||
!cell.text ||
cell.col < ranges[0].col
)
continue;
const targetGroups = ranges
.filter((r) => cell.col < r.end && cell.col + cell.span > r.col)
.map((r) => r.group);
if (!targetGroups.length) continue;
emit(cell, targetGroups, cell.text);
count++;
}
if (!count) skippedRows++;
}
}
}
if (!lessons.length)
throw new Error(
"Таблица расписания не распознана. Нужны названия дней, время и заголовки групп. Исходный файл не изменён.",
);
if (lessons.some((l) => l.variant !== "all"))
warnings.add(
"Белые строки относятся к нечётным неделям, зелёные — к чётным, по таблице недель университета. Цвет строк сохранён как в Word.",
);
if (
lessons.some(
(l) =>
l.condition ||
/нед|\d\s*н[.\s]|сентября|октября|ноября|декабря/i.test(l.raw),
)
)
warnings.add(
"Номера недель и даты рассчитаны по таблице недель университета. Исходная пометка сохранена в карточке; при сомнениях сверяйте её с Word.",
);
if (lessons.some((l) => l.subgroups.length))
warnings.add(
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
);
// A lesson on a day the group spends in another town is there too, unless its cell says otherwise.
const labels = new Map();
for (const l of lessons)
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
// The chosen subject on its own in a cell ("Немецкий язык" on Thursday) is the same choice:
// only that part of the group has it.
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
const sets = [];
for (const c of choices) {
const key = c.groups.join("|") + "#" + c.options.map(norm).sort().join("|");
if (!sets.some((s) => s.key === key)) sets.push({ key, groups: c.groups, options: c.options });
}
for (const lesson of lessons) {
if (lesson.option || lesson.subgroups.length) continue;
const set = sets.find((s) => s.groups.some((g) => lesson.groups.includes(g)) && s.options.some((o) => norm(o) === norm(lesson.subject)));
if (set) lesson.option = set.options.find((o) => norm(o) === norm(lesson.subject));
}
for (const lesson of lessons) {
if (lesson.place || !places.length) continue;
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
if (place) lesson.place = place;
}
return {
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
groups: [...groups],
lessons: lessons.sort(
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
),
...(places.length && { places }),
...(sets.length && { choices: sets.map(({ groups, options }) => ({ groups, options })) }),
notes: document.notes,
warnings: [...warnings],
source: {
faculty:
source.faculty ||
[
[/финансово-экономическ/iu, "Финансово-экономический факультет"],
[
/факультета информационных технологий/iu,
"Факультет информационных технологий",
],
[
/факультета компьютерных наук и электроники/iu,
"Факультет компьютерных наук и электроники",
],
[/гуманитарного факультета/iu, "Гуманитарный факультет"],
[/инженерно-строительн/iu, "Инженерно-строительный факультет"],
[/механико-технологическ/iu, "Механико-технологический факультет"],
[/юридического факультета/iu, "Юридический факультет"],
].find(([pattern]) => pattern.test(document.text))?.[1],
course:
source.course ||
(document.text.match(/студентов\s+(\d)\s+курса/iu)?.[1]
? `${document.text.match(/студентов\s+(\d)\s+курса/iu)[1]} курс`
: undefined),
...source,
importedAt: new Date().toISOString(),
},
schemaVersion: PARSER_VERSION,
// Start year of the academic year printed in Word ("2026/2027" → "2026").
academicYear: academicYear || (yearStart ? String(yearStart) : null),
stats: {
lessons: lessons.length,
groups: groups.size,
rows: seenRows,
emptyRows: skippedRows,
},
format: "docx",
period: /лабораторно-экзаменационн/i.test(document.text)
? "Лабораторно-экзаменационная сессия"
: credits
? "Зачёты"
: /экзаменационн/i.test(document.text)
? "Экзаменационная сессия"
: /установочн/i.test(document.text)
? "Установочная сессия"
: /осенний семестр/i.test(document.text)
? "Осенний семестр"
: /весенний семестр/i.test(document.text)
? "Весенний семестр"
: "Расписание из Word",
};
}
export async function parseWord(buffer, source = {}) {
if (buffer.subarray(0, 2).toString() === "PK")
return parseDocx(buffer, source);
if (buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1")
throw new Error("Выберите настоящий файл Word (.docx или .doc).");
const dir = await mkdtemp(join(tmpdir(), "psu-word-"));
try {
const input = join(dir, "schedule.doc"),
output = join(dir, "schedule.docx");
await writeFile(input, buffer);
await run(
process.env.LIBREOFFICE_BIN || "soffice",
[
"-env:UserInstallation=file://" + join(dir, "profile"),
"--headless",
"--convert-to",
"docx",
"--outdir",
dir,
input,
],
{ timeout: 30000 },
);
const parsed = parseDocx(await readFile(output), source);
parsed.format = "doc";
parsed.warnings.unshift(
"Старый .doc преобразован в .docx. Проверьте таблицу и условия по оригиналу.",
);
return parsed;
} catch (error) {
if (error.code === "ENOENT")
throw new Error(
"Для старых .doc на сервере нужен LibreOffice. Можно сохранить документ как .docx в Word и импортировать его.",
);
throw error;
} finally {
await rm(dir, { recursive: true, force: true });
}
}