- Created new snapshot files for ISF credits and daily schedules for the 26-АРХ and 26-ПГС programs. - Added summer 2026 and winter 2024 exam schedules for the 23-ИТзс program, detailing lectures, labs, and consultations.
670 lines
28 KiB
JavaScript
670 lines
28 KiB
JavaScript
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";
|
||
const run = promisify(execFile);
|
||
export const PARSER_VERSION = 12;
|
||
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-ДОи", "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.
|
||
export const groupsIn = (value) =>
|
||
[
|
||
...value.matchAll(
|
||
/(?<![\d.,:/])\b\d{2}\s*[-–]?\s*[А-ЯЁ][А-ЯЁа-яё]{1,7}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu,
|
||
),
|
||
].map((m) =>
|
||
m[0]
|
||
.replace(/\s+/g, "")
|
||
.replace(/–/g, "-")
|
||
.replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2"),
|
||
);
|
||
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(raw) {
|
||
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);
|
||
const kind = rest.match(
|
||
/\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
|
||
);
|
||
// 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 =
|
||
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;
|
||
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 === "конс"
|
||
? "Консультация"
|
||
: "Занятие",
|
||
condition,
|
||
};
|
||
}
|
||
// 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);
|
||
}
|
||
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 = [],
|
||
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";
|
||
for (const alternative of alternatives(raw)) {
|
||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject.
|
||
const read = (text) => {
|
||
const data = details(text.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 = 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}`,
|
||
}));
|
||
// "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: alternative,
|
||
});
|
||
}
|
||
};
|
||
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.",
|
||
);
|
||
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),
|
||
),
|
||
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 });
|
||
}
|
||
}
|