Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-10-19 19:36:19 +03:00
parent 170a44c377
commit 5f7bffd6e4
17 changed files with 454 additions and 85 deletions

View File

@@ -51,7 +51,9 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована. Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
Ошибки приходят как `{ "error": "текст для показа", "code": "…" }`. Клиенты опираются на `code`, а не на текст: `bad_request` (400), `unknown_endpoint`, `document_not_found`, `group_not_found` (404), `file_too_large` (413), `unreadable_file` (422), `too_many_requests` и `busy` (429), `source_unavailable` и `source_invalid` (502, каталог университета не ответил или не распознан), `source_timeout` (504), `internal` (500). Полный список — в `server/errors.js`. Android и iOS при `source_unavailable`, `source_timeout` и `busy` оставляют на экране сохранённую копию и показывают «Повторить».
`GET /api/health` без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется. `GET /api/health` без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется.
@@ -63,7 +65,20 @@ npm run sync
## Границы парсера ## Границы парсера
Основной парсер читает XML и сетку таблиц `.docx`, включая горизонтальные и вертикальные объединения ячеек. Поддерживаются проверенные дневные шаблоны ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, а также заочные установочные сессии с отдельными колонками предмета, аудитории и преподавателя. Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку. Основной парсер читает XML и сетку таблиц `.docx`, включая горизонтальные и вертикальные объединения ячеек. Поддерживаются проверенные дневные шаблоны ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, заочные установочные сессии с отдельными колонками предмета, аудитории и преподавателя, а также заочные лабораторно-экзаменационные сессии.
**Лабораторно-экзаменационные сессии заочников** (проверено на зимней сессии 23-ИТзс 2024 года и летней 2026 года, `test/fixtures/itzs-*.docx`) устроены иначе:
- **Группа.** Указана только в заголовке («группа 23-ИТзс»).
- **Недели.** В таблице две–три недели стоят рядом блоками столбцов «Д.н. — Время — Дисциплина», у каждого дня своя дата. Сессия поэтому не зависит от таблицы недель и показывается по своим датам даже в прошлом учебном году. API отдаёт её границы в поле `semester` с названием «Лабораторно-экзаменационная сессия».
- **Подгруппы.** Если под одним заголовком дисциплины столбец разделён, левая часть — подгруппа 1, правая — подгруппа 2, даже при неровном делении.
- **Типы и предмет.** Распознаются установочные лекции и занятия (`уст.л.`, `уст.пз.`), экзамены, зачёты, консультации и защита курсовой работы. Предмет берётся из кавычек («…по учебной дисциплине «Базы данных»»).
- **Время.** Если у экзамена указано только начало («8.30»), конец берётся по расписанию звонков.
**Расписания зачётов** (папка «Расписание зачетов», проверено на 23-, 24- и 26-АРХ ИСФ, `test/fixtures/isf-credits-26-arh.docx`) — таблица «Дата — Время — группа»:
- **Дата и время.** В ячейке дня дата стоит перед днём недели («27.10.2026 вторник»), пустая ячейка продолжает день выше. Указано одно время — начало, конец берётся по расписанию звонков. Такое одиночное время теперь принимается и в других таблицах: прежде в документах «Введение в высшее образование» и в установочных сессиях пропадали строки вроде «Торжественное собрание 10.30».
- **Тип записи.** Строка без пометки считается зачётом, «Консультация по учебной дисциплине…» — консультацией. Все они попадают в обратный отсчёт сессии.
Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку.
**Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы». Текстовые даты вроде «15 октября» привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет. **Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы». Текстовые даты вроде «15 октября» привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет.
@@ -173,6 +188,7 @@ SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.x
- `server/parser.js` — таблицы `.docx`, условия и преобразование `.doc`. - `server/parser.js` — таблицы `.docx`, условия и преобразование `.doc`.
- `server/index.js` — API и выдача собранной PWA. - `server/index.js` — API и выдача собранной PWA.
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса. - `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
- `server/errors.js` — коды ошибок API и их HTTP-статусы.
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов. - `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`. - `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
- `ios/Widgets/` — виджеты WidgetKit. - `ios/Widgets/` — виджеты WidgetKit.

View File

