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 = 14; 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( /(? 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(/(?= 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(/(? 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(/(?:ауд\.?|(? 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 }); } }