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 } from "../shared/weeks.js"; const run = promisify(execFile); export const PARSER_VERSION = 7; const days = [ "понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье", ]; const clean = (value) => value .replace(/\u00a0/g, " ") .replace(/[\t ]+/g, " ") .replace(/\s*\n\s*/g, "\n") .trim(); const groupsIn = (value) => [ ...value.matchAll( /\b\d{2}\s*[-–]?\s*[А-ЯЁа-яё]{2,8}(?:\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; } function conditionIn(value) { const m = value.match( /^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)(?:\s*\.)?\s*/i, ); 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.:–—-]*/i, ); if (leading) rest = rest.slice(leading[0].length); const kind = rest.match( /\((л\.?\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)), }; } 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; 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) 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]; const dayCell = row.find((c) => days.some((d) => c.text.toLowerCase().startsWith(d)), ); if (dayCell) { day = days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1; 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; const timeCell = row.find( (c) => c.col < ranges[0].col && timesIn(c.text), ); if (timeCell) { time = timesIn(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)) { const data = details(alternative); const dateCandidate = alternative.match( /^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/, ); const singleDate = dateCandidate && Number(dateCandidate[1]) >= 1 && Number(dateCandidate[1]) <= 31 && Number(dateCandidate[2]) >= 1 && Number(dateCandidate[2]) <= 12 ? dateCandidate : null; const explicitTime = alternative.match( /^([0-2]?\d)([.:])([0-5]\d)\s+/, ); const ownTime = explicitTime && Number(explicitTime[1]) <= 23 && (explicitTime[2] === ":" || Number(explicitTime[3]) > 12) ? `${explicitTime[1].padStart(2, "0")}:${explicitTime[3]}` : null; const oneDate = singleDate && (singleDate[3] || academicYear) ? `${singleDate[3] || academicYear}-${singleDate[2]}-${singleDate[1]}` : null; if (singleDate) { data.condition = singleDate[0].trim(); data.subject = details( alternative.slice(singleDate[0].length), ).subject; } if (ownTime) { data.subject = details( alternative.slice(explicitTime[0].length), ).subject; data.condition = ""; } 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}`, })); const id = createHash("sha256") .update([source.name, i, cell.col, alternative].join("|")) .digest("hex") .slice(0, 16); lessons.push({ id, day, date: date || oneDate, pair: pair || null, ...time, ...(ownTime ? { start: ownTime, end: null } : {}), ...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) ? "Экзаменационная сессия" : /установочн/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 }); } }