diff --git a/README.md b/README.md index f6b2e2f..6fb5c64 100644 --- a/README.md +++ b/README.md @@ -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 секундами. Обход каталога (около сотни папок) занимает несколько секунд. 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 9de5e49..20cabca 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 @@ -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 diff --git a/ios/PSUSchedule/API.swift b/ios/PSUSchedule/API.swift index 9d0ba28..ab31b70 100644 --- a/ios/PSUSchedule/API.swift +++ b/ios/PSUSchedule/API.swift @@ -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 = ["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 = ["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 diff --git a/server/index.js b/server/index.js index 0cdb997..6697093 100644 --- a/server/index.js +++ b/server/index.js @@ -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); } diff --git a/shared/updates.d.ts b/shared/updates.d.ts index df351e0..ef507e9 100644 --- a/shared/updates.d.ts +++ b/shared/updates.d.ts @@ -16,7 +16,7 @@ interface SavedLike { export function namesGroup(fileName: string, group: string): boolean; export function scheduleUpdate( 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; diff --git a/shared/updates.js b/shared/updates.js index 0de43b4..7f829bc 100644 --- a/shared/updates.js +++ b/shared/updates.js @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 5ae819b..fc94f90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); diff --git a/src/types.ts b/src/types.ts index ab6dcb1..8e1852e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/test/api.test.js b/test/api.test.js index a6ceca0..93fd512 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -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); diff --git a/test/sources.test.js b/test/sources.test.js index c2e1710..deb12c2 100644 --- a/test/sources.test.js +++ b/test/sources.test.js @@ -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"),