feat: enhance lesson plan handling with new parser and color-coded room assignments

This commit is contained in:
2026-10-23 00:33:13 +03:00
parent c0487e4c2a
commit 02e6404731
11 changed files with 121 additions and 18 deletions

View File

@@ -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_PUBLIC_URL>; <PSU_CONTACT>)`. Например, `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_PUBLIC_URL>; <PSU_CONTACT>)`. Например, `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н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.

View File

@@ -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н",

View File

@@ -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 = [
"понедельник",
"вторник",

View File

@@ -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, " ");

View File

@@ -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,

8
shared/lessons.d.ts vendored
View File

@@ -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;

View File

@@ -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();

View File

@@ -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<Lesson["plan"]>[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({
</span>
<strong>{lesson.subject}</strong>
<span className="lesson-meta">
{lesson.teacher && (
{teacher && (
<span>
<Icon name="person" size={20} />
{lesson.teacher}
{teacher}
</span>
)}
{(lesson.room || lesson.place) && (
{(room || lesson.place) && (
<span>
<Icon name="location_on" size={20} />
{whereLabel(lesson, "Ауд.")}
{whereLabel({ ...lesson, room }, "Ауд.")}
</span>
)}
{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() {
<Icon name="location_on" size={20} />
<small>Аудитория</small>
<b>{details.room || "Не указана"}</b>
{details.plan?.some((p) => p.rooms.length || p.teachers.length) && (
<small className="detail-halves">
{details.plan.filter((p) => p.rooms.length || p.teachers.length).map(planLine).join("; ")}
</small>
)}
</span>
{details.place && (
<span>

View File

@@ -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[];

View File

@@ -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);
});

View File

@@ -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);
});