feat: Update API versioning and parser handling for improved schedule updates and error management
This commit is contained in:
@@ -47,13 +47,13 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
|
||||
|
||||
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
|
||||
|
||||
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
|
||||
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
|
||||
|
||||
Каталог и папка года перечитываются раз в 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/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` оставляют на экране сохранённую копию и показывают «Повторить».
|
||||
Ошибки приходят как `{ "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` и `timeout` (504, университет или сам сервер не ответили вовремя), `internal` (500). Полный список — в `server/errors.js`. Android и iOS при `source_unavailable`, `source_timeout`, `timeout`, `busy` и `too_many_requests` оставляют на экране сохранённую копию и показывают «Повторить».
|
||||
|
||||
К каталогу университета сервер обращается встроенным в Node `fetch`: соединения переиспользуются, сертификаты проверяются, перенаправления не принимаются, ответ ограничен 10 МБ и 25 секундами. Обход каталога (около сотни папок) занимает несколько секунд.
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
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")
|
||||
/**
|
||||
* Server codes after which trying again later can help: the university is down or slow, the
|
||||
* server is busy or did not answer in time, or this address sent too many requests.
|
||||
*/
|
||||
private val SOURCE_DOWN = setOf("source_unavailable", "source_timeout", "busy", "timeout", "too_many_requests")
|
||||
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
|
||||
@@ -28,8 +28,9 @@ 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<String> = ["source_unavailable", "source_timeout", "busy"]
|
||||
/// Server codes after which trying again later can help: the university is down or slow, the
|
||||
/// server is busy or did not answer in time, or this address sent too many requests.
|
||||
private static let sourceDown: Set<String> = ["source_unavailable", "source_timeout", "busy", "timeout", "too_many_requests"]
|
||||
private static let retries = 3
|
||||
|
||||
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again
|
||||
|
||||
@@ -66,8 +66,9 @@ app.use("/api", (_req, res, next) => {
|
||||
// 6 — the timetable carries the group's exams and credits from other catalog documents;
|
||||
// 7 — /api/diagnostics, cells the parser could not read with confidence;
|
||||
// 8 — /api/ready, and `ready` in /api/health;
|
||||
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers.
|
||||
const API_VERSION = 9;
|
||||
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers;
|
||||
// 10 — parserVersion in /api/catalog.
|
||||
const API_VERSION = 10;
|
||||
|
||||
/**
|
||||
* What the server still waits for before it answers from memory: the catalog, the week table
|
||||
@@ -221,7 +222,8 @@ app.get("/api/diagnostics", async (req, res, next) => {
|
||||
});
|
||||
app.get("/api/catalog", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getCatalog(req.query.refresh === "1"));
|
||||
// The parser version lets the PWA re-read schedules it saved from an older parser.
|
||||
res.json({ ...(await getCatalog(req.query.refresh === "1")), parserVersion: PARSER_VERSION });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
||||
2
shared/updates.d.ts
vendored
2
shared/updates.d.ts
vendored
@@ -16,7 +16,7 @@ interface SavedLike {
|
||||
export function namesGroup(fileName: string, group: string): boolean;
|
||||
export function scheduleUpdate<F extends CatalogFileLike>(
|
||||
saved: SavedLike | null | undefined,
|
||||
catalog: { files: F[]; stale?: boolean } | null | undefined,
|
||||
catalog: { files: F[]; stale?: boolean; parserVersion?: number } | null | undefined,
|
||||
group: string,
|
||||
options?: { dismissed?: string[]; schemaVersion?: number },
|
||||
): { changed: F | null; newer: F | null; missing: boolean } | null;
|
||||
|
||||
@@ -15,12 +15,15 @@ export function namesGroup(fileName, group) {
|
||||
}
|
||||
|
||||
/**
|
||||
* changed: the same document was republished (or parsed by an older version) — reload it quietly.
|
||||
* changed: the same document was republished, or parsed by an older parser than the server's
|
||||
* (`catalog.parserVersion`, from API version 10) — reload it quietly.
|
||||
* newer: another document for this group appeared later, e.g. the next semester — offer it.
|
||||
* missing: the document left the catalog, e.g. a new academic year began.
|
||||
*/
|
||||
export function scheduleUpdate(saved, catalog, group, options = {}) {
|
||||
const { dismissed = [], schemaVersion = 0 } = options;
|
||||
// A server without parserVersion never asks for a re-read: comparing with a guess could
|
||||
// reload the same document on every check.
|
||||
const { dismissed = [], schemaVersion = catalog?.parserVersion ?? 0 } = options;
|
||||
const fileId = saved?.source?.fileId;
|
||||
if (!fileId || !catalog?.files || catalog.stale) return null;
|
||||
const current = catalog.files.find((f) => f.id === fileId) || null;
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
subgroupsOf,
|
||||
} from "../shared/lessons";
|
||||
import qrcode from "qrcode-generator";
|
||||
/** Matches PARSER_VERSION on the server: older saved schedules are re-read once. */
|
||||
const SCHEMA_VERSION = 7;
|
||||
const SOURCE =
|
||||
"https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027";
|
||||
const DAYS = [
|
||||
@@ -710,10 +708,8 @@ export default function App() {
|
||||
lastCheck.current = Date.now();
|
||||
const c = known === undefined ? await loadCatalog() : known;
|
||||
if (!c || !schedule) return;
|
||||
const found = scheduleUpdate(schedule, c, group, {
|
||||
dismissed: readDismissed(),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
});
|
||||
// A schedule read by an older parser than the server's (c.parserVersion) is re-read once.
|
||||
const found = scheduleUpdate(schedule, c, group, { dismissed: readDismissed() });
|
||||
if (!found) return setUpdate(null);
|
||||
// activate() reports what changed for the group.
|
||||
if (found.changed) await loadFile(found.changed, false, group);
|
||||
|
||||
@@ -61,4 +61,6 @@ export interface Catalog {
|
||||
year: string;
|
||||
currentYear?: boolean;
|
||||
expectedYear?: string;
|
||||
/** The server's parser: saved schedules read by an older one are re-read (API 10+). */
|
||||
parserVersion?: number;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ test("API: ошибки с машиночитаемым кодом", async () =>
|
||||
|
||||
test("API: исходный документ, сбой и таймаут университета", async () => {
|
||||
const catalog = (await json("/api/catalog")).body;
|
||||
assert.equal(typeof catalog.parserVersion, "number");
|
||||
const id = (name) => catalog.files.find((f) => f.name === name).id;
|
||||
const original = await get(`/api/original/${id("23-ЛГ_23-БА.docx")}`);
|
||||
assert.equal(original.status, 200);
|
||||
|
||||
@@ -156,6 +156,12 @@ test("Обновления: тот же файл перечитывается,
|
||||
scheduleUpdate({ ...saved, schemaVersion: 4 }, { files: [base] }, "23-БА", options).changed,
|
||||
base,
|
||||
);
|
||||
// Without options the server's parser version from the catalog decides; an old server
|
||||
// without it never triggers a re-read.
|
||||
const parsed11 = { ...saved, schemaVersion: 11 };
|
||||
assert.equal(scheduleUpdate(parsed11, { files: [base], parserVersion: 12 }, "23-БА").changed, base);
|
||||
assert.equal(scheduleUpdate(parsed11, { files: [base], parserVersion: 11 }, "23-БА"), null);
|
||||
assert.equal(scheduleUpdate(parsed11, { files: [base] }, "23-БА"), null);
|
||||
const spring = file("b", "23-БА весна.docx", "Mon, 25 Jan 2027 09:00:00 GMT");
|
||||
const others = [
|
||||
file("c", "23-БАз.docx", "Mon, 25 Jan 2027 09:00:00 GMT"),
|
||||
|
||||
Reference in New Issue
Block a user