Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { readDocx } from "./parser.js";
|
||||
import { apiError } from "./errors.js";
|
||||
|
||||
const toIso = (d, m, y) =>
|
||||
`${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
@@ -31,7 +32,7 @@ export function parseWeeksDocx(buffer) {
|
||||
}
|
||||
weeks.sort((a, b) => a.start.localeCompare(b.start));
|
||||
if (!weeks.length)
|
||||
throw new Error("Таблица недель не распознана.");
|
||||
throw apiError(502, "source_invalid", "Таблица недель не распознана.");
|
||||
return weeks;
|
||||
}
|
||||
|
||||
|
||||
58
server/errors.js
Normal file
58
server/errors.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Errors the API reports to clients: an HTTP status, a stable machine-readable `code` and a
|
||||
* message ready to show. Clients branch on the code, never on the wording of the message.
|
||||
*
|
||||
* 400 bad_request the request itself is wrong (missing or malformed parameters)
|
||||
* 404 unknown_endpoint no such API address: the server is older than the client
|
||||
* 404 document_not_found the document left the catalog
|
||||
* 404 group_not_found the document has no such group
|
||||
* 413 file_too_large an import over 10 MB
|
||||
* 422 unreadable_file an import that is not a readable Word table
|
||||
* 429 too_many_requests the client sends too much; Retry-After says when to come back
|
||||
* 429 busy the server is parsing other imports
|
||||
* 502 source_unavailable the university catalog did not answer
|
||||
* 502 source_invalid the catalog answered, but its folders or tables are not recognised
|
||||
* 504 source_timeout the university catalog answered too slowly
|
||||
* 500 internal a fault of the server itself
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiError = (status, code, message) => new ApiError(status, code, message);
|
||||
|
||||
const SOURCE_DOWN = "Каталог университета сейчас не отвечает. Попробуйте позже.";
|
||||
const SOURCE_SLOW = "Каталог университета отвечает слишком долго. Попробуйте позже.";
|
||||
|
||||
/**
|
||||
* A failed download from the university as an API error: curl's exit code 28 (or our own
|
||||
* kill on the time limit) is a timeout, any other failure means the source is unavailable.
|
||||
*/
|
||||
export function sourceError(error) {
|
||||
if (error instanceof ApiError) return error;
|
||||
const timedOut = error?.code === 28 || error?.killed || error?.signal === "SIGTERM";
|
||||
return timedOut
|
||||
? new ApiError(504, "source_timeout", SOURCE_SLOW)
|
||||
: new ApiError(502, "source_unavailable", SOURCE_DOWN);
|
||||
}
|
||||
|
||||
/** Status, code and message for any error that reached the error handler. */
|
||||
export function describe(error) {
|
||||
if (error instanceof ApiError)
|
||||
return { status: error.status, code: error.code, message: error.message };
|
||||
if (error?.code === "LIMIT_FILE_SIZE")
|
||||
return {
|
||||
status: 413,
|
||||
code: "file_too_large",
|
||||
message: "Файл больше 10 МБ. Выберите документ меньшего размера.",
|
||||
};
|
||||
return {
|
||||
status: 500,
|
||||
code: "internal",
|
||||
message: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import multer from "multer";
|
||||
import { parseWord, PARSER_VERSION } from "./parser.js";
|
||||
import { scheduleFor } from "./schedules.js";
|
||||
import { rateLimit, semaphore } from "./limits.js";
|
||||
import { apiError, describe } from "./errors.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { timetable } from "./timetable.js";
|
||||
import {
|
||||
getIndex,
|
||||
@@ -39,8 +41,9 @@ app.use("/api", (_req, res, next) => {
|
||||
});
|
||||
// Raised whenever an endpoint is added or changed, so a deployment is easy to verify:
|
||||
// 2 — /api/timetable for the Android client; 3 — its range=semester;
|
||||
// 4 — compression, ETag revalidation, throttled refresh and import limits.
|
||||
const API_VERSION = 4;
|
||||
// 4 — compression, ETag revalidation, throttled refresh and import limits;
|
||||
// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work.
|
||||
const API_VERSION = 5;
|
||||
|
||||
// Liveness plus what the server currently reads from the university, without network calls.
|
||||
app.get("/api/health", async (_req, res) => {
|
||||
@@ -54,7 +57,7 @@ app.get("/api/health", async (_req, res) => {
|
||||
});
|
||||
});
|
||||
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
|
||||
const badRequest = (res, error) => res.status(400).json({ error });
|
||||
const badRequest = (res, error) => res.status(400).json({ error, code: "bad_request" });
|
||||
// University-wide lookups over every catalog document.
|
||||
app.get("/api/directory/search", async (req, res, next) => {
|
||||
try {
|
||||
@@ -112,16 +115,28 @@ app.get("/api/timetable", async (req, res, next) => {
|
||||
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
|
||||
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
|
||||
const file = await documentFor(doc);
|
||||
const calendar = await directoryCalendar();
|
||||
const range = req.query.range === "semester" ? "semester" : undefined;
|
||||
const today = todayIso();
|
||||
// The answer follows only from these: the document's version, the parser and the server
|
||||
// that read it, the week table, the request and the date. An unchanged one comes back
|
||||
// as an empty 304 before the document is opened or a single day is computed.
|
||||
const etag = 'W/"tt-' + createHash("sha1")
|
||||
.update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, group, from, req.query.days, range, today].join("|"))
|
||||
.digest("base64url")
|
||||
.slice(0, 22) + '"';
|
||||
res.set("ETag", etag);
|
||||
if (req.fresh) return res.status(304).end();
|
||||
res.json(
|
||||
timetable({
|
||||
file,
|
||||
schedule: await scheduleFor(file),
|
||||
calendar: await directoryCalendar(),
|
||||
calendar,
|
||||
group,
|
||||
from,
|
||||
days: req.query.days,
|
||||
range: req.query.range === "semester" ? "semester" : undefined,
|
||||
today: todayIso(),
|
||||
range,
|
||||
today,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -194,39 +209,30 @@ const upload = multer({
|
||||
app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file)
|
||||
return res.status(400).json({ error: "Выберите файл Word." });
|
||||
return badRequest(res, "Выберите файл 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." });
|
||||
return badRequest(res, "Поддерживаются файлы .docx и .doc.");
|
||||
res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true })));
|
||||
} catch (e) {
|
||||
// A file that cannot be read is the client's problem (422); a busy server keeps its 429.
|
||||
e.status ??= 422;
|
||||
next(e);
|
||||
// A file that cannot be read is the client's problem; a busy server keeps its 429.
|
||||
next(e.status || e.code === "LIMIT_FILE_SIZE" ? e : apiError(422, "unreadable_file", e.message));
|
||||
}
|
||||
});
|
||||
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.status(404).json({ error: "Адрес не найден.", code: "unknown_endpoint" })
|
||||
: 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 МБ. Выберите документ меньшего размера."
|
||||
: [404, 422, 429].includes(error.status)
|
||||
? error.message
|
||||
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
|
||||
});
|
||||
app.use((error, req, res, _next) => {
|
||||
const { status, code, message } = describe(error);
|
||||
// Faults of the server keep their stack; expected errors need a line.
|
||||
if (status >= 500 && code === "internal") console.error(req.method, req.originalUrl, error);
|
||||
else console.error(`${req.method} ${req.originalUrl} → ${status} ${code}: ${error.message}`);
|
||||
res.status(status).json({ error: message, code });
|
||||
});
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
app.listen(port, process.env.HOST || "127.0.0.1", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Limits for the expensive endpoints: parsing an uploaded Word file can take a LibreOffice
|
||||
* conversion and seconds of CPU, so one client must not be able to occupy the server.
|
||||
@@ -15,7 +17,7 @@ export function rateLimit({ max, windowMs, message }) {
|
||||
windows.set(req.ip, w);
|
||||
if (w.count <= max) return next();
|
||||
res.set("Retry-After", String(Math.ceil((w.start + windowMs - now) / 1000)));
|
||||
res.status(429).json({ error: message });
|
||||
res.status(429).json({ error: message, code: "too_many_requests" });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@ export function semaphore({ limit, queue, message }) {
|
||||
return async function run(task) {
|
||||
if (active < limit) active++;
|
||||
else if (waiting.length < queue) await new Promise((resolve) => waiting.push(resolve));
|
||||
else throw Object.assign(new Error(message), { status: 429 });
|
||||
else throw apiError(429, "busy", message);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
|
||||
144
server/parser.js
144
server/parser.js
@@ -8,7 +8,7 @@ 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;
|
||||
export const PARSER_VERSION = 10;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -62,11 +62,14 @@ export function details(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,
|
||||
/^(экзамен|зач[её]т|консультация|защита\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*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
|
||||
/\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
|
||||
);
|
||||
// Whole words only: "консультирования" in a subject name is not a consultation.
|
||||
const sessionWord = (leading?.[1] ||
|
||||
@@ -75,15 +78,19 @@ export function details(raw) {
|
||||
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)
|
||||
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();
|
||||
if (!subject) subject = rest;
|
||||
const defence = /^защита/i.test(leading?.[1] || "");
|
||||
const token =
|
||||
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
|
||||
(defence ? "защ" : undefined) ||
|
||||
(sessionWord.startsWith("экз")
|
||||
? "экз"
|
||||
: sessionWord.startsWith("зач")
|
||||
@@ -96,7 +103,13 @@ export function details(raw) {
|
||||
teacher: person?.[0] || "",
|
||||
room: room?.[1].trim().replace(/\s+/g, "") || "",
|
||||
type:
|
||||
token === "лз" || token === "лаб"
|
||||
token === "устл"
|
||||
? "Установочная лекция"
|
||||
: token === "устпз"
|
||||
? "Установочное занятие"
|
||||
: token === "защ"
|
||||
? "Защита курсовой работы"
|
||||
: token === "лз" || token === "лаб"
|
||||
? "Лабораторная"
|
||||
: token === "пз" || token === "пр"
|
||||
? "Практика"
|
||||
@@ -205,6 +218,38 @@ export function readDocx(buffer) {
|
||||
.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 = [],
|
||||
@@ -223,6 +268,8 @@ export function parseDocx(buffer, source = {}) {
|
||||
: 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;
|
||||
@@ -243,7 +290,64 @@ export function parseDocx(buffer, source = {}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (header < 0) continue;
|
||||
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,
|
||||
@@ -265,12 +369,14 @@ export function parseDocx(buffer, source = {}) {
|
||||
}
|
||||
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)),
|
||||
);
|
||||
// "Понедельник 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 =
|
||||
days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1;
|
||||
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")}`
|
||||
@@ -279,11 +385,12 @@ export function parseDocx(buffer, source = {}) {
|
||||
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),
|
||||
(c) => c.col < ranges[0].col && (timesIn(c.text) || startOnly(c.text)),
|
||||
);
|
||||
if (timeCell) {
|
||||
time = timesIn(timeCell.text);
|
||||
time = timesIn(timeCell.text) || startOnly(timeCell.text);
|
||||
timeCol = timeCell.col;
|
||||
pair = session ? row.find((c) => c.col === 1)?.text : null;
|
||||
}
|
||||
@@ -302,6 +409,7 @@ export function parseDocx(buffer, source = {}) {
|
||||
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
||||
for (const alternative of alternatives(raw)) {
|
||||
const data = details(alternative);
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
const dateCandidate = alternative.match(
|
||||
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
|
||||
);
|
||||
@@ -481,7 +589,11 @@ export function parseDocx(buffer, source = {}) {
|
||||
emptyRows: skippedRows,
|
||||
},
|
||||
format: "docx",
|
||||
period: /экзаменационн/i.test(document.text)
|
||||
period: /лабораторно-экзаменационн/i.test(document.text)
|
||||
? "Лабораторно-экзаменационная сессия"
|
||||
: credits
|
||||
? "Зачёты"
|
||||
: /экзаменационн/i.test(document.text)
|
||||
? "Экзаменационная сессия"
|
||||
: /установочн/i.test(document.text)
|
||||
? "Установочная сессия"
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { apiError, sourceError } from "./errors.js";
|
||||
import {
|
||||
academicStartYear,
|
||||
assignSemesters,
|
||||
@@ -68,6 +69,8 @@ export async function request(path, method = "GET") {
|
||||
encoding: "buffer",
|
||||
maxBuffer: 11 * 1024 * 1024,
|
||||
timeout: 28000,
|
||||
}).catch((error) => {
|
||||
throw sourceError(error);
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
@@ -164,7 +167,7 @@ async function discover(refresh = false) {
|
||||
root.filter((e) => e.folder).map((e) => nameOf(e.path)),
|
||||
);
|
||||
if (!year)
|
||||
throw new Error("В каталоге университета нет папки учебного года.");
|
||||
throw apiError(502, "source_invalid", "В каталоге университета нет папки учебного года.");
|
||||
const yearRoot = SHARE + encodeURIComponent(year.name) + "/";
|
||||
const yearEntries = decodeListing(
|
||||
(await request(yearRoot, "PROPFIND")).toString(),
|
||||
@@ -187,7 +190,8 @@ async function crawl() {
|
||||
files = [],
|
||||
count = 0;
|
||||
while (queue.length) {
|
||||
if (count + queue.length > 180) throw new Error("Каталог слишком большой.");
|
||||
if (count + queue.length > 180)
|
||||
throw apiError(502, "source_invalid", "Каталог университета слишком большой.");
|
||||
const batch = queue.splice(0, 5);
|
||||
count += batch.length;
|
||||
const results = await Promise.all(
|
||||
@@ -278,9 +282,7 @@ 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;
|
||||
throw apiError(404, "document_not_found", "Документ не найден в каталоге. Обновите список.");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
@@ -296,7 +298,7 @@ export function mergeWeekTables(tables) {
|
||||
async function loadCalendar() {
|
||||
const { references } = await discover(true);
|
||||
if (!references.weeks.length)
|
||||
throw new Error("Таблица белых и зелёных недель не найдена в каталоге.");
|
||||
throw apiError(502, "source_invalid", "Таблица белых и зелёных недель не найдена в каталоге.");
|
||||
const tables = [];
|
||||
for (const file of references.weeks)
|
||||
try {
|
||||
@@ -305,7 +307,7 @@ async function loadCalendar() {
|
||||
// One damaged copy must not hide a readable newer or older one.
|
||||
console.error(`${file.name}: ${error.message}`);
|
||||
}
|
||||
if (!tables.length) throw new Error("Таблица недель не распознана.");
|
||||
if (!tables.length) throw apiError(502, "source_invalid", "Таблица недель не распознана.");
|
||||
let bells = [];
|
||||
if (references.bells)
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
|
||||
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
|
||||
import { occurrence, peopleAndRooms, roomsOn } from "./directory.js";
|
||||
import { apiError } from "./errors.js";
|
||||
|
||||
const MAX_DAYS = 62;
|
||||
// A whole semester with its session fits easily; the cap only guards against abuse.
|
||||
@@ -38,22 +39,35 @@ function lessonOut(lesson, group, people, date, status, calendar) {
|
||||
*/
|
||||
export function timetable({ file, schedule, calendar, group, from, days, range, today }) {
|
||||
if (!schedule.groups.includes(group)) {
|
||||
const error = new Error(`В документе нет группы ${group}.`);
|
||||
error.status = 404;
|
||||
throw error;
|
||||
throw apiError(404, "group_not_found", `В документе нет группы ${group}.`);
|
||||
}
|
||||
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
|
||||
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
||||
// A session schedule printed with a date on every day needs no week table: its own dates
|
||||
// say when each lesson takes place, even in a year the table no longer covers.
|
||||
const sessionDates = lessons.every((l) => l.date || l.dates?.length)
|
||||
? lessons.flatMap((l) => (l.date ? [l.date] : l.dates)).sort()
|
||||
: [];
|
||||
const session = sessionDates.length
|
||||
? {
|
||||
id: `session-${sessionDates[0]}`,
|
||||
label: schedule.period || "Сессия",
|
||||
start: sessionDates[0],
|
||||
end: sessionDates.at(-1),
|
||||
}
|
||||
: null;
|
||||
// range=semester: every day of the semester `today` falls in, so a client can keep
|
||||
// the whole term offline. Without a known semester it keeps the plain from/days window.
|
||||
// the whole term offline; a session schedule keeps its own span. Without either it
|
||||
// keeps the plain from/days window.
|
||||
if (range === "semester") {
|
||||
const current = semesterView(calendar, schedule, today || from);
|
||||
if (current) {
|
||||
from = current.semester.start;
|
||||
const span = Math.round((Date.parse(current.semester.end) - Date.parse(from)) / DAY_MS) + 1;
|
||||
count = Math.min(Math.max(span, 1), MAX_SEMESTER_DAYS);
|
||||
const span = current?.semester || session;
|
||||
if (span) {
|
||||
from = span.start;
|
||||
const length = Math.round((Date.parse(span.end) - Date.parse(from)) / DAY_MS) + 1;
|
||||
count = Math.min(Math.max(length, 1), MAX_SEMESTER_DAYS);
|
||||
}
|
||||
}
|
||||
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
|
||||
const entries = lessons.map((lesson) => ({
|
||||
doc: { id: file.id, schedule },
|
||||
lesson,
|
||||
@@ -66,7 +80,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
const date = addDays(from, i);
|
||||
const week = view ? view.weeks[weekIndexOf(view, date)] || null : null;
|
||||
const dayLessons = [];
|
||||
if (view)
|
||||
if (view || session)
|
||||
for (const entry of entries) {
|
||||
const status = occurrence(entry, date, calendar, views);
|
||||
if (status === "yes" || status === "maybe")
|
||||
@@ -99,8 +113,8 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
modified: file.modified || schedule.source.modified || null,
|
||||
},
|
||||
period: schedule.period,
|
||||
semester: view?.semester || null,
|
||||
note: view
|
||||
semester: view?.semester || session,
|
||||
note: view || session
|
||||
? null
|
||||
: "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).",
|
||||
subgroups: subgroupsOf(schedule, group),
|
||||
|
||||
Reference in New Issue
Block a user