@@ -15,7 +15,15 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ApiException(message: String, val offline: Boolean = false) : Exception(message) /**
* An error ready to show. [code] is the server's machine-readable reason (server/errors.js);
* [offline] means the data could not be fetched — no network, or the university's catalog
* is down — so a saved copy stays on screen and "Повторить" makes sense.
*/
class ApiException(message: String, val offline: Boolean = false, val code: String? = null) : Exception(message)
/** Server codes after which trying again later can help. */
private val SOURCE_DOWN = setOf("source_unavailable", "source_timeout", "busy")
val json = Json { val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -44,14 +52,20 @@ class Api(baseUrl: String, cacheDir: File? = null) {
client.newCall(Request.Builder().url(url).build()).execute().use { response -> client.newCall(Request.Builder().url(url).build()).execute().use { response ->
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val message = runCatching { val body = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
json.parseToJsonElement(text).jsonObject["error"]?.jsonPrimitive?.content val message = body?.get("error")?.jsonPrimitive?.content
}.getOrNull() // Servers before API version 5 send no code; their catch-all 404 has this text.
// The server's catch-all 404 means it runs an older version without this endpoint. val code = body?.get("code")?.jsonPrimitive?.content
if (response.code == 404 && path.startsWith("api/") && message == "Адрес не найден.") throw ApiException( ?: if (response.code == 404 && message == "Адрес не найден.") "unknown_endpoint" else null
if (code == "unknown_endpoint") throw ApiException(
"Сервер расписания не знает запрос /$path — на нём старая версия. Обновите сервер.", "Сервер расписания не знает запрос /$path — на нём старая версия. Обновите сервер.",
code = code,
)
throw ApiException(
message ?: "Сервер ответил ошибкой ${response.code}.",
offline = code in SOURCE_DOWN,
code = code,
) )
throw ApiException(message ?: "Сервер ответил ошибкой ${response.code}.")
} }
text text
} }
@@ -70,12 +84,12 @@ class Api(baseUrl: String, cacheDir: File? = null) {
return call() return call()
} catch (e: IOException) { } catch (e: IOException) {
android.util.Log.w("Api", "GET $url failed (attempt ${attempt + 1})", e) android.util.Log.w("Api", "GET $url failed (attempt ${attempt + 1})", e)
if (attempt == RETRIES - 1) throw ApiException(OFFLINE, offline = true) if (attempt == RETRIES - 1) throw ApiException(OFFLINE, offline = true, code = "offline")
delay(wait) delay(wait)
wait *= 3 wait *= 3
} }
} }
throw ApiException(OFFLINE, offline = true) throw ApiException(OFFLINE, offline = true, code = "offline")
} }
private companion object { private companion object {

View File

@@ -1,10 +1,13 @@
import Foundation import Foundation
import OSLog import OSLog
/// An error with a message ready to show; `offline` when the server could not be reached. /// An error with a message ready to show. `code` is the server's machine-readable reason
/// (server/errors.js); `offline` means the data could not be fetched — no network, or the
/// university's catalog is down — so the saved copy stays and "Повторить" makes sense.
struct APIError: LocalizedError { struct APIError: LocalizedError {
var message: String var message: String
var offline = false var offline = false
var code: String?
var errorDescription: String? { message } var errorDescription: String? { message }
} }
@@ -25,6 +28,8 @@ struct API: Sendable {
private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API") private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API")
private static let offline = "Нет связи с сервером расписания." private static let offline = "Нет связи с сервером расписания."
/// Server codes after which trying again later can help.
private static let sourceDown: Set<String> = ["source_unavailable", "source_timeout", "busy"]
private static let retries = 3 private static let retries = 3
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again // The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again
@@ -43,14 +48,21 @@ struct API: Sendable {
if !items.isEmpty { components.queryItems = items } if !items.isEmpty { components.queryItems = items }
let url = components.url! let url = components.url!
let (data, response) = try await withRetry(url) let (data, response) = try await withRetry(url)
guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true) } guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true, code: "offline") }
guard (200..<300).contains(http.statusCode) else { guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
// The server's catch-all 404 means it runs an older version without this endpoint. let message = body?["error"] as? String
if http.statusCode == 404 && message == "Адрес не найден." { // Servers before API version 5 send no code; their catch-all 404 has this text.
throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.") let code = body?["code"] as? String
?? (http.statusCode == 404 && message == "Адрес не найден." ? "unknown_endpoint" : nil)
if code == "unknown_endpoint" {
throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.", code: code)
} }
throw APIError(message: message ?? "Сервер ответил ошибкой \(http.statusCode).") throw APIError(
message: message ?? "Сервер ответил ошибкой \(http.statusCode).",
offline: code.map(Self.sourceDown.contains) ?? false,
code: code
)
} }
do { do {
return try JSONDecoder().decode(T.self, from: data) return try JSONDecoder().decode(T.self, from: data)
@@ -74,7 +86,7 @@ struct API: Sendable {
wait *= 3 wait *= 3
} }
} }
throw APIError(message: Self.offline, offline: true) throw APIError(message: Self.offline, offline: true, code: "offline")
} }
/// The whole current semester; a server without range=semester answers the from/days window. /// The whole current semester; a server without range=semester answers the from/days window.

