diff --git a/README.md b/README.md index 61dcafd..3e2f1f0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` Каталог и папка года перечитываются раз в 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` без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется. @@ -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. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет. @@ -173,6 +188,7 @@ SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.x - `server/parser.js` — таблицы `.docx`, условия и преобразование `.doc`. - `server/index.js` — API и выдача собранной PWA. - `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса. +- `server/errors.js` — коды ошибок API и их HTTP-статусы. - `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов. - `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`. - `ios/Widgets/` — виджеты WidgetKit. diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Api.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Api.kt index f25ad6e..9de5e49 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Api.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Api.kt @@ -15,7 +15,15 @@ import java.io.File import java.io.IOException 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 { ignoreUnknownKeys = true @@ -44,14 +52,20 @@ class Api(baseUrl: String, cacheDir: File? = null) { client.newCall(Request.Builder().url(url).build()).execute().use { response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) { - val message = runCatching { - json.parseToJsonElement(text).jsonObject["error"]?.jsonPrimitive?.content - }.getOrNull() - // The server's catch-all 404 means it runs an older version without this endpoint. - if (response.code == 404 && path.startsWith("api/") && message == "Адрес не найден.") throw ApiException( + val body = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull() + val message = body?.get("error")?.jsonPrimitive?.content + // Servers before API version 5 send no code; their catch-all 404 has this text. + val code = body?.get("code")?.jsonPrimitive?.content + ?: if (response.code == 404 && message == "Адрес не найден.") "unknown_endpoint" else null + if (code == "unknown_endpoint") throw ApiException( "Сервер расписания не знает запрос /$path — на нём старая версия. Обновите сервер.", + code = code, + ) + throw ApiException( + message ?: "Сервер ответил ошибкой ${response.code}.", + offline = code in SOURCE_DOWN, + code = code, ) - throw ApiException(message ?: "Сервер ответил ошибкой ${response.code}.") } text } @@ -70,12 +84,12 @@ class Api(baseUrl: String, cacheDir: File? = null) { return call() } catch (e: IOException) { 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) wait *= 3 } } - throw ApiException(OFFLINE, offline = true) + throw ApiException(OFFLINE, offline = true, code = "offline") } private companion object { diff --git a/ios/PSUSchedule/API.swift b/ios/PSUSchedule/API.swift index 32087c5..9d0ba28 100644 --- a/ios/PSUSchedule/API.swift +++ b/ios/PSUSchedule/API.swift @@ -1,10 +1,13 @@ import Foundation 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 { var message: String var offline = false + var code: String? 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 offline = "Нет связи с сервером расписания." + /// Server codes after which trying again later can help. + private static let sourceDown: Set = ["source_unavailable", "source_timeout", "busy"] private static let retries = 3 // 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 } let url = components.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 { - let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String - // The server's catch-all 404 means it runs an older version without this endpoint. - if http.statusCode == 404 && message == "Адрес не найден." { - throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.") + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let message = body?["error"] as? String + // Servers before API version 5 send no code; their catch-all 404 has this text. + 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 { return try JSONDecoder().decode(T.self, from: data) @@ -74,7 +86,7 @@ struct API: Sendable { 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. diff --git a/server/calendar.js b/server/calendar.js index fcc6aba..6beb81f 100644 --- a/server/calendar.js +++ b/server/calendar.js @@ -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; } diff --git a/server/errors.js b/server/errors.js new file mode 100644 index 0000000..f9c0591 --- /dev/null +++ b/server/errors.js @@ -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: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.", + }; +} diff --git a/server/index.js b/server/index.js index b838d59..74bf1b2 100644 --- a/server/index.js +++ b/server/index.js @@ -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", () => { diff --git a/server/limits.js b/server/limits.js index 0de19fd..df2722c 100644 --- a/server/limits.js +++ b/server/limits.js @@ -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 { diff --git a/server/parser.js b/server/parser.js index 90a2929..8b65f6c 100644 --- a/server/parser.js +++ b/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) ? "Установочная сессия" diff --git a/server/source.js b/server/source.js index f13b16d..090ce6a 100644 --- a/server/source.js +++ b/server/source.js @@ -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 { diff --git a/server/timetable.js b/server/timetable.js index 1eecb9b..66eb838 100644 --- a/server/timetable.js +++ b/server/timetable.js @@ -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), diff --git a/test/errors.test.js b/test/errors.test.js new file mode 100644 index 0000000..aad4171 --- /dev/null +++ b/test/errors.test.js @@ -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); +}); diff --git a/test/fixtures/isf-credits-26-arh.docx b/test/fixtures/isf-credits-26-arh.docx new file mode 100644 index 0000000..5486c60 Binary files /dev/null and b/test/fixtures/isf-credits-26-arh.docx differ diff --git a/test/fixtures/itzs-summer-2026.docx b/test/fixtures/itzs-summer-2026.docx new file mode 100644 index 0000000..faa9127 Binary files /dev/null and b/test/fixtures/itzs-summer-2026.docx differ diff --git a/test/fixtures/itzs-winter-2024.docx b/test/fixtures/itzs-winter-2024.docx new file mode 100644 index 0000000..18b1284 Binary files /dev/null and b/test/fixtures/itzs-winter-2024.docx differ diff --git a/test/limits.test.js b/test/limits.test.js index c38c690..d009fd1 100644 --- a/test/limits.test.js +++ b/test/limits.test.js @@ -14,7 +14,7 @@ test("Импорт: не больше двух разборов сразу, ли active--; }); 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. while (release.length) { release.shift()(); @@ -47,6 +47,10 @@ test("Импорт: лимит запросов считается по адре }; 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); }); diff --git a/test/parser.test.js b/test/parser.test.js index bd4e966..5ecbd9f 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -160,3 +160,79 @@ test("DAV: метаданные вложенных документов и то assert.equal(file.course, "4 курс"); 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"], + ], + ); +}); diff --git a/test/timetable.test.js b/test/timetable.test.js index a9ea828..11810d9 100644 --- a/test/timetable.test.js +++ b/test/timetable.test.js @@ -58,3 +58,28 @@ test("range=semester: все дни текущего семестра от пе assert.equal(t.days.at(-1).date, t.semester.end); 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]); +});