Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-10-19 19:36:19 +03:00
parent 170a44c377
commit 5f7bffd6e4
17 changed files with 454 additions and 85 deletions

View File

@@ -8,7 +8,7 @@ import { join } from "node:path";
import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
const run = promisify(execFile);
export const PARSER_VERSION = 8;
export const PARSER_VERSION = 10;
const days = [
"понедельник",
"вторник",
@@ -62,11 +62,14 @@ export function details(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.:–—-]*/i,
/^(экзамен|зач[её]т|консультация|защита\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);
const kind = rest.match(
/\((л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
/\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
);
// Whole words only: "консультирования" in a subject name is not a consultation.
const sessionWord = (leading?.[1] ||
@@ -75,15 +78,19 @@ export function details(raw) {
const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u);
const room =
rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) ||
rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u);
let subject = rest
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length)
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();
if (!subject) subject = rest;
const defence = /^защита/i.test(leading?.[1] || "");
const token =
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
(defence ? "защ" : undefined) ||
(sessionWord.startsWith("экз")
? "экз"
: sessionWord.startsWith("зач")
@@ -96,7 +103,13 @@ export function details(raw) {
teacher: person?.[0] || "",
room: room?.[1].trim().replace(/\s+/g, "") || "",
type:
token === "лз" || token === "лаб"
token === "устл"
? "Установочная лекция"
: token === "устпз"
? "Установочное занятие"
: token === "защ"
? "Защита курсовой работы"
: token === "лз" || token === "лаб"
? "Лабораторная"
: token === "пз" || token === "пр"
? "Практика"
@@ -205,6 +218,38 @@ export function readDocx(buffer) {
.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.
*/
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 = [],
@@ -223,6 +268,8 @@ export function parseDocx(buffer, source = {}) {
: 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;
@@ -243,7 +290,64 @@ export function parseDocx(buffer, source = {}) {
break;
}
}
if (header < 0) continue;
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,
@@ -265,12 +369,14 @@ export function parseDocx(buffer, source = {}) {
}
for (let i = header + 1; i < rows.length; i++) {
const row = rows[i];
const dayCell = row.find((c) =>
days.some((d) => c.text.toLowerCase().startsWith(d)),
);
// "Понедельник 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 =
days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1;
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")}`
@@ -279,11 +385,12 @@ export function parseDocx(buffer, source = {}) {
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),
(c) => c.col < ranges[0].col && (timesIn(c.text) || startOnly(c.text)),
);
if (timeCell) {
time = timesIn(timeCell.text);
time = timesIn(timeCell.text) || startOnly(timeCell.text);
timeCol = timeCell.col;
pair = session ? row.find((c) => c.col === 1)?.text : null;
}
@@ -302,6 +409,7 @@ export function parseDocx(buffer, source = {}) {
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
for (const alternative of alternatives(raw)) {
const data = details(alternative);
if (credits && data.type === "Занятие") data.type = "Зачёт";
const dateCandidate = alternative.match(
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
);
@@ -481,7 +589,11 @@ export function parseDocx(buffer, source = {}) {
emptyRows: skippedRows,
},
format: "docx",
period: /экзаменационн/i.test(document.text)
period: /лабораторно-экзаменационн/i.test(document.text)
? "Лабораторно-экзаменационная сессия"
: credits
? "Зачёты"
: /экзаменационн/i.test(document.text)
? "Экзаменационная сессия"
: /установочн/i.test(document.text)
? "Установочная сессия"