initial commit
This commit is contained in:
59
server/calendar.js
Normal file
59
server/calendar.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readDocx } from "./parser.js";
|
||||
|
||||
const toIso = (d, m, y) =>
|
||||
`${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
const time = (h, m) => `${h.padStart(2, "0")}:${m}`;
|
||||
|
||||
// "Расписание белых и зеленых недель.docx": pairs of cells "номер | дд.мм.гггг-дд.мм.гггг".
|
||||
export function parseWeeksDocx(buffer) {
|
||||
const { tables } = readDocx(buffer);
|
||||
const weeks = [];
|
||||
for (const { rows } of tables) {
|
||||
const header = rows[0] || [];
|
||||
const colorOf = (col) => {
|
||||
const label = header.find((c) => c.col === col)?.text.toLowerCase() || "";
|
||||
return /зел[её]н/.test(label) ? "green" : /бел/.test(label) ? "white" : null;
|
||||
};
|
||||
for (const row of rows)
|
||||
row.forEach((cell, i) => {
|
||||
const range = cell.text.match(
|
||||
/(\d{1,2})\.(\d{1,2})\.(\d{4})\s*[-–]\s*(\d{1,2})\.(\d{1,2})\.(\d{4})/,
|
||||
);
|
||||
const n = Number(row[i - 1]?.text.match(/^\d{1,2}$/)?.[0]);
|
||||
if (!range || !n) return;
|
||||
weeks.push({
|
||||
n,
|
||||
start: toIso(range[1], range[2], range[3]),
|
||||
end: toIso(range[4], range[5], range[6]),
|
||||
color: colorOf(cell.col) || (n % 2 ? "white" : "green"),
|
||||
});
|
||||
});
|
||||
}
|
||||
weeks.sort((a, b) => a.start.localeCompare(b.start));
|
||||
if (!weeks.length)
|
||||
throw new Error("Таблица недель не распознана.");
|
||||
return weeks;
|
||||
}
|
||||
|
||||
// "Расписание звонков.docx": "1 пара | 8.30 – 9.10 9.15 – 9.55".
|
||||
export function parseBellsDocx(buffer) {
|
||||
const { tables } = readDocx(buffer);
|
||||
const bells = [];
|
||||
for (const { rows } of tables)
|
||||
for (const row of rows) {
|
||||
const pair = Number(row[0]?.text.match(/^(\d)\s*пара/i)?.[1]);
|
||||
const times = [
|
||||
...row
|
||||
.slice(1)
|
||||
.map((c) => c.text)
|
||||
.join(" ")
|
||||
.matchAll(/([0-2]?\d)[.:]([0-5]\d)/g),
|
||||
].map((m) => time(m[1], m[2]));
|
||||
if (!pair || times.length < 2) continue;
|
||||
const halves = [];
|
||||
for (let i = 0; i + 1 < times.length; i += 2)
|
||||
halves.push([times[i], times[i + 1]]);
|
||||
bells.push({ pair, start: times[0], end: times.at(-1), halves });
|
||||
}
|
||||
return bells.sort((a, b) => a.pair - b.pair);
|
||||
}
|
||||
456
server/directory.js
Normal file
456
server/directory.js
Normal file
@@ -0,0 +1,456 @@
|
||||
// University-wide directory: every catalog document parsed once and searched
|
||||
// by teacher, room and date. Answers come from the Word files only, so every
|
||||
// response carries its coverage and the caller must present it as such.
|
||||
import { getCatalog, getCalendar } from "./source.js";
|
||||
import { scheduleFor } from "./schedules.js";
|
||||
import {
|
||||
semesterView,
|
||||
lessonDates,
|
||||
todayIso,
|
||||
weekday,
|
||||
weekIndexOf,
|
||||
} from "../shared/weeks.js";
|
||||
|
||||
let index = null,
|
||||
building = null;
|
||||
|
||||
const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu;
|
||||
// "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits.
|
||||
const ROOM_TOKEN = /[А-ЯA-Z]\d-\d{2}|\d{2,4}\/\d{2,4}|\d{2,4}[А-ЯA-Z]{0,2}/gu;
|
||||
const ROOM_TEXT = "[А-ЯA-Z]\\d-\\d{2}|\\d{2,4}(?:\\s?(?!и(?![а-яё]))[А-Яа-я]{1,2}(?![а-яё]))?";
|
||||
// "(13.10-ауд.510н)", "14.10. -ауд. 333", "07.09. ауд. 414", "07.10-203мк": on that date the lesson moves.
|
||||
const OVERRIDE = new RegExp(
|
||||
`(\\d{1,2})\\.(\\d{1,2})\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`,
|
||||
"giu",
|
||||
);
|
||||
const DATE = /\d{1,2}\.\d{1,2}(?:\.\d{2,4})?\.?/g;
|
||||
// Week lists inside a room note: "ауд.205;6,8н.-…", "1-9н.".
|
||||
const WEEKS = /\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*н(?:ед)?\.?|\d{1,2}\s*н(?:ед)?\./giu;
|
||||
const MENTION = /(?:ауд\.?|(?<![А-Яа-яЁё])а\.)\s*([^\n(]{0,40})/giu;
|
||||
const HYPHEN_ROOM = /[А-ЯA-Z]\d-\d{2}/gu;
|
||||
export const normalize = (s) =>
|
||||
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s.]/g, "");
|
||||
export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim();
|
||||
// Word files mix Latin look-alikes into room numbers ("12C" with a Latin C).
|
||||
const LATIN = { A: "А", B: "В", C: "С", E: "Е", H: "Н", K: "К", M: "М", O: "О", P: "Р", T: "Т", X: "Х" };
|
||||
export const roomKey = (s) =>
|
||||
(s || "")
|
||||
.replace(/\s+/g, "")
|
||||
.toUpperCase()
|
||||
.replace(/[ABCEHKMOPTX]/g, (c) => LATIN[c])
|
||||
.replace(/\.$/, "");
|
||||
/** Ordinary numbered rooms only: "129", "207Г". Distance learning "200(ДО)" is not a free room. */
|
||||
export const isPhysicalRoom = (room) =>
|
||||
/^(\d{2,4}[А-ЯA-Z]{0,2}|\d{2,4}\/\d{2,4}|[А-ЯA-Z]\d-\d{2})$/.test(room) &&
|
||||
!/ДО$/.test(room);
|
||||
|
||||
const isoFor = (day, month, year) => {
|
||||
const m = Number(month),
|
||||
y = m >= 8 ? Number(year) : Number(year) + 1;
|
||||
return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
};
|
||||
const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12;
|
||||
const tokens = (text) =>
|
||||
(text || "")
|
||||
.toUpperCase()
|
||||
// "210 мк" → "210МК"; "129 и 130" stays two rooms.
|
||||
.replace(/(\d{2,4})\s+(?!И(?![А-ЯЁ]))([А-ЯЁ]{1,2})(?![А-ЯЁ])/gu, "$1$2")
|
||||
.match(ROOM_TOKEN) || [];
|
||||
|
||||
/**
|
||||
* Rooms in a cell or room field. Dates, week lists and dated moves are taken out
|
||||
* first so "ауд. 432 14.10. -ауд. 333" gives room 432 and a move to 333 on 14 October.
|
||||
* "200 (ДО)" is distance learning and occupies no room.
|
||||
*/
|
||||
export function parseRooms(text, year) {
|
||||
let rest = (text || "").replace(/\u00a0/g, " ");
|
||||
const overrides = [];
|
||||
rest = rest.replace(OVERRIDE, (match, d, m, room) => {
|
||||
if (!validDate(d, m)) return match;
|
||||
if (year) overrides.push({ date: isoFor(d, m, year), room: roomKey(room) });
|
||||
return " ";
|
||||
});
|
||||
// "200 (ДО)" or "200ДО": distance learning.
|
||||
const distance = /\(\s*до\s*\)|\d\s*до(?![а-яё])/i.test(rest);
|
||||
rest = rest.replace(/\([^)]*\)?/g, " ").replace(DATE, " ").replace(WEEKS, " ");
|
||||
return {
|
||||
rooms: distance ? [] : [...new Set(tokens(rest).map(roomKey).filter((r) => !/ДО$/.test(r)))],
|
||||
overrides,
|
||||
distance,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every teacher and room named in a cell, not only the first one the parser picked. */
|
||||
export function peopleAndRooms(lesson, year) {
|
||||
const teachers = new Set();
|
||||
if (lesson.teacher) teachers.add(personName(lesson.teacher));
|
||||
for (const m of (lesson.raw || "").matchAll(PERSON)) teachers.add(personName(m[0]));
|
||||
const cell = parseRooms(lesson.raw, year);
|
||||
const rooms = new Set();
|
||||
// Rooms written after "ауд." / "а." and the "А1-15" style anywhere in the cell.
|
||||
for (const m of cell.rest.matchAll(MENTION)) for (const t of tokens(m[1])) rooms.add(roomKey(t));
|
||||
for (const m of cell.rest.toUpperCase().matchAll(HYPHEN_ROOM)) rooms.add(roomKey(m[0]));
|
||||
// The parser's room field only counts when the cell really names that room
|
||||
// (it can pick "12" out of a date like "17.12" or "05" out of "В0-05").
|
||||
const field = parseRooms(lesson.room, year);
|
||||
const cleanCell = cell.rest.toUpperCase().replace(/\s+/g, "");
|
||||
for (const r of field.rooms)
|
||||
if (
|
||||
![...rooms].some((x) => x.endsWith(`-${r}`)) &&
|
||||
new RegExp(`(^|[^\\dА-ЯA-Z-])${r}(?![\\dА-Я])`, "u").test(cleanCell)
|
||||
)
|
||||
rooms.add(r);
|
||||
const overrides = [...cell.overrides, ...field.overrides].filter(
|
||||
(o, i, all) => all.findIndex((x) => x.date === o.date && x.room === o.room) === i,
|
||||
);
|
||||
return {
|
||||
teachers: [...teachers],
|
||||
rooms: cell.distance || field.distance ? [] : [...rooms],
|
||||
overrides,
|
||||
distance: cell.distance || field.distance,
|
||||
};
|
||||
}
|
||||
/** Rooms a lesson uses on a date: a dated replacement wins over the usual room. */
|
||||
export function roomsOn(entry, date) {
|
||||
const moved = entry.overrides.filter((o) => o.date === date).map((o) => o.room);
|
||||
return moved.length ? moved : entry.rooms;
|
||||
}
|
||||
|
||||
export function buildIndex(docs) {
|
||||
const entries = [];
|
||||
for (const doc of docs)
|
||||
for (const lesson of doc.schedule.lessons)
|
||||
entries.push({
|
||||
doc,
|
||||
lesson,
|
||||
...peopleAndRooms(lesson, doc.schedule.academicYear),
|
||||
});
|
||||
const teachers = new Map(),
|
||||
rooms = new Map();
|
||||
for (const e of entries) {
|
||||
for (const t of e.teachers) {
|
||||
const key = normalize(t),
|
||||
item = teachers.get(key) || { name: t, count: 0, faculties: new Set() };
|
||||
item.count++;
|
||||
if (e.doc.schedule.source.faculty) item.faculties.add(e.doc.schedule.source.faculty);
|
||||
teachers.set(key, item);
|
||||
}
|
||||
for (const r of [...e.rooms, ...e.overrides.map((o) => o.room)])
|
||||
rooms.set(r, (rooms.get(r) || 0) + 1);
|
||||
}
|
||||
return { entries, teachers, rooms };
|
||||
}
|
||||
|
||||
async function build(catalog) {
|
||||
const docs = [],
|
||||
failed = [],
|
||||
queue = [...catalog.files];
|
||||
await Promise.all(
|
||||
Array.from({ length: 4 }, async () => {
|
||||
while (queue.length) {
|
||||
const file = queue.shift();
|
||||
try {
|
||||
docs.push({ id: file.id, file, schedule: await scheduleFor(file) });
|
||||
} catch (error) {
|
||||
failed.push({ name: file.name, error: error.message });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
index = {
|
||||
catalogAt: catalog.updatedAt,
|
||||
year: catalog.year,
|
||||
builtAt: new Date().toISOString(),
|
||||
total: catalog.files.length,
|
||||
docs,
|
||||
failed,
|
||||
...buildIndex(docs),
|
||||
};
|
||||
return index;
|
||||
}
|
||||
|
||||
/** The current index; rebuilt in the background when the catalog changes. */
|
||||
export async function getIndex() {
|
||||
const catalog = await getCatalog();
|
||||
if ((!index || index.catalogAt !== catalog.updatedAt) && !building)
|
||||
building = build(catalog).finally(() => {
|
||||
building = null;
|
||||
});
|
||||
if (!index) await building;
|
||||
return index;
|
||||
}
|
||||
export const indexStatus = () =>
|
||||
index
|
||||
? {
|
||||
documents: index.docs.length,
|
||||
total: index.total,
|
||||
failed: index.failed.length,
|
||||
builtAt: index.builtAt,
|
||||
rebuilding: Boolean(building),
|
||||
}
|
||||
: { documents: 0, rebuilding: Boolean(building) };
|
||||
|
||||
const minutes = (t) => {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a lesson takes place on a date: "yes", "no", "maybe" (the week mark
|
||||
* is unreadable) or "unknown" (the document's semester is not in the week table).
|
||||
*/
|
||||
export function occurrence(entry, date, calendar, views = new Map()) {
|
||||
const { lesson, doc } = entry;
|
||||
if (lesson.date) return lesson.date === date ? "yes" : "no";
|
||||
if (lesson.dates?.length) return lesson.dates.includes(date) ? "yes" : "no";
|
||||
if (lesson.day !== weekday(date)) return "no";
|
||||
const key = `${doc.id}|${date}`;
|
||||
if (!views.has(key)) views.set(key, semesterView(calendar, doc.schedule, date));
|
||||
const view = views.get(key);
|
||||
if (!view) return "unknown";
|
||||
const dates = lessonDates(lesson, view);
|
||||
if (dates) return dates.includes(date) ? "yes" : "no";
|
||||
const week = view.weeks[weekIndexOf(view, date)];
|
||||
if (week && lesson.variant !== "all" && (week.color === "white") !== (lesson.variant === "upper"))
|
||||
return "no";
|
||||
return week ? "maybe" : "no";
|
||||
}
|
||||
|
||||
function coverage(idx, unknownDocs) {
|
||||
return {
|
||||
documents: idx.docs.length - unknownDocs.size,
|
||||
total: idx.total,
|
||||
failed: idx.failed.map((f) => f.name),
|
||||
withoutSemester: [...unknownDocs].map((id) => idx.docs.find((d) => d.id === id)?.file.name).filter(Boolean),
|
||||
year: idx.year,
|
||||
};
|
||||
}
|
||||
|
||||
export function search(idx, query) {
|
||||
const q = normalize(query);
|
||||
if (q.length < 2) return { teachers: [], rooms: [] };
|
||||
const teachers = [...idx.teachers.entries()]
|
||||
.filter(([key]) => key.includes(q))
|
||||
.map(([, t]) => ({ name: t.name, count: t.count, faculties: [...t.faculties] }))
|
||||
.sort((a, b) => Number(!normalize(a.name).startsWith(q)) - Number(!normalize(b.name).startsWith(q)) || b.count - a.count)
|
||||
.slice(0, 20);
|
||||
const roomQuery = roomKey(query);
|
||||
const rooms = [...idx.rooms.entries()]
|
||||
.filter(([room]) => room.startsWith(roomQuery))
|
||||
.map(([room, count]) => ({ room, count }))
|
||||
.sort((a, b) => a.room.localeCompare(b.room, "ru", { numeric: true }))
|
||||
.slice(0, 20);
|
||||
return { teachers, rooms };
|
||||
}
|
||||
|
||||
/** One teacher's or room's lessons on a date, merged across groups and documents. */
|
||||
export function dayOf(idx, calendar, { teacher, room }, date) {
|
||||
const views = new Map(),
|
||||
unknownDocs = new Set();
|
||||
const want = teacher ? normalize(teacher) : null,
|
||||
wantRoom = room ? roomKey(room) : null;
|
||||
const merged = new Map();
|
||||
for (const entry of idx.entries) {
|
||||
if (want && !entry.teachers.some((t) => normalize(t) === want)) continue;
|
||||
if (
|
||||
wantRoom &&
|
||||
!entry.rooms.includes(wantRoom) &&
|
||||
!entry.overrides.some((o) => o.room === wantRoom)
|
||||
)
|
||||
continue;
|
||||
const status = occurrence(entry, date, calendar, views);
|
||||
if (status === "unknown") unknownDocs.add(entry.doc.id);
|
||||
if (status !== "yes" && status !== "maybe") continue;
|
||||
const rooms = roomsOn(entry, date);
|
||||
if (wantRoom && !rooms.includes(wantRoom)) continue;
|
||||
const l = entry.lesson;
|
||||
const key = [l.start, normalize(l.subject), rooms.join(","), entry.teachers.join(",")].join("|");
|
||||
const item = merged.get(key) || {
|
||||
start: l.start,
|
||||
end: l.end,
|
||||
subject: l.subject,
|
||||
type: l.type,
|
||||
teachers: entry.teachers,
|
||||
rooms,
|
||||
moved: rooms !== entry.rooms,
|
||||
distance: entry.distance,
|
||||
groups: [],
|
||||
documents: [],
|
||||
condition: l.condition,
|
||||
uncertain: false,
|
||||
};
|
||||
for (const g of l.groups) if (!item.groups.includes(g)) item.groups.push(g);
|
||||
if (!item.documents.some((d) => d.id === entry.doc.id))
|
||||
item.documents.push({ id: entry.doc.id, name: entry.doc.file.name });
|
||||
if (status === "maybe") item.uncertain = true;
|
||||
merged.set(key, item);
|
||||
}
|
||||
return {
|
||||
date,
|
||||
lessons: [...merged.values()].sort((a, b) => a.start.localeCompare(b.start)),
|
||||
coverage: coverage(idx, unknownDocs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rooms with no lesson overlapping a bell pair on a date, by the published schedules. */
|
||||
export function freeRooms(idx, calendar, date, pair) {
|
||||
const bell = calendar?.bells.find((b) => b.pair === pair);
|
||||
if (!bell) return null;
|
||||
const from = minutes(bell.start),
|
||||
to = minutes(bell.end);
|
||||
const views = new Map(),
|
||||
unknownDocs = new Set(),
|
||||
busy = new Map(),
|
||||
maybe = new Map();
|
||||
const endOf = (l) =>
|
||||
l.end ? minutes(l.end) : minutes(calendar.bells.find((b) => b.start === l.start)?.end || l.start) || minutes(l.start) + 80;
|
||||
for (const entry of idx.entries) {
|
||||
const rooms = roomsOn(entry, date).filter(isPhysicalRoom);
|
||||
if (!rooms.length) continue;
|
||||
const l = entry.lesson,
|
||||
start = minutes(l.start),
|
||||
end = Math.max(endOf(l), start + 45);
|
||||
if (end <= from || start >= to) continue;
|
||||
const status = occurrence(entry, date, calendar, views);
|
||||
if (status === "unknown") unknownDocs.add(entry.doc.id);
|
||||
const target = status === "yes" ? busy : status === "maybe" ? maybe : null;
|
||||
if (!target) continue;
|
||||
for (const r of rooms)
|
||||
target.set(r, [...(target.get(r) || []), { subject: l.subject, groups: l.groups, condition: l.condition }]);
|
||||
}
|
||||
const all = [...idx.rooms.keys()].filter(isPhysicalRoom);
|
||||
const free = all.filter((r) => !busy.has(r) && !maybe.has(r)).sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
|
||||
const groups = new Map();
|
||||
for (const r of free) {
|
||||
const letter = r.match(/[А-ЯA-Z]+$/)?.[0] || r.match(/^[А-ЯA-Z]/)?.[0] || "";
|
||||
groups.set(letter, [...(groups.get(letter) || []), r]);
|
||||
}
|
||||
return {
|
||||
date,
|
||||
pair,
|
||||
bell,
|
||||
free: [...groups.entries()]
|
||||
.sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b, "ru")))
|
||||
.map(([letter, list]) => ({ letter, rooms: list })),
|
||||
freeCount: free.length,
|
||||
busyCount: busy.size,
|
||||
maybe: [...maybe.entries()].filter(([r]) => !busy.has(r)).map(([room, lessons]) => ({ room, lessons })),
|
||||
knownRooms: all.length,
|
||||
coverage: coverage(idx, unknownDocs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function directoryCalendar() {
|
||||
try {
|
||||
return await getCalendar();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export { todayIso };
|
||||
|
||||
export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация"];
|
||||
/** Dated exams, credits and consultations of a group across every document. */
|
||||
export function examsOf(idx, group, faculty) {
|
||||
const seen = new Map();
|
||||
for (const { doc, lesson, teachers, rooms } of idx.entries) {
|
||||
if (!EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue;
|
||||
if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue;
|
||||
const dates = lesson.date ? [lesson.date] : lesson.dates || [];
|
||||
for (const date of dates) {
|
||||
const key = [date, lesson.start, normalize(lesson.subject), lesson.type].join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.set(key, {
|
||||
date,
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
type: lesson.type,
|
||||
subject: lesson.subject,
|
||||
teachers,
|
||||
rooms,
|
||||
document: { id: doc.id, name: doc.file.name, period: doc.schedule.period },
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...seen.values()].sort(
|
||||
(a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start),
|
||||
);
|
||||
}
|
||||
|
||||
/** "23-БА", "23 ба" and "23ба" are the same group for searching. */
|
||||
export const groupKey = (s) =>
|
||||
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s\-–_.]/g, "");
|
||||
|
||||
/** Every group of every document, with the catalog place it came from. */
|
||||
export function groupEntries(idx) {
|
||||
const list = [];
|
||||
for (const { file, schedule } of idx.docs)
|
||||
for (const group of schedule.groups)
|
||||
list.push({
|
||||
group,
|
||||
key: groupKey(group),
|
||||
fileId: file.id,
|
||||
file: file.name,
|
||||
faculty: file.faculty || schedule.source.faculty || "",
|
||||
course: file.course || schedule.source.course || "",
|
||||
form: file.form || "",
|
||||
period: schedule.period || "",
|
||||
modified: file.modified || null,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
const count = (items, pick) => {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
const value = pick(item);
|
||||
if (value) map.set(value, (map.get(value) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([name, n]) => ({ name, count: n }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "ru", { numeric: true }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups matching a query and/or catalog filters. The query ignores filters on
|
||||
* purpose: a student typing their group must find it in any faculty.
|
||||
*/
|
||||
export function findGroups(idx, { q = "", faculty = "", course = "", form = "" } = {}) {
|
||||
const all = groupEntries(idx);
|
||||
const key = groupKey(q);
|
||||
let results;
|
||||
if (key.length >= 2) {
|
||||
const rank = (e) => (e.key === key ? 0 : e.key.startsWith(key) ? 1 : 2);
|
||||
results = all
|
||||
.filter((e) => e.key.includes(key))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
a.group.localeCompare(b.group, "ru", { numeric: true }) ||
|
||||
a.period.localeCompare(b.period, "ru"),
|
||||
)
|
||||
.slice(0, 50);
|
||||
} else if (faculty || course || form) {
|
||||
results = all
|
||||
.filter(
|
||||
(e) =>
|
||||
(!faculty || e.faculty === faculty) &&
|
||||
(!course || e.course === course) &&
|
||||
(!form || e.form === form),
|
||||
)
|
||||
.sort((a, b) => a.group.localeCompare(b.group, "ru", { numeric: true }))
|
||||
.slice(0, 300);
|
||||
} else results = [];
|
||||
const inFaculty = all.filter((e) => !faculty || e.faculty === faculty);
|
||||
return {
|
||||
results,
|
||||
facets: {
|
||||
faculties: count(all, (e) => e.faculty),
|
||||
courses: count(inFaculty, (e) => e.course),
|
||||
forms: count(inFaculty, (e) => e.form),
|
||||
},
|
||||
total: all.length,
|
||||
year: idx.year,
|
||||
};
|
||||
}
|
||||
186
server/index.js
Normal file
186
server/index.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import express from "express";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import multer from "multer";
|
||||
import { parseWord, PARSER_VERSION } from "./parser.js";
|
||||
import { scheduleFor } from "./schedules.js";
|
||||
import {
|
||||
getIndex,
|
||||
indexStatus,
|
||||
search,
|
||||
dayOf,
|
||||
freeRooms,
|
||||
examsOf,
|
||||
findGroups,
|
||||
directoryCalendar,
|
||||
todayIso,
|
||||
} from "./directory.js";
|
||||
import {
|
||||
getCatalog,
|
||||
getCalendar,
|
||||
documentFor,
|
||||
request,
|
||||
sourceStatus,
|
||||
} from "./source.js";
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use("/api", (_req, res, next) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
// Liveness plus what the server currently reads from the university, without network calls.
|
||||
app.get("/api/health", async (_req, res) => {
|
||||
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
|
||||
res.json({
|
||||
ok: true,
|
||||
parserVersion: PARSER_VERSION,
|
||||
...sources,
|
||||
directory: indexStatus(),
|
||||
});
|
||||
});
|
||||
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
|
||||
const badRequest = (res, error) => res.status(400).json({ error });
|
||||
// University-wide lookups over every catalog document.
|
||||
app.get("/api/directory/search", async (req, res, next) => {
|
||||
try {
|
||||
res.json(search(await getIndex(), String(req.query.q || "").slice(0, 60)));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/directory/day", async (req, res, next) => {
|
||||
try {
|
||||
const date = req.query.date || todayIso();
|
||||
const teacher = String(req.query.teacher || "").slice(0, 80),
|
||||
room = String(req.query.room || "").slice(0, 20);
|
||||
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
|
||||
if (!teacher && !room) return badRequest(res, "Укажите преподавателя или аудиторию.");
|
||||
res.json(dayOf(await getIndex(), await directoryCalendar(), { teacher, room }, date));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/directory/free", async (req, res, next) => {
|
||||
try {
|
||||
const date = req.query.date || todayIso(),
|
||||
pair = Number(req.query.pair);
|
||||
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
|
||||
const calendar = await directoryCalendar();
|
||||
const result = freeRooms(await getIndex(), calendar, date, pair);
|
||||
if (!result) return badRequest(res, "Нет такой пары в расписании звонков.");
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/groups", async (req, res, next) => {
|
||||
try {
|
||||
const text = (name) => String(req.query[name] || "").slice(0, 120);
|
||||
res.json(
|
||||
findGroups(await getIndex(), {
|
||||
q: text("q"),
|
||||
faculty: text("faculty"),
|
||||
course: text("course"),
|
||||
form: text("form"),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/exams", async (req, res, next) => {
|
||||
try {
|
||||
const group = String(req.query.group || "").slice(0, 40);
|
||||
if (!group) return badRequest(res, "Укажите группу.");
|
||||
res.json({
|
||||
group,
|
||||
exams: examsOf(await getIndex(), group, String(req.query.faculty || "") || null),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/catalog", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getCatalog(req.query.refresh === "1"));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/calendar", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getCalendar(req.query.refresh === "1"));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/schedule/:id", async (req, res, next) => {
|
||||
try {
|
||||
const file = await documentFor(req.params.id);
|
||||
res.json(await scheduleFor(file, { refresh: req.query.refresh === "1" }));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.get("/api/original/:id", async (req, res, next) => {
|
||||
try {
|
||||
const file = await documentFor(req.params.id);
|
||||
res.set("Content-Type", "application/octet-stream");
|
||||
res.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
);
|
||||
res.send(await request(file.path));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
|
||||
});
|
||||
app.post("/api/import", upload.single("file"), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file)
|
||||
return res.status(400).json({ error: "Выберите файл Word." });
|
||||
// Multer decodes multipart names as latin1 by default.
|
||||
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
|
||||
if (!/\.docx?$/i.test(name))
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Поддерживаются файлы .docx и .doc." });
|
||||
res.json(await parseWord(req.file.buffer, { name, uploaded: true }));
|
||||
} catch (e) {
|
||||
e.status = 422;
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
const dist = fileURLToPath(new URL("../dist/", import.meta.url));
|
||||
app.use(express.static(dist, { maxAge: 0 }));
|
||||
app.get("/{*path}", (req, res, next) =>
|
||||
req.path.startsWith("/api/")
|
||||
? res.status(404).json({ error: "Адрес не найден." })
|
||||
: res.sendFile(dist + "index.html", (err) => err && next(err)),
|
||||
);
|
||||
app.use((error, _req, res, _next) => {
|
||||
console.error(error.message);
|
||||
res
|
||||
.status(error.code === "LIMIT_FILE_SIZE" ? 413 : error.status || 503)
|
||||
.json({
|
||||
error:
|
||||
error.code === "LIMIT_FILE_SIZE"
|
||||
? "Файл больше 10 МБ. Выберите документ меньшего размера."
|
||||
: error.status === 422
|
||||
? error.message
|
||||
: error.status === 404
|
||||
? error.message
|
||||
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
|
||||
});
|
||||
});
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
app.listen(port, process.env.HOST || "127.0.0.1", () => {
|
||||
console.log(`API и PWA: http://127.0.0.1:${port}`);
|
||||
// Read the week table and parse the whole catalog in the background,
|
||||
// so the first visit and the first search do not wait for the university.
|
||||
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
|
||||
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
|
||||
});
|
||||
530
server/parser.js
Normal file
530
server/parser.js
Normal file
@@ -0,0 +1,530 @@
|
||||
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(/(?<![а-яё])(экзамен|зач[её]т|консультация)(?![а-яё])/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 });
|
||||
}
|
||||
}
|
||||
34
server/schedules.js
Normal file
34
server/schedules.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseWord, PARSER_VERSION } from "./parser.js";
|
||||
import { request } from "./source.js";
|
||||
|
||||
const data = fileURLToPath(new URL("../.data/parsed/", import.meta.url));
|
||||
|
||||
/** A parsed catalog document, from the disk cache while the file and parser are unchanged. */
|
||||
export async function scheduleFor(file, { refresh = false } = {}) {
|
||||
const cache = data + file.id + ".json";
|
||||
if (!refresh)
|
||||
try {
|
||||
const cached = JSON.parse(await readFile(cache, "utf8"));
|
||||
if (
|
||||
cached.schemaVersion === PARSER_VERSION &&
|
||||
cached.source.modified === file.modified
|
||||
)
|
||||
return cached;
|
||||
} catch {}
|
||||
const buffer = await request(file.path);
|
||||
const parsed = await parseWord(buffer, {
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
faculty: file.faculty,
|
||||
modified: file.modified,
|
||||
fileId: file.id,
|
||||
form: file.form,
|
||||
course: file.course,
|
||||
year: file.year,
|
||||
});
|
||||
await mkdir(data, { recursive: true });
|
||||
await writeFile(cache, JSON.stringify(parsed));
|
||||
return parsed;
|
||||
}
|
||||
400
server/source.js
Normal file
400
server/source.js
Normal file
@@ -0,0 +1,400 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
|
||||
import {
|
||||
academicStartYear,
|
||||
assignSemesters,
|
||||
todayIso,
|
||||
weekIndexOf,
|
||||
} from "../shared/weeks.js";
|
||||
const run = promisify(execFile);
|
||||
const ORIGIN = "https://nextcloud.psu.by";
|
||||
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
|
||||
const YEAR_FOLDER = /^(\d{4})-(\d{4})$/;
|
||||
const DATA = fileURLToPath(new URL("../.data/", import.meta.url));
|
||||
const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false });
|
||||
const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []);
|
||||
const DISCOVERY_TTL = 15 * 60 * 1000,
|
||||
CATALOG_TTL = 15 * 60 * 1000,
|
||||
CALENDAR_TTL = 6 * 60 * 60 * 1000;
|
||||
let catalogue, pending, calendar, calendarPending, sources, sourcesPending;
|
||||
const status = { catalogError: null, calendarError: null };
|
||||
|
||||
export async function request(path, method = "GET") {
|
||||
// Only the university share is reachable; listings and catalog entries supply the paths.
|
||||
if (
|
||||
!path.startsWith(SHARE) ||
|
||||
/[\r\n]/.test(path) ||
|
||||
/(^|\/)\.\.(\/|$)/.test(decodeURIComponent(path)) ||
|
||||
!["GET", "PROPFIND"].includes(method)
|
||||
)
|
||||
throw new Error("Недопустимый путь документа.");
|
||||
const url = ORIGIN + path;
|
||||
// curl also works with the system trust store on macOS. Certificate checks stay enabled.
|
||||
const args = [
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"25",
|
||||
"--max-filesize",
|
||||
"10485760",
|
||||
"--max-redirs",
|
||||
"0",
|
||||
"--request",
|
||||
method,
|
||||
];
|
||||
if (method === "PROPFIND") args.push("--header", "Depth: 1");
|
||||
args.push(url);
|
||||
const { stdout } = await run("curl", args, {
|
||||
encoding: "buffer",
|
||||
maxBuffer: 11 * 1024 * 1024,
|
||||
timeout: 28000,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
export function decodeListing(xml, root = SHARE) {
|
||||
const entries = arr(parser.parse(xml).multistatus?.response);
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const props =
|
||||
arr(entry.propstat).find((p) => p.status?.includes("200"))?.prop || {};
|
||||
return {
|
||||
path: entry.href,
|
||||
folder: props.resourcetype?.collection !== undefined,
|
||||
modified: props.getlastmodified || null,
|
||||
size: Number(props.getcontentlength || 0),
|
||||
};
|
||||
})
|
||||
.filter((e) => typeof e.path === "string" && e.path.startsWith(root));
|
||||
}
|
||||
const nameOf = (path) =>
|
||||
decodeURIComponent(path).replace(/\/$/, "").split("/").at(-1);
|
||||
export const isWeeksTable = (name) =>
|
||||
/\.docx$/i.test(name) &&
|
||||
/бел/i.test(name) &&
|
||||
/зел[её]н/i.test(name) &&
|
||||
/недел/i.test(name);
|
||||
export const isBellSchedule = (name) =>
|
||||
/\.docx$/i.test(name) && /звонк/i.test(name);
|
||||
const isReference = (name) => isWeeksTable(name) || isBellSchedule(name);
|
||||
|
||||
export function fileMetadata(entry) {
|
||||
const pieces = decodeURIComponent(entry.path).slice(SHARE.length).split("/");
|
||||
const year = YEAR_FOLDER.test(pieces[0]) ? pieces.shift() : "";
|
||||
const name = pieces.at(-1);
|
||||
return {
|
||||
...entry,
|
||||
id: createHash("sha256").update(entry.path).digest("hex").slice(0, 24),
|
||||
name,
|
||||
year: year.replace("-", "–"),
|
||||
faculty: pieces[0],
|
||||
form: pieces[1] || "",
|
||||
course: pieces.find((s) => /^\d курс$/.test(s)) || "",
|
||||
section: pieces.slice(2, -1).join(" / "),
|
||||
url: ORIGIN + entry.path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder of the current academic year (from 1 September), else the newest
|
||||
* earlier one, else the oldest later one. Folders look like "2026-2027".
|
||||
*/
|
||||
export function pickYearFolder(names, today = todayIso()) {
|
||||
const target = academicStartYear(today);
|
||||
const years = names
|
||||
.map((name) => ({ name, m: name.match(YEAR_FOLDER) }))
|
||||
.filter(({ m }) => m && Number(m[2]) === Number(m[1]) + 1)
|
||||
.map(({ name, m }) => ({ name, start: Number(m[1]) }))
|
||||
.sort((a, b) => b.start - a.start);
|
||||
const expected = `${target}-${target + 1}`;
|
||||
const pick =
|
||||
years.find((y) => y.start === target) ||
|
||||
years.find((y) => y.start < target) ||
|
||||
years.at(-1);
|
||||
return pick
|
||||
? { name: pick.name, label: pick.name.replace("-", "–"), current: pick.start === target, expected }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Reference documents: newest first, the year folder before the share root. */
|
||||
export function pickReferences(rootEntries, yearEntries = []) {
|
||||
const files = (entries, scope) =>
|
||||
entries
|
||||
.filter((e) => !e.folder)
|
||||
.map((e) => ({ ...e, name: nameOf(e.path), scope }));
|
||||
const all = [...files(yearEntries, "year"), ...files(rootEntries, "root")];
|
||||
const order = (a, b) =>
|
||||
(a.scope === b.scope ? 0 : a.scope === "year" ? -1 : 1) ||
|
||||
new Date(b.modified || 0) - new Date(a.modified || 0);
|
||||
return {
|
||||
weeks: all.filter((f) => isWeeksTable(f.name)).sort(order),
|
||||
bells: all.filter((f) => isBellSchedule(f.name)).sort(order)[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lists the share root and the chosen year folder; cached for 15 minutes. */
|
||||
async function discover(refresh = false) {
|
||||
if (!refresh && sources && Date.now() - sources.checkedAt < DISCOVERY_TTL)
|
||||
return sources;
|
||||
if (!sourcesPending)
|
||||
sourcesPending = (async () => {
|
||||
const root = decodeListing(
|
||||
(await request(SHARE, "PROPFIND")).toString(),
|
||||
).filter((e) => e.path.toLowerCase() !== SHARE.toLowerCase());
|
||||
const year = pickYearFolder(
|
||||
root.filter((e) => e.folder).map((e) => nameOf(e.path)),
|
||||
);
|
||||
if (!year)
|
||||
throw new Error("В каталоге университета нет папки учебного года.");
|
||||
const yearRoot = SHARE + encodeURIComponent(year.name) + "/";
|
||||
const yearEntries = decodeListing(
|
||||
(await request(yearRoot, "PROPFIND")).toString(),
|
||||
).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase());
|
||||
return (sources = {
|
||||
checkedAt: Date.now(),
|
||||
year,
|
||||
yearRoot: root.find((e) => nameOf(e.path) === year.name)?.path || yearRoot,
|
||||
references: pickReferences(root, yearEntries),
|
||||
});
|
||||
})().finally(() => {
|
||||
sourcesPending = null;
|
||||
});
|
||||
return sourcesPending;
|
||||
}
|
||||
|
||||
async function crawl() {
|
||||
const source = await discover(true);
|
||||
let queue = [source.yearRoot],
|
||||
files = [],
|
||||
count = 0;
|
||||
while (queue.length) {
|
||||
if (count + queue.length > 180) throw new Error("Каталог слишком большой.");
|
||||
const batch = queue.splice(0, 5);
|
||||
count += batch.length;
|
||||
const results = await Promise.all(
|
||||
batch.map(async (path) =>
|
||||
decodeListing((await request(path, "PROPFIND")).toString()).filter(
|
||||
(e) => e.path.toLowerCase() !== path.toLowerCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const entries of results)
|
||||
for (const entry of entries) {
|
||||
if (entry.folder) queue.push(entry.path);
|
||||
else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path))
|
||||
files.push(fileMetadata(entry));
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
// Week tables and bell schedules are reference documents, not timetables.
|
||||
files: files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)),
|
||||
otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
year: source.year.label,
|
||||
currentYear: source.year.current,
|
||||
expectedYear: source.year.expected.replace("-", "–"),
|
||||
};
|
||||
await mkdir(DATA, { recursive: true });
|
||||
await writeFile(DATA + "catalog.json", JSON.stringify(result));
|
||||
status.catalogError = null;
|
||||
return (catalogue = result);
|
||||
}
|
||||
async function readCatalogCache() {
|
||||
if (catalogue) return;
|
||||
try {
|
||||
catalogue = JSON.parse(await readFile(DATA + "catalog.json", "utf8"));
|
||||
} catch {
|
||||
// An initial source snapshot can be used during development after an interrupted sync.
|
||||
try {
|
||||
const files = JSON.parse(
|
||||
await readFile(DATA + "source-catalog.json", "utf8"),
|
||||
);
|
||||
catalogue = {
|
||||
files: files
|
||||
.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name))
|
||||
.map(fileMetadata),
|
||||
updatedAt: null,
|
||||
stale: true,
|
||||
year: files[0] ? fileMetadata(files[0]).year : "",
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
export async function getCatalog(refresh = false) {
|
||||
await readCatalogCache();
|
||||
const expected = (() => {
|
||||
const y = academicStartYear(todayIso());
|
||||
return `${y}–${y + 1}`;
|
||||
})();
|
||||
// A cached catalog of last year is refreshed as soon as the new year begins.
|
||||
const fresh =
|
||||
catalogue &&
|
||||
Date.now() - new Date(catalogue.updatedAt).getTime() < CATALOG_TTL &&
|
||||
(catalogue.year === expected || catalogue.currentYear === false);
|
||||
if (!refresh && fresh) return catalogue;
|
||||
if (!pending)
|
||||
pending = crawl()
|
||||
.catch((error) => {
|
||||
status.catalogError = error.message;
|
||||
if (catalogue)
|
||||
return {
|
||||
...catalogue,
|
||||
stale: true,
|
||||
error: "Источник недоступен. Показана сохранённая копия каталога.",
|
||||
};
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
export async function documentFor(id) {
|
||||
const catalog = await getCatalog();
|
||||
const file = catalog.files.find((f) => f.id === id);
|
||||
if (!file) {
|
||||
const error = new Error("Документ не найден в каталоге. Обновите список.");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Merges every week table found (newest wins on overlap) and splits it into semesters. */
|
||||
export function mergeWeekTables(tables) {
|
||||
const byStart = new Map();
|
||||
for (const weeks of tables)
|
||||
for (const week of weeks)
|
||||
if (!byStart.has(week.start)) byStart.set(week.start, week);
|
||||
return assignSemesters([...byStart.values()]);
|
||||
}
|
||||
async function loadCalendar() {
|
||||
const { references } = await discover(true);
|
||||
if (!references.weeks.length)
|
||||
throw new Error("Таблица белых и зелёных недель не найдена в каталоге.");
|
||||
const tables = [];
|
||||
for (const file of references.weeks)
|
||||
try {
|
||||
tables.push(parseWeeksDocx(await request(file.path)));
|
||||
} catch (error) {
|
||||
// One damaged copy must not hide a readable newer or older one.
|
||||
console.error(`${file.name}: ${error.message}`);
|
||||
}
|
||||
if (!tables.length) throw new Error("Таблица недель не распознана.");
|
||||
let bells = [];
|
||||
if (references.bells)
|
||||
try {
|
||||
bells = parseBellsDocx(await request(references.bells.path));
|
||||
} catch (error) {
|
||||
console.error(`${references.bells.name}: ${error.message}`);
|
||||
}
|
||||
const { weeks, semesters } = mergeWeekTables(tables);
|
||||
const result = {
|
||||
weeks,
|
||||
semesters,
|
||||
// Keep the last known bells if the bell schedule became unreadable.
|
||||
bells: bells.length ? bells : calendar?.bells || [],
|
||||
source: {
|
||||
weeks: references.weeks[0].modified,
|
||||
bells: references.bells?.modified || null,
|
||||
weeksFiles: references.weeks.map((f) => f.name),
|
||||
bellsFile: references.bells?.name || null,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
};
|
||||
await mkdir(DATA, { recursive: true });
|
||||
await writeFile(DATA + "calendar.json", JSON.stringify(result));
|
||||
status.calendarError = null;
|
||||
return (calendar = result);
|
||||
}
|
||||
async function readCalendarCache() {
|
||||
if (calendar) return;
|
||||
try {
|
||||
calendar = JSON.parse(await readFile(DATA + "calendar.json", "utf8"));
|
||||
// Caches written before semesters existed are upgraded in place.
|
||||
if (!calendar.semesters) calendar = { ...calendar, ...assignSemesters(calendar.weeks) };
|
||||
} catch {}
|
||||
}
|
||||
export async function getCalendar(refresh = false) {
|
||||
await readCalendarCache();
|
||||
if (
|
||||
!refresh &&
|
||||
calendar &&
|
||||
Date.now() - new Date(calendar.updatedAt).getTime() < CALENDAR_TTL
|
||||
)
|
||||
return calendar;
|
||||
if (!calendarPending)
|
||||
calendarPending = loadCalendar()
|
||||
.catch((error) => {
|
||||
status.calendarError = error.message;
|
||||
if (calendar) return { ...calendar, stale: true, error: error.message };
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
calendarPending = null;
|
||||
});
|
||||
return calendarPending;
|
||||
}
|
||||
|
||||
/** What the server currently uses, for /api/health. Never touches the network. */
|
||||
export async function sourceStatus(today = todayIso()) {
|
||||
await Promise.all([readCatalogCache(), readCalendarCache()]);
|
||||
const warnings = [];
|
||||
const expected = academicStartYear(today);
|
||||
const expectedYear = `${expected}–${expected + 1}`;
|
||||
if (catalogue && catalogue.year !== expectedYear)
|
||||
warnings.push(
|
||||
`Папка учебного года ${expectedYear} не найдена или каталог ещё не обновлён; используется ${catalogue.year || "неизвестный год"}.`,
|
||||
);
|
||||
if (status.catalogError)
|
||||
warnings.push(`Каталог не обновился: ${status.catalogError}`);
|
||||
if (!calendar) warnings.push("Таблица недель ещё не загружена.");
|
||||
else if (weekIndexOf(calendar, today) < 0)
|
||||
warnings.push(
|
||||
"Сегодняшняя дата вне таблицы недель: приложение показывает все строки Word.",
|
||||
);
|
||||
if (status.calendarError)
|
||||
warnings.push(`Таблица недель не обновилась: ${status.calendarError}`);
|
||||
const age = (iso) =>
|
||||
iso ? Math.round((Date.now() - new Date(iso).getTime()) / 60000) : null;
|
||||
return {
|
||||
today,
|
||||
year: sources?.year || (catalogue ? { label: catalogue.year } : null),
|
||||
catalog: catalogue
|
||||
? {
|
||||
year: catalogue.year,
|
||||
files: catalogue.files.length,
|
||||
updatedAt: catalogue.updatedAt,
|
||||
ageMinutes: age(catalogue.updatedAt),
|
||||
stale: Boolean(catalogue.stale || status.catalogError),
|
||||
}
|
||||
: null,
|
||||
calendar: calendar
|
||||
? {
|
||||
weeksFiles: calendar.source.weeksFiles || [],
|
||||
weeksModified: calendar.source.weeks,
|
||||
bellsFile: calendar.source.bellsFile || null,
|
||||
semesters: (calendar.semesters || []).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
weeks: calendar.weeks.filter((w) => w.semester === s.id).length,
|
||||
})),
|
||||
bells: calendar.bells.length,
|
||||
updatedAt: calendar.updatedAt,
|
||||
ageMinutes: age(calendar.updatedAt),
|
||||
coversToday: weekIndexOf(calendar, today) >= 0,
|
||||
stale: Boolean(calendar.stale || status.calendarError),
|
||||
}
|
||||
: null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user