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( /(? 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(/(? 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 }); } }