- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI. - Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately. - Modified CalendarView and PlansView to include session indicators in the calendar and plans. - Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence. - Added new SessionMark component for consistent session representation across Android and iOS. - Updated server logic to handle session entries from external documents, ensuring accurate timetable representation. - Improved date parsing to accommodate session-related dates in various formats. - Added tests to verify session handling and date parsing functionality.
649 lines
25 KiB
JavaScript
649 lines
25 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 } from "../shared/weeks.js";
|
||
const run = promisify(execFile);
|
||
export const PARSER_VERSION = 11;
|
||
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;
|
||
}
|
||
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+курсов(?:ой|ого)\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(
|
||
/\n(?=(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)/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.
|
||
*/
|
||
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 data = details(alternative.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||
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)
|
||
? "Лабораторно-экзаменационная сессия"
|
||
: 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 });
|
||
}
|
||
}
|