Files
psu-schedule/server/parser.js

803 lines
36 KiB
JavaScript
Raw 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 = 15;
const days = [
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
];
const clean = (value) =>
value
.replace(/\u00a0/g, " ")
.replace(/[\t ]+/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);
let subject = (named ? named[1].trim() : rest
.slice(0, kind?.index ?? person?.index ?? 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])}`];
}
/**
* 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;
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 = [],
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;
// 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;
}
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;
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) {
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
raw = note.rest;
if (!raw.trim()) return;
}
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(roomMoves(text).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: targetGroups,
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,
// 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]));
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 }),
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 });
}
}