- Added WeekGrid composable to visualize weekly timetable with lessons. - Introduced LessonBlock for individual lesson representation. - Created theme file for consistent color schemes across the app. - Developed NextLessonWidget and TodayWidget for home screen integration. - Implemented Upcoming data class to manage upcoming lessons. - Added WidgetStyle for dynamic theming in widgets. - Created SyncWorker for periodic timetable updates and widget refresh.
534 lines
19 KiB
JavaScript
534 lines
19 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 } from "../shared/weeks.js";
|
||
const run = promisify(execFile);
|
||
export const PARSER_VERSION = 8;
|
||
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.:–—-]*/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(/(?<![а-яё])(экзамен|зач[её]т|консультация)(?![а-яё])/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);
|
||
let subject = rest
|
||
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length)
|
||
.trim()
|
||
.replace(/[–—,\-]+$/, "")
|
||
.trim();
|
||
if (!subject) subject = rest;
|
||
const token =
|
||
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
|
||
(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 === "конс"
|
||
? "Консультация"
|
||
: "Занятие",
|
||
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)),
|
||
};
|
||
}
|
||
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 });
|
||
}
|
||
}
|