View File

@@ -1,4 +1,5 @@
import { readDocx } from "./parser.js"; import { readDocx } from "./parser.js";
import { apiError } from "./errors.js";
const toIso = (d, m, y) => const toIso = (d, m, y) =>
`${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`; `${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)); weeks.sort((a, b) => a.start.localeCompare(b.start));
if (!weeks.length) if (!weeks.length)
throw new Error("Таблица недель не распознана."); throw apiError(502, "source_invalid", "Таблица недель не распознана.");
return weeks; return weeks;
} }

58
server/errors.js Normal file
View 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: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
};
}

View File

@@ -5,6 +5,8 @@ import multer from "multer";
import { parseWord, PARSER_VERSION } from "./parser.js"; import { parseWord, PARSER_VERSION } from "./parser.js";
import { scheduleFor } from "./schedules.js"; import { scheduleFor } from "./schedules.js";
import { rateLimit, semaphore } from "./limits.js"; import { rateLimit, semaphore } from "./limits.js";
import { apiError, describe } from "./errors.js";
import { createHash } from "node:crypto";
import { timetable } from "./timetable.js"; import { timetable } from "./timetable.js";
import { import {
getIndex, 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: // 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; // 2 — /api/timetable for the Android client; 3 — its range=semester;
// 4 — compression, ETag revalidation, throttled refresh and import limits. // 4 — compression, ETag revalidation, throttled refresh and import limits;
const API_VERSION = 4; // 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. // Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => { 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 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. // University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => { app.get("/api/directory/search", async (req, res, next) => {
try { try {
@@ -112,16 +115,28 @@ app.get("/api/timetable", async (req, res, next) => {
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group)."); if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД."); if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const file = await documentFor(doc); 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( res.json(
timetable({ timetable({
file, file,
schedule: await scheduleFor(file), schedule: await scheduleFor(file),
calendar: await directoryCalendar(), calendar,
group, group,
from, from,
days: req.query.days, days: req.query.days,
range: req.query.range === "semester" ? "semester" : undefined, range,
today: todayIso(), today,
}), }),
); );
} catch (e) { } catch (e) {
@@ -194,39 +209,30 @@ const upload = multer({
app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => { app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => {
try { try {
if (!req.file) if (!req.file)
return res.status(400).json({ error: "Выберите файл Word." }); return badRequest(res, "Выберите файл Word.");
// Multer decodes multipart names as latin1 by default. // Multer decodes multipart names as latin1 by default.
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8"); const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
if (!/\.docx?$/i.test(name)) if (!/\.docx?$/i.test(name))
return res return badRequest(res, "Поддерживаются файлы .docx и .doc.");
.status(400)
.json({ error: "Поддерживаются файлы .docx и .doc." });
res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true }))); res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true })));
} catch (e) { } catch (e) {
// A file that cannot be read is the client's problem (422); a busy server keeps its 429. // A file that cannot be read is the client's problem; a busy server keeps its 429.
e.status ??= 422; next(e.status || e.code === "LIMIT_FILE_SIZE" ? e : apiError(422, "unreadable_file", e.message));
next(e);
} }
}); });
const dist = fileURLToPath(new URL("../dist/", import.meta.url)); const dist = fileURLToPath(new URL("../dist/", import.meta.url));
app.use(express.static(dist, { maxAge: 0 })); app.use(express.static(dist, { maxAge: 0 }));
app.get("/{*path}", (req, res, next) => app.get("/{*path}", (req, res, next) =>
req.path.startsWith("/api/") 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)), : res.sendFile(dist + "index.html", (err) => err && next(err)),
); );
app.use((error, _req, res, _next) => { app.use((error, req, res, _next) => {
console.error(error.message); const { status, code, message } = describe(error);
res // Faults of the server keep their stack; expected errors need a line.
.status(error.code === "LIMIT_FILE_SIZE" ? 413 : error.status || 503) if (status >= 500 && code === "internal") console.error(req.method, req.originalUrl, error);
.json({ else console.error(`${req.method} ${req.originalUrl} → ${status} ${code}: ${error.message}`);
error: res.status(status).json({ error: message, code });
error.code === "LIMIT_FILE_SIZE"
? "Файл больше 10 МБ. Выберите документ меньшего размера."
: [404, 422, 429].includes(error.status)
? error.message
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
});
}); });
const port = Number(process.env.PORT || 3001); const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => { app.listen(port, process.env.HOST || "127.0.0.1", () => {

View File

@@ -1,3 +1,5 @@
import { apiError } from "./errors.js";
/** /**
* Limits for the expensive endpoints: parsing an uploaded Word file can take a LibreOffice * 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. * 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); windows.set(req.ip, w);
if (w.count <= max) return next(); if (w.count <= max) return next();
res.set("Retry-After", String(Math.ceil((w.start + windowMs - now) / 1000))); 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) { return async function run(task) {
if (active < limit) active++; if (active < limit) active++;
else if (waiting.length < queue) await new Promise((resolve) => waiting.push(resolve)); 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 { try {
return await task(); return await task();
} finally { } finally {

View File

@@ -8,7 +8,7 @@ import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js"; import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
const run = promisify(execFile); const run = promisify(execFile);
export const PARSER_VERSION = 8; export const PARSER_VERSION = 10;
const days = [ const days = [
"понедельник", "понедельник",
"вторник", "вторник",
@@ -62,11 +62,14 @@ export function details(raw) {
let rest = raw.slice(condition.length).trim(); let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject. // "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
const leading = rest.match( const leading = rest.match(
/^(экзамен|зач[её]т|консультация)(?![а-яё])[\s.:–—-]*/i, /^(экзамен|зач[её]т|консультация|защита\s+курсов(?:ой|ого)\s+(?:работы|проекта))(?![а-яё])[\s.:–—-]*/i,
); );
if (leading) rest = rest.slice(leading[0].length); 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( const kind = rest.match(
/\((л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i, /\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
); );
// Whole words only: "консультирования" in a subject name is not a consultation. // Whole words only: "консультирования" in a subject name is not a consultation.
const sessionWord = (leading?.[1] || const sessionWord = (leading?.[1] ||
@@ -75,15 +78,19 @@ export function details(raw) {
const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u); const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u);
const room = const room =
rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) || rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) ||
rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u); rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u) ||
let subject = rest // A room with a name instead of a number: "ауд.Медиа".
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length) rest.match(/(?:ауд\.\s*)([А-ЯЁ][а-яё]{2,})(?![а-яё])/u);
let subject = (named ? named[1].trim() : rest
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length))
.trim() .trim()
.replace(/[–—,\-]+$/, "") .replace(/[–—,\-]+$/, "")
.trim(); .trim();
if (!subject) subject = rest; if (!subject) subject = rest;
const defence = /^защита/i.test(leading?.[1] || "");
const token = const token =
kind?.[1].replace(/[.\s]/g, "").toLowerCase() || kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
(defence ? "защ" : undefined) ||
(sessionWord.startsWith("экз") (sessionWord.startsWith("экз")
? "экз" ? "экз"
: sessionWord.startsWith("зач") : sessionWord.startsWith("зач")
@@ -96,7 +103,13 @@ export function details(raw) {
teacher: person?.[0] || "", teacher: person?.[0] || "",
room: room?.[1].trim().replace(/\s+/g, "") || "", room: room?.[1].trim().replace(/\s+/g, "") || "",
type: type:
token === "лз" || token === "лаб" token === "устл"
? "Установочная лекция"
: token === "устпз"
? "Установочное занятие"
: token === "защ"
? "Защита курсовой работы"
: token === "лз" || token === "лаб"
? "Лабораторная" ? "Лабораторная"
: token === "пз" || token === "пр" : token === "пз" || token === "пр"
? "Практика" ? "Практика"
@@ -205,6 +218,38 @@ export function readDocx(buffer) {
.filter((s) => s && !/декан|начальник|_{3}|^утверждаю/i.test(s)), .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 = {}) { export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer); const document = readDocx(buffer);
const lessons = [], const lessons = [],
@@ -223,6 +268,8 @@ export function parseDocx(buffer, source = {}) {
: null); : null);
let seenRows = 0, let seenRows = 0,
skippedRows = 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) { for (const { rows } of document.tables) {
let ranges = [], let ranges = [],
header = -1; header = -1;
@@ -243,7 +290,64 @@ export function parseDocx(buffer, source = {}) {
break; 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)); ranges.forEach((r) => groups.add(r.group));
let day = null, let day = null,
date = null, date = null,
@@ -265,12 +369,14 @@ export function parseDocx(buffer, source = {}) {
} }
for (let i = header + 1; i < rows.length; i++) { for (let i = header + 1; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
const dayCell = row.find((c) => // "Понедельник 12.01.2026", or date first as in credit schedules: "27.10.2026 ⏎ вторник".
days.some((d) => c.text.toLowerCase().startsWith(d)), 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) { if (dayCell) {
day = day = weekdayOf(dayCell.text);
days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1;
const m = dayCell.text.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/); const m = dayCell.text.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/);
date = m date = m
? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}` ? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`
@@ -279,11 +385,12 @@ export function parseDocx(buffer, source = {}) {
pair = null; pair = null;
} }
if (!day) continue; if (!day) continue;
// Credits and exams give only the start ("8.30"); the bell table supplies the end.
const timeCell = row.find( 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) { if (timeCell) {
time = timesIn(timeCell.text); time = timesIn(timeCell.text) || startOnly(timeCell.text);
timeCol = timeCell.col; timeCol = timeCell.col;
pair = session ? row.find((c) => c.col === 1)?.text : null; 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"; rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
for (const alternative of alternatives(raw)) { for (const alternative of alternatives(raw)) {
const data = details(alternative); const data = details(alternative);
if (credits && data.type === "Занятие") data.type = "Зачёт";
const dateCandidate = alternative.match( const dateCandidate = alternative.match(
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/, /^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
); );
@@ -481,7 +589,11 @@ export function parseDocx(buffer, source = {}) {
emptyRows: skippedRows, emptyRows: skippedRows,
}, },
format: "docx", format: "docx",
period: /экзаменационн/i.test(document.text) period: /лабораторно-экзаменационн/i.test(document.text)
? "Лабораторно-экзаменационная сессия"
: credits
? "Зачёты"
: /экзаменационн/i.test(document.text)
? "Экзаменационная сессия" ? "Экзаменационная сессия"
: /установочн/i.test(document.text) : /установочн/i.test(document.text)
? "Установочная сессия" ? "Установочная сессия"

View File

@@ -5,6 +5,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js"; import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
import { apiError, sourceError } from "./errors.js";
import { import {
academicStartYear, academicStartYear,
assignSemesters, assignSemesters,
@@ -68,6 +69,8 @@ export async function request(path, method = "GET") {
encoding: "buffer", encoding: "buffer",
maxBuffer: 11 * 1024 * 1024, maxBuffer: 11 * 1024 * 1024,
timeout: 28000, timeout: 28000,
}).catch((error) => {
throw sourceError(error);
}); });
return stdout; return stdout;
} }
@@ -164,7 +167,7 @@ async function discover(refresh = false) {
root.filter((e) => e.folder).map((e) => nameOf(e.path)), root.filter((e) => e.folder).map((e) => nameOf(e.path)),
); );
if (!year) if (!year)
throw new Error("В каталоге университета нет папки учебного года."); throw apiError(502, "source_invalid", "В каталоге университета нет папки учебного года.");
const yearRoot = SHARE + encodeURIComponent(year.name) + "/"; const yearRoot = SHARE + encodeURIComponent(year.name) + "/";
const yearEntries = decodeListing( const yearEntries = decodeListing(
(await request(yearRoot, "PROPFIND")).toString(), (await request(yearRoot, "PROPFIND")).toString(),
@@ -187,7 +190,8 @@ async function crawl() {
files = [], files = [],
count = 0; count = 0;
while (queue.length) { 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); const batch = queue.splice(0, 5);
count += batch.length; count += batch.length;
const results = await Promise.all( const results = await Promise.all(
@@ -278,9 +282,7 @@ export async function documentFor(id) {
const catalog = await getCatalog(); const catalog = await getCatalog();
const file = catalog.files.find((f) => f.id === id); const file = catalog.files.find((f) => f.id === id);
if (!file) { if (!file) {
const error = new Error("Документ не найден в каталоге. Обновите список."); throw apiError(404, "document_not_found", "Документ не найден в каталоге. Обновите список.");
error.status = 404;
throw error;
} }
return file; return file;
} }
@@ -296,7 +298,7 @@ export function mergeWeekTables(tables) {
async function loadCalendar() { async function loadCalendar() {
const { references } = await discover(true); const { references } = await discover(true);
if (!references.weeks.length) if (!references.weeks.length)
throw new Error("Таблица белых и зелёных недель не найдена в каталоге."); throw apiError(502, "source_invalid", "Таблица белых и зелёных недель не найдена в каталоге.");
const tables = []; const tables = [];
for (const file of references.weeks) for (const file of references.weeks)
try { try {
@@ -305,7 +307,7 @@ async function loadCalendar() {
// One damaged copy must not hide a readable newer or older one. // One damaged copy must not hide a readable newer or older one.
console.error(`${file.name}: ${error.message}`); console.error(`${file.name}: ${error.message}`);
} }
if (!tables.length) throw new Error("Таблица недель не распознана."); if (!tables.length) throw apiError(502, "source_invalid", "Таблица недель не распознана.");
let bells = []; let bells = [];
if (references.bells) if (references.bells)
try { try {

View File

@@ -3,6 +3,7 @@
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js"; import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
import { lessonKey, subgroupsOf } from "../shared/lessons.js"; import { lessonKey, subgroupsOf } from "../shared/lessons.js";
import { occurrence, peopleAndRooms, roomsOn } from "./directory.js"; import { occurrence, peopleAndRooms, roomsOn } from "./directory.js";
import { apiError } from "./errors.js";
const MAX_DAYS = 62; const MAX_DAYS = 62;
// A whole semester with its session fits easily; the cap only guards against abuse. // 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 }) { export function timetable({ file, schedule, calendar, group, from, days, range, today }) {
if (!schedule.groups.includes(group)) { if (!schedule.groups.includes(group)) {
const error = new Error(`В документе нет группы ${group}.`); throw apiError(404, "group_not_found", `В документе нет группы ${group}.`);
error.status = 404;
throw error;
} }
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS); 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 // 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") { if (range === "semester") {
const current = semesterView(calendar, schedule, today || from); const current = semesterView(calendar, schedule, today || from);
if (current) { const span = current?.semester || session;
from = current.semester.start; if (span) {
const span = Math.round((Date.parse(current.semester.end) - Date.parse(from)) / DAY_MS) + 1; from = span.start;
count = Math.min(Math.max(span, 1), MAX_SEMESTER_DAYS); 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) => ({ const entries = lessons.map((lesson) => ({
doc: { id: file.id, schedule }, doc: { id: file.id, schedule },
lesson, lesson,
@@ -66,7 +80,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
const date = addDays(from, i); const date = addDays(from, i);
const week = view ? view.weeks[weekIndexOf(view, date)] || null : null; const week = view ? view.weeks[weekIndexOf(view, date)] || null : null;
const dayLessons = []; const dayLessons = [];
if (view) if (view || session)
for (const entry of entries) { for (const entry of entries) {
const status = occurrence(entry, date, calendar, views); const status = occurrence(entry, date, calendar, views);
if (status === "yes" || status === "maybe") 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, modified: file.modified || schedule.source.modified || null,
}, },
period: schedule.period, period: schedule.period,
semester: view?.semester || null, semester: view?.semester || session,
note: view note: view || session
? null ? null
: "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).", : "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).",
subgroups: subgroupsOf(schedule, group), subgroups: subgroupsOf(schedule, group),

27
test/errors.test.js Normal file
View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { apiError, describe, sourceError } from "../server/errors.js";
test("Ошибки API: код и статус для клиентов", () => {
assert.deepEqual(describe(apiError(404, "group_not_found", "В документе нет группы 1-А.")), {
status: 404,
code: "group_not_found",
message: "В документе нет группы 1-А.",
});
assert.deepEqual(describe({ code: "LIMIT_FILE_SIZE" }).code, "file_too_large");
// Faults of the server itself never show their technical text.
const internal = describe(new TypeError("Cannot read properties of undefined"));
assert.equal(internal.status, 500);
assert.equal(internal.code, "internal");
assert.doesNotMatch(internal.message, /undefined/);
});
test("Ошибки источника: таймаут curl отдельно от недоступности", () => {
assert.equal(sourceError({ code: 28 }).code, "source_timeout");
assert.equal(sourceError({ code: 28 }).status, 504);
assert.equal(sourceError({ killed: true, signal: "SIGTERM" }).code, "source_timeout");
assert.equal(sourceError({ code: 6 }).code, "source_unavailable");
assert.equal(sourceError({ code: 22 }).status, 502);
const own = apiError(502, "source_invalid", "Таблица недель не распознана.");
assert.equal(sourceError(own), own);
});

BIN
test/fixtures/isf-credits-26-arh.docx vendored Normal file

Binary file not shown.

BIN
test/fixtures/itzs-summer-2026.docx vendored Normal file

Binary file not shown.

BIN
test/fixtures/itzs-winter-2024.docx vendored Normal file

Binary file not shown.

View File

@@ -14,7 +14,7 @@ test("Импорт: не больше двух разборов сразу, ли
active--; active--;
}); });
const running = [task(), task(), task()]; const running = [task(), task(), task()];
await assert.rejects(task(), (e) => e.status === 429 && e.message === "Занято"); await assert.rejects(task(), (e) => e.status === 429 && e.code === "busy" && e.message === "Занято");
// Each finished task hands its slot to the one waiting. // Each finished task hands its slot to the one waiting.
while (release.length) { while (release.length) {
release.shift()(); release.shift()();
@@ -47,6 +47,10 @@ test("Импорт: лимит запросов считается по адре
}; };
assert.ok(call("10.0.0.1").passed); assert.ok(call("10.0.0.1").passed);
assert.ok(call("10.0.0.1").passed); assert.ok(call("10.0.0.1").passed);
assert.deepEqual(call("10.0.0.1"), { status: 429, body: { error: "Подождите" }, passed: false }); assert.deepEqual(call("10.0.0.1"), {
status: 429,
body: { error: "Подождите", code: "too_many_requests" },
passed: false,
});
assert.ok(call("10.0.0.2").passed); assert.ok(call("10.0.0.2").passed);
}); });

View File

@@ -160,3 +160,79 @@ test("DAV: метаданные вложенных документов и то
assert.equal(file.course, "4 курс"); assert.equal(file.course, "4 курс");
assert.equal(file.name, "23-ЛГ_23-БА.docx"); assert.equal(file.name, "23-ЛГ_23-БА.docx");
}); });
// Laboratory-examination sessions of correspondence students (23-ИТзс): weeks side by side,
// the group named only in the title, a date on every day.
const itzs = (name) => parseDocx(fixture(name), { name: `${name}.docx` });
const at = (s, date, start) =>
s.lessons
.filter((l) => l.date === date && l.start === start)
.map((l) => [l.type, l.subject, l.teacher, l.room, l.subgroups.map((g) => g.label).join()]);
test("Заочная сессия: группа из заголовка, недели рядом, даты из Word", () => {
const s = itzs("itzs-winter-2024");
assert.deepEqual(s.groups, ["23-ИТзс"]);
assert.equal(s.period, "Лабораторно-экзаменационная сессия");
// Every filled discipline cell of the three weeks is one lesson.
assert.equal(s.lessons.length, 36);
assert.ok(s.lessons.every((l) => l.date >= "2024-01-08" && l.date <= "2024-01-28"));
// Two columns under one heading are the subgroups' laboratory classes.
assert.deepEqual(at(s, "2024-01-08", "17:00"), [
["Лабораторная", "Основы информационных технологий и компьютерной графики", "Михнович О.В.", "217Д", "Подгруппа 1"],
["Лабораторная", "Основы алгоритмизации и программирования", "Конаплева Г.Ф.", "218Д", "Подгруппа 2"],
]);
assert.deepEqual(at(s, "2024-01-15", "17:00"), [
["Установочная лекция", "Математический анализ", "Забелендик О.Н.", "220Д", ""],
]);
// "Экзамен по учебной дисциплине «…»": the subject from the quotes, a room with a name.
assert.deepEqual(at(s, "2024-01-18", "08:30"), [
["Экзамен", "Основы алгоритмизации и программирования", "Чертков В.М.", "Медиа", ""],
]);
assert.deepEqual(at(s, "2024-01-16", "13:40"), [
["Защита курсовой работы", "Основы алгоритмизации и программирования", "Чертков В.М.", "254В", ""],
]);
});
test("Заочная сессия: неровное деление столбцов и экзамен без конца", () => {
const s = itzs("itzs-summer-2026");
assert.deepEqual(s.groups, ["23-ИТзс"]);
assert.equal(s.lessons.length, 58);
// The third week's heading spans three columns, split 2+1 or 1+2: left and right subgroup.
assert.deepEqual(at(s, "2026-06-24", "08:30").map((l) => [l[1], l[4]]), [
["Методы и алгоритмы принятия", "Подгруппа 1"],
["Распределенные вычисления", "Подгруппа 2"],
]);
// "8.30" alone: the exam starts then; its end comes from the bell table.
const exam = s.lessons.find((l) => l.date === "2026-06-18" && l.type === "Экзамен");
assert.deepEqual([exam.start, exam.end, exam.subject], ["08:30", null, "Базы данных"]);
assert.equal(s.lessons.filter((l) => l.type === "Консультация").length, 4);
});
test("Расписание зачетов: дата перед днём недели, одно время, зачёты по умолчанию", () => {
const s = parseDocx(fixture("isf-credits-26-arh"), { name: "26-АРХ.docx" });
assert.deepEqual(s.groups, ["26-АРХ"]);
assert.equal(s.period, "Зачёты");
assert.equal(s.lessons.length, 9);
const row = (l) => [l.date, l.start, l.end, l.type, l.subject, l.subgroups.map((g) => g.label).join()];
const sorted = [...s.lessons].sort((a, b) => (a.date + a.start).localeCompare(b.date + b.start));
assert.deepEqual(sorted.slice(0, 2).map(row), [
["2026-10-27", "17:00", null, "Консультация", "Введение в архитектурное проектирование", ""],
["2026-10-31", "08:30", null, "Зачёт", "Введение в архитектурное проектирование", ""],
]);
// An empty date cell continues the day above: the second consultation on 18 December.
assert.deepEqual(
s.lessons.filter((l) => l.date === "2026-12-18").map((l) => [l.start, l.subject]),
[
["08:30", "Проектирование небольшого архитектурного сооружения"],
["15:20", "Рисунок"],
],
);
// Two columns under the group: each subgroup takes its credit with its own teacher.
assert.deepEqual(
s.lessons.filter((l) => l.date === "2026-12-22").map((l) => [l.teacher, l.room, l.subgroups[0]?.label]),
[
["Кузякова О.Д.", "361", "Подгруппа 1"],
["Пискун А.А.", "370", "Подгруппа 2"],
],
);
});

View File

@@ -58,3 +58,28 @@ test("range=semester: все дни текущего семестра от пе
assert.equal(t.days.at(-1).date, t.semester.end); assert.equal(t.days.at(-1).date, t.semester.end);
assert.ok(t.days.length > 62, "больше обычного предела в 62 дня"); assert.ok(t.days.length > 62, "больше обычного предела в 62 дня");
}); });
test("Сессия с датами в Word не зависит от таблицы недель", () => {
// June 2026 lies outside the 2026/27 week table, yet every lesson has its own date.
const session = parseDocx(fixture("itzs-summer-2026"), { name: "23-ИТзс.docx" });
const t = timetable({
file: { id: "itzs", name: "23-ИТзс.docx" },
schedule: session,
calendar,
group: "23-ИТзс",
from: "2026-10-19",
range: "semester",
today: "2026-10-19",
});
assert.deepEqual(t.semester, {
id: "session-2026-06-08",
label: "Лабораторно-экзаменационная сессия",
start: "2026-06-08",
end: "2026-06-27",
});
assert.equal(t.note, null);
assert.equal(t.days.length, 20);
assert.equal(t.days.reduce((n, d) => n + d.lessons.length, 0), 58);
const exam = t.days.find((d) => d.date === "2026-06-18").lessons.find((l) => l.type === "Экзамен");
assert.deepEqual([exam.start, exam.end, exam.pair], ["08:30", "09:55", 1]);
});