diff --git a/README.md b/README.md index dc47da3..664bbee 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` **Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки. -**Вежливость к университету.** Каждый запрос к Nextcloud представляется: `User-Agent: PSU-Schedule/0.1.0 (+; )`. Например, `PSU_CONTACT=mailto:you@example.com` — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, `502`–`504`, `429`, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (`PSU_SOURCE_BACKOFF_MS`, `PSU_SOURCE_BACKOFF_MAX_MS`), и учитывает `Retry-After`. Первый же ответ её снимает. Ошибка одного документа (`404`, `500`) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в `/status/data.json` — `catalog.backoff`. +**Вежливость к университету.** Каждый запрос к Nextcloud представляется: `User-Agent: PSU-Schedule/0.1.0 (+; )`. Например, `PSU_CONTACT=mailto:you@example.com` — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, `502`–`504`, `429`, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (`PSU_SOURCE_BACKOFF_MS`, `PSU_SOURCE_BACKOFF_MAX_MS`), и учитывает `Retry-After`. Первый же ответ её снимает. Скачанные документы сервер хранит у себя (`.data/documents/`), поэтому новая версия парсера перечитывает их локально, не обращаясь к университету. Ошибка одного документа (`404`, `500`) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в `/status/data.json` — `catalog.backoff`. **Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней: - **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram; @@ -146,8 +146,8 @@ npm run sync **Аудитории и преподаватели по неделям и датам** (`server/rooms.js`): - **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`. - **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям. -- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д». -- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель. +- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д» и с цветом после аудитории — «ауд.533н-б.н.,420н-з.н.» (25-ДО, 26-ДО, 23-ДО, АРХ, ПГС, ТТиИ и др., парсер 15). Номер аудитории перед пометкой цвета («32н-з.н.») не принимается за 32-ю неделю. +- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели; PWA — тоже (общий `planFor` из `shared/lessons.js`), а в режиме «Все строки Word» показывает аудитории обоих цветов: «533Н (бел. н.), 420Н (зел. н.)». Весь план виден в деталях занятия. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель. **Ещё форматы ячеек:** - **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом. diff --git a/server/directory.js b/server/directory.js index ee4be1e..81ac81c 100644 --- a/server/directory.js +++ b/server/directory.js @@ -12,6 +12,7 @@ import { weekday, weekIndexOf, } from "../shared/weeks.js"; +import { planFor as planForWeek } from "../shared/lessons.js"; let index = null, building = null; @@ -87,10 +88,8 @@ export function peopleAndRooms(lesson, year) { }; } -const inWeeks = (ranges, n) => !ranges || (n != null && ranges.some(([a, b]) => n >= a && (b === null || n <= b))); /** The lesson's plan entries for a week `{n, color}`: by its number, its colour or both. */ -const planFor = (entry, week) => - week && entry.plan?.length ? entry.plan.filter((p) => inWeeks(p.weeks, week.n) && (!p.color || p.color === week.color)) : []; +const planFor = (entry, week) => planForWeek(entry.plan, week); /** * Rooms a lesson uses on a date: a dated move wins, then the room for that week ("1-9н. 502н", diff --git a/server/parser.js b/server/parser.js index fec2a94..66647df 100644 --- a/server/parser.js +++ b/server/parser.js @@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js"; import { placeNote, lessonPlace, placeOf } from "./places.js"; const run = promisify(execFile); -export const PARSER_VERSION = 14; +export const PARSER_VERSION = 15; const days = [ "понедельник", "вторник", diff --git a/server/rooms.js b/server/rooms.js index 7cfda59..0a9ea78 100644 --- a/server/rooms.js +++ b/server/rooms.js @@ -51,8 +51,9 @@ export function roomMoves(text, year) { return { moves, rest }; } -// A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.". -const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?(?![а-яё])\.?/giu; +// A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.". Not a room +// with its week's colour: "32н-з.н." is room 32Н in green weeks. +const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?(?![а-яё])(?!\.?\s*[-–]?\s*[бз](?:ел)?\.?\s*н)\.?/giu; /** A line cut where each week mark begins: "1-13н-332, 14-17н.-502н" → two parts. */ export function partsByWeek(text) { @@ -63,6 +64,8 @@ export function partsByWeek(text) { const PART_MARK = /^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/iu; // "бел.н.504н/зел.н.524н", "б.н.301Г/з.н.317Д": a room for white weeks and one for green ones. const BY_COLOR = /б(?:ел)?\.?\s*н(?:ед)?\.?\s*([^/\s]+)\s*\/\s*з(?:ел)?\.?\s*н(?:ед)?\.?\s*([^\s,;]+)/iu; +// "ауд.533н-б.н.,420н-з.н.": the colour after each room. +const ROOM_COLOR = new RegExp(`(${ROOM_TEXT})\\s*[-–]?\\s*(б|з)(?:ел)?\\.?\\s*н(?:ед)?\\.?(?![а-яё])`, "giu"); const roomsIn = (text) => tokens(text.replace(PERSON, " ").replace(/\([^)]*\)/g, " ")) @@ -83,9 +86,15 @@ export function weekPlan(text) { const rest = part.slice(mark.length); const teachers = [...rest.matchAll(PERSON)].map((m) => personName(m[0])); const color = rest.match(BY_COLOR); + const byRoom = [...rest.matchAll(ROOM_COLOR)]; if (color) { plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers }); plan.push({ weeks, color: "green", rooms: roomsIn(color[2]), teachers }); + } else if (byRoom.length) { + for (const [name, letter] of [["white", "б"], ["green", "з"]]) { + const rooms = byRoom.filter((m) => m[2].toLowerCase() === letter).map((m) => roomKey(m[1])); + if (rooms.length) plan.push({ weeks, color: name, rooms, teachers }); + } } else if (mark) { const rooms = roomsIn(rest); if (rooms.length || teachers.length) plan.push({ weeks, color: null, rooms, teachers }); @@ -106,6 +115,7 @@ export function onlyPlace(text) { const left = (text || "") .replace(PERSON, " ") .replace(BY_COLOR, " ") + .replace(ROOM_COLOR, " ") .replace(/(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/giu, " ") .replace(/ауд\.?|а\./giu, " ") .replace(/[А-ЯA-Z]\d-\d{2}|\d{2,4}\s?[А-Яа-я]{0,2}(?![а-яё])/gu, " "); diff --git a/server/schedules.js b/server/schedules.js index 96eb4fd..ef32a65 100644 --- a/server/schedules.js +++ b/server/schedules.js @@ -4,6 +4,8 @@ import { request, MIN_REFRESH } from "./source.js"; import { DATA } from "./paths.js"; const data = DATA + "parsed/"; +// The downloaded Word files: a new parser reads them again without asking the university. +const originals = DATA + "documents/"; // When each document was last downloaded, and the downloads under way: a forced refresh // downloads a document at most once per MIN_REFRESH, and simultaneous ones share a download. const fetchedAt = new Map(), @@ -16,11 +18,12 @@ export async function scheduleFor(file, { refresh = false } = {}) { if (!refresh) try { const cached = JSON.parse(await readFile(cache, "utf8")); - if ( - cached.schemaVersion === PARSER_VERSION && - cached.source.modified === file.modified - ) - return cached; + if (cached.source.modified === file.modified) { + if (cached.schemaVersion === PARSER_VERSION) return cached; + // The same file read by an older parser: parse the saved copy again. + const saved = await readFile(originals + file.id).catch(() => null); + if (saved) return parse(file, saved, cache); + } } catch {} if (!loading.has(file.id)) loading.set( @@ -33,6 +36,12 @@ export async function scheduleFor(file, { refresh = false } = {}) { async function download(file, cache) { const buffer = await request(file.path); fetchedAt.set(file.id, Date.now()); + await mkdir(originals, { recursive: true }); + await writeFile(originals + file.id, buffer); + return parse(file, buffer, cache); +} + +async function parse(file, buffer, cache) { const parsed = await parseWord(buffer, { name: file.name, url: file.url, diff --git a/shared/lessons.d.ts b/shared/lessons.d.ts index 77df32b..05090f3 100644 --- a/shared/lessons.d.ts +++ b/shared/lessons.d.ts @@ -13,6 +13,14 @@ interface LessonLike { groups: string[]; subgroups: { group: string; label: string }[]; } +/** Rooms and teachers of a lesson for some weeks, by number or colour (parser's `plan`). */ +export interface PlanEntry { + weeks: [number, number | null][] | null; + color: "white" | "green" | null; + rooms: string[]; + teachers: string[]; +} +export function planFor(plan: PlanEntry[] | null | undefined, week: { n: number | null; color: string } | null | undefined): PlanEntry[]; export interface FieldChange { field: string; label: string; diff --git a/shared/lessons.js b/shared/lessons.js index 7c0e4fe..95c1009 100644 --- a/shared/lessons.js +++ b/shared/lessons.js @@ -95,6 +95,14 @@ export function personalFilter(lesson, group, prefs) { if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden"; return "shown"; } +const inWeeks = (ranges, n) => !ranges || (n != null && ranges.some(([a, b]) => n >= a && (b === null || n <= b))); +/** + * A lesson's rooms and teachers by week ("1-9н. 502н", "бел.н.504н/зел.н.524н", + * "ауд.533н-б.н.,420н-з.н."): the plan entries for a week `{n, color}`, by its number, its colour + * or both. Shared by the server's timetable and the browser. + */ +export const planFor = (plan, week) => + week && plan?.length ? plan.filter((p) => inWeeks(p.weeks, week.n) && (!p.color || p.color === week.color)) : []; /** Subgroup labels present for a group, in column order. */ export function subgroupsOf(schedule, group) { const labels = new Set(); diff --git a/src/App.tsx b/src/App.tsx index 7bebb98..d9d4cd9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ import { diffSchedules, lessonKey, personalFilter, + planFor, subgroupsOf, } from "../shared/lessons"; import { placesFor, placesOn, type DayPlace } from "../shared/places"; @@ -149,7 +150,33 @@ interface ListContext { /** Minutes since midnight when the list shows today; drives the live pair chip. */ clock: number | null; isHidden: (lesson: Lesson) => boolean; + /** The week shown, or only its colour for white or green Word rows; null for all rows. */ + week: { n: number | null; color: string } | null; } +const unique = (list: string[]) => [...new Set(list)]; +const weeksLabel = (ranges: [number, number | null][]) => + "недели " + ranges.map(([a, b]) => (b === null ? `с ${a}` : a === b ? `${a}` : `${a}–${b}`)).join(", "); +/** + * Room and teacher of a lesson in a week, when Word names them by week ("1-9н. 502н", + * "ауд.533н-б.н.,420н-з.н."). For all Word rows at once: the rooms of each week colour. + */ +function inWeek(lesson: Lesson, week: ListContext["week"]) { + const planned = planFor(lesson.plan, week); + const rooms = unique(planned.flatMap((p) => p.rooms)), + teachers = unique(planned.flatMap((p) => p.teachers)); + const byColor = !week && !lesson.plan?.some((p) => p.weeks) + ? (lesson.plan || []).filter((p) => p.color && p.rooms.length).map((p) => `${p.rooms.join(", ")} (${p.color === "white" ? "бел." : "зел."} н.)`) + : []; + return { + room: rooms.join(", ") || byColor.join(", ") || lesson.room, + teacher: teachers.join(", ") || lesson.teacher, + }; +} +/** "533Н — белые недели", "502Н — недели 1–9": the plan for the details. */ +const planLine = (p: NonNullable[number]) => + [p.rooms.join(", "), p.teachers.join(", ")].filter(Boolean).join(", ") + + " — " + + [p.weeks && weeksLabel(p.weeks), p.color && (p.color === "white" ? "белые недели" : "зелёные недели")].filter(Boolean).join(", "); const duration = (m: number) => m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч${m % 60 ? ` ${m % 60} мин` : ""}`; function LessonCard({ @@ -163,6 +190,7 @@ function LessonCard({ }) { const theme = lessonTheme(lesson.type); const subgroup = lesson.subgroups.find((item) => item.group === ctx.group); + const { room, teacher } = inWeek(lesson, ctx.week); const dates = ctx.calendar && hasWeekLimit(lesson) ? ctx.datesOf(lesson) : undefined; const hidden = ctx.isHidden(lesson); return ( @@ -192,16 +220,16 @@ function LessonCard({ {lesson.subject} - {lesson.teacher && ( + {teacher && ( - {lesson.teacher} + {teacher} )} - {(lesson.room || lesson.place) && ( + {(room || lesson.place) && ( - {whereLabel(lesson, "Ауд.")} + {whereLabel({ ...lesson, room }, "Ауд.")} )} {subgroup && ( @@ -1062,6 +1090,11 @@ export default function App() { today, clock: showsToday ? clock : null, isHidden, + week: calendarMode + ? { n: week!.n, color: week!.color } + : mode === "upper" || mode === "lower" + ? { n: null, color: mode === "upper" ? "white" : "green" } + : null, }); const scheduleDated = !!schedule?.lessons.length && schedule.lessons.every((l) => l.date); @@ -2205,6 +2238,11 @@ export default function App() { Аудитория {details.room || "Не указана"} + {details.plan?.some((p) => p.rooms.length || p.teachers.length) && ( + + {details.plan.filter((p) => p.rooms.length || p.teachers.length).map(planLine).join("; ")} + + )} {details.place && ( diff --git a/src/types.ts b/src/types.ts index febe93c..bb38e7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { WeekRange } from "../shared/weeks"; import type { PlaceNote } from "../shared/places"; +import type { PlanEntry } from "../shared/lessons"; export interface Lesson { id: string; day: number; @@ -12,6 +13,8 @@ export interface Lesson { room: string; /** "Полоцк": the town, when the cell or the day's note names one (parser 14). */ place?: string; + /** Rooms and teachers by week number or colour: "1-9н. 502н", "ауд.533н-б.н.,420н-з.н.". */ + plan?: PlanEntry[]; type: string; condition: string; groups: string[]; diff --git a/test/backoff.test.js b/test/backoff.test.js index 3b0400c..057a495 100644 --- a/test/backoff.test.js +++ b/test/backoff.test.js @@ -1,6 +1,7 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { setTimeout as sleep } from "node:timers/promises"; +import { readFileSync, writeFileSync } from "node:fs"; import { startApi } from "./support/nextcloud.js"; // The university is asked politely: every request names the service and its owner, and while @@ -62,3 +63,17 @@ test("Источник: пока университет не отвечает, assert.equal(catalog.failingSince, null); assert.ok(!(await (await fetch(api.base + "/api/catalog")).json()).stale); }); + +test("Источник: новый парсер перечитывает сохранённый документ, не обращаясь к университету", async () => { + const { files } = await (await fetch(api.base + "/api/catalog")).json(); + const file = files.find((f) => f.name === "23-ЛГ_23-БА.docx"); + assert.equal((await fetch(api.base + "/api/schedule/" + file.id)).status, 200); + // The cache as an older parser left it. + const cache = `${process.env.PSU_DATA_DIR}/parsed/${file.id}.json`; + writeFileSync(cache, JSON.stringify({ ...JSON.parse(readFileSync(cache, "utf8")), schemaVersion: 1 })); + const requests = api.stats.requests; + const schedule = await (await fetch(api.base + "/api/schedule/" + file.id)).json(); + assert.equal(api.stats.requests, requests); + assert.notEqual(schedule.schemaVersion, 1); + assert.ok(schedule.lessons.length > 0); +}); diff --git a/test/formats.test.js b/test/formats.test.js index 6441fa4..ca06360 100644 --- a/test/formats.test.js +++ b/test/formats.test.js @@ -53,6 +53,19 @@ test("Преподаватель по неделям и аудитория по const byColor = peopleAndRooms({ raw: lesson.text, room: "", teacher: "", plan: lesson.plan }, "2026"); assert.deepEqual(roomsOn(byColor, "2026-11-10", { n: 11, color: "white" }), ["504Н"]); assert.deepEqual(roomsOn(byColor, "2026-11-17", { n: 12, color: "green" }), ["524Н"]); + // The colour after each room (25-ДО): "ауд.533н-б.н.,420н-з.н.". + const [after] = entries("Теоретические основы воспитания и развития речи детей раннего возраста (п.з.) Савейко В.Л. ауд.533н-б.н.,420н-з.н."); + assert.deepEqual(after.plan, [ + { weeks: null, color: "white", rooms: ["533Н"], teachers: ["Савейко В.Л."] }, + { weeks: null, color: "green", rooms: ["420Н"], teachers: ["Савейко В.Л."] }, + ]); + const roomAfter = peopleAndRooms({ raw: after.text, room: "533н", teacher: "Савейко В.Л.", plan: after.plan }, "2026"); + assert.deepEqual(roomsOn(roomAfter, "2026-10-30", { n: 9, color: "white" }), ["533Н"]); + assert.deepEqual(roomsOn(roomAfter, "2026-10-23", { n: 8, color: "green" }), ["420Н"]); + assert.deepEqual(weekPlan("ауд. 420н з.н., 533н б.н.").map((p) => [p.color, p.rooms]), [["white", ["533Н"]], ["green", ["420Н"]]]); + assert.equal(onlyPlace("533н-б.н.,420н-з.н."), true); + // "32н" before its colour is a room, not week 32. + assert.deepEqual(weekPlan("Коррекционная дошкольная педагогика (л.) Остапчук С.В. ауд.64н -б.н., 32н-з.н.").map((p) => [p.color, p.rooms]), [["white", ["64Н"]], ["green", ["32Н"]]]); assert.equal(onlyPlace("1-9н. 502н"), true); assert.equal(onlyPlace("13,15 н. РБ (л.з) Сивацкий Д.С.209н"), false); });