feat: enhance lesson plan handling with new parser and color-coded room assignments
This commit is contained in:
@@ -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` там же — время и ошибка последней проверки.
|
**Проверка каталога по 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`). На ней:
|
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
|
||||||
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
|
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
|
||||||
@@ -146,8 +146,8 @@ npm run sync
|
|||||||
**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`):
|
**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`):
|
||||||
- **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`.
|
- **Перенос на дату.** «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Д». Аудитория в конце такой строки относится ко всем её частям.
|
- **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям.
|
||||||
- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д».
|
- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д» и с цветом после аудитории — «ауд.533н-б.н.,420н-з.н.» (25-ДО, 26-ДО, 23-ДО, АРХ, ПГС, ТТиИ и др., парсер 15). Номер аудитории перед пометкой цвета («32н-з.н.») не принимается за 32-ю неделю.
|
||||||
- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
|
- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели; PWA — тоже (общий `planFor` из `shared/lessons.js`), а в режиме «Все строки Word» показывает аудитории обоих цветов: «533Н (бел. н.), 420Н (зел. н.)». Весь план виден в деталях занятия. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
|
||||||
|
|
||||||
**Ещё форматы ячеек:**
|
**Ещё форматы ячеек:**
|
||||||
- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
|
- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
weekday,
|
weekday,
|
||||||
weekIndexOf,
|
weekIndexOf,
|
||||||
} from "../shared/weeks.js";
|
} from "../shared/weeks.js";
|
||||||
|
import { planFor as planForWeek } from "../shared/lessons.js";
|
||||||
|
|
||||||
let index = null,
|
let index = null,
|
||||||
building = 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. */
|
/** The lesson's plan entries for a week `{n, color}`: by its number, its colour or both. */
|
||||||
const planFor = (entry, week) =>
|
const planFor = (entry, week) => planForWeek(entry.plan, week);
|
||||||
week && entry.plan?.length ? entry.plan.filter((p) => inWeeks(p.weeks, week.n) && (!p.color || p.color === week.color)) : [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rooms a lesson uses on a date: a dated move wins, then the room for that week ("1-9н. 502н",
|
* Rooms a lesson uses on a date: a dated move wins, then the room for that week ("1-9н. 502н",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
|
|||||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||||
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
export const PARSER_VERSION = 14;
|
export const PARSER_VERSION = 15;
|
||||||
const days = [
|
const days = [
|
||||||
"понедельник",
|
"понедельник",
|
||||||
"вторник",
|
"вторник",
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export function roomMoves(text, year) {
|
|||||||
return { moves, rest };
|
return { moves, rest };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.".
|
// A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.". Not a room
|
||||||
const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?(?![а-яё])\.?/giu;
|
// 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. */
|
/** A line cut where each week mark begins: "1-13н-332, 14-17н.-502н" → two parts. */
|
||||||
export function partsByWeek(text) {
|
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;
|
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.
|
// "бел.н.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;
|
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) =>
|
const roomsIn = (text) =>
|
||||||
tokens(text.replace(PERSON, " ").replace(/\([^)]*\)/g, " "))
|
tokens(text.replace(PERSON, " ").replace(/\([^)]*\)/g, " "))
|
||||||
@@ -83,9 +86,15 @@ export function weekPlan(text) {
|
|||||||
const rest = part.slice(mark.length);
|
const rest = part.slice(mark.length);
|
||||||
const teachers = [...rest.matchAll(PERSON)].map((m) => personName(m[0]));
|
const teachers = [...rest.matchAll(PERSON)].map((m) => personName(m[0]));
|
||||||
const color = rest.match(BY_COLOR);
|
const color = rest.match(BY_COLOR);
|
||||||
|
const byRoom = [...rest.matchAll(ROOM_COLOR)];
|
||||||
if (color) {
|
if (color) {
|
||||||
plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers });
|
plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers });
|
||||||
plan.push({ weeks, color: "green", rooms: roomsIn(color[2]), 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) {
|
} else if (mark) {
|
||||||
const rooms = roomsIn(rest);
|
const rooms = roomsIn(rest);
|
||||||
if (rooms.length || teachers.length) plan.push({ weeks, color: null, rooms, teachers });
|
if (rooms.length || teachers.length) plan.push({ weeks, color: null, rooms, teachers });
|
||||||
@@ -106,6 +115,7 @@ export function onlyPlace(text) {
|
|||||||
const left = (text || "")
|
const left = (text || "")
|
||||||
.replace(PERSON, " ")
|
.replace(PERSON, " ")
|
||||||
.replace(BY_COLOR, " ")
|
.replace(BY_COLOR, " ")
|
||||||
|
.replace(ROOM_COLOR, " ")
|
||||||
.replace(/(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/giu, " ")
|
.replace(/(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/giu, " ")
|
||||||
.replace(/ауд\.?|а\./giu, " ")
|
.replace(/ауд\.?|а\./giu, " ")
|
||||||
.replace(/[А-ЯA-Z]\d-\d{2}|\d{2,4}\s?[А-Яа-я]{0,2}(?![а-яё])/gu, " ");
|
.replace(/[А-ЯA-Z]\d-\d{2}|\d{2,4}\s?[А-Яа-я]{0,2}(?![а-яё])/gu, " ");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { request, MIN_REFRESH } from "./source.js";
|
|||||||
import { DATA } from "./paths.js";
|
import { DATA } from "./paths.js";
|
||||||
|
|
||||||
const data = DATA + "parsed/";
|
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
|
// 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.
|
// downloads a document at most once per MIN_REFRESH, and simultaneous ones share a download.
|
||||||
const fetchedAt = new Map(),
|
const fetchedAt = new Map(),
|
||||||
@@ -16,11 +18,12 @@ export async function scheduleFor(file, { refresh = false } = {}) {
|
|||||||
if (!refresh)
|
if (!refresh)
|
||||||
try {
|
try {
|
||||||
const cached = JSON.parse(await readFile(cache, "utf8"));
|
const cached = JSON.parse(await readFile(cache, "utf8"));
|
||||||
if (
|
if (cached.source.modified === file.modified) {
|
||||||
cached.schemaVersion === PARSER_VERSION &&
|
if (cached.schemaVersion === PARSER_VERSION) return cached;
|
||||||
cached.source.modified === file.modified
|
// The same file read by an older parser: parse the saved copy again.
|
||||||
)
|
const saved = await readFile(originals + file.id).catch(() => null);
|
||||||
return cached;
|
if (saved) return parse(file, saved, cache);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!loading.has(file.id))
|
if (!loading.has(file.id))
|
||||||
loading.set(
|
loading.set(
|
||||||
@@ -33,6 +36,12 @@ export async function scheduleFor(file, { refresh = false } = {}) {
|
|||||||
async function download(file, cache) {
|
async function download(file, cache) {
|
||||||
const buffer = await request(file.path);
|
const buffer = await request(file.path);
|
||||||
fetchedAt.set(file.id, Date.now());
|
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, {
|
const parsed = await parseWord(buffer, {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
|||||||
8
shared/lessons.d.ts
vendored
8
shared/lessons.d.ts
vendored
@@ -13,6 +13,14 @@ interface LessonLike {
|
|||||||
groups: string[];
|
groups: string[];
|
||||||
subgroups: { group: string; label: 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 {
|
export interface FieldChange {
|
||||||
field: string;
|
field: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export function personalFilter(lesson, group, prefs) {
|
|||||||
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
|
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
|
||||||
return "shown";
|
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. */
|
/** Subgroup labels present for a group, in column order. */
|
||||||
export function subgroupsOf(schedule, group) {
|
export function subgroupsOf(schedule, group) {
|
||||||
const labels = new Set();
|
const labels = new Set();
|
||||||
|
|||||||
46
src/App.tsx
46
src/App.tsx
@@ -58,6 +58,7 @@ import {
|
|||||||
diffSchedules,
|
diffSchedules,
|
||||||
lessonKey,
|
lessonKey,
|
||||||
personalFilter,
|
personalFilter,
|
||||||
|
planFor,
|
||||||
subgroupsOf,
|
subgroupsOf,
|
||||||
} from "../shared/lessons";
|
} from "../shared/lessons";
|
||||||
import { placesFor, placesOn, type DayPlace } from "../shared/places";
|
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. */
|
/** Minutes since midnight when the list shows today; drives the live pair chip. */
|
||||||
clock: number | null;
|
clock: number | null;
|
||||||
isHidden: (lesson: Lesson) => boolean;
|
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) =>
|
const duration = (m: number) =>
|
||||||
m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч${m % 60 ? ` ${m % 60} мин` : ""}`;
|
m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч${m % 60 ? ` ${m % 60} мин` : ""}`;
|
||||||
function LessonCard({
|
function LessonCard({
|
||||||
@@ -163,6 +190,7 @@ function LessonCard({
|
|||||||
}) {
|
}) {
|
||||||
const theme = lessonTheme(lesson.type);
|
const theme = lessonTheme(lesson.type);
|
||||||
const subgroup = lesson.subgroups.find((item) => item.group === ctx.group);
|
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 dates = ctx.calendar && hasWeekLimit(lesson) ? ctx.datesOf(lesson) : undefined;
|
||||||
const hidden = ctx.isHidden(lesson);
|
const hidden = ctx.isHidden(lesson);
|
||||||
return (
|
return (
|
||||||
@@ -192,16 +220,16 @@ function LessonCard({
|
|||||||
</span>
|
</span>
|
||||||
<strong>{lesson.subject}</strong>
|
<strong>{lesson.subject}</strong>
|
||||||
<span className="lesson-meta">
|
<span className="lesson-meta">
|
||||||
{lesson.teacher && (
|
{teacher && (
|
||||||
<span>
|
<span>
|
||||||
<Icon name="person" size={20} />
|
<Icon name="person" size={20} />
|
||||||
{lesson.teacher}
|
{teacher}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(lesson.room || lesson.place) && (
|
{(room || lesson.place) && (
|
||||||
<span>
|
<span>
|
||||||
<Icon name="location_on" size={20} />
|
<Icon name="location_on" size={20} />
|
||||||
{whereLabel(lesson, "Ауд.")}
|
{whereLabel({ ...lesson, room }, "Ауд.")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subgroup && (
|
{subgroup && (
|
||||||
@@ -1062,6 +1090,11 @@ export default function App() {
|
|||||||
today,
|
today,
|
||||||
clock: showsToday ? clock : null,
|
clock: showsToday ? clock : null,
|
||||||
isHidden,
|
isHidden,
|
||||||
|
week: calendarMode
|
||||||
|
? { n: week!.n, color: week!.color }
|
||||||
|
: mode === "upper" || mode === "lower"
|
||||||
|
? { n: null, color: mode === "upper" ? "white" : "green" }
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
const scheduleDated =
|
const scheduleDated =
|
||||||
!!schedule?.lessons.length && schedule.lessons.every((l) => l.date);
|
!!schedule?.lessons.length && schedule.lessons.every((l) => l.date);
|
||||||
@@ -2205,6 +2238,11 @@ export default function App() {
|
|||||||
<Icon name="location_on" size={20} />
|
<Icon name="location_on" size={20} />
|
||||||
<small>Аудитория</small>
|
<small>Аудитория</small>
|
||||||
<b>{details.room || "Не указана"}</b>
|
<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>
|
</span>
|
||||||
{details.place && (
|
{details.place && (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { WeekRange } from "../shared/weeks";
|
import type { WeekRange } from "../shared/weeks";
|
||||||
import type { PlaceNote } from "../shared/places";
|
import type { PlaceNote } from "../shared/places";
|
||||||
|
import type { PlanEntry } from "../shared/lessons";
|
||||||
export interface Lesson {
|
export interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
day: number;
|
day: number;
|
||||||
@@ -12,6 +13,8 @@ export interface Lesson {
|
|||||||
room: string;
|
room: string;
|
||||||
/** "Полоцк": the town, when the cell or the day's note names one (parser 14). */
|
/** "Полоцк": the town, when the cell or the day's note names one (parser 14). */
|
||||||
place?: string;
|
place?: string;
|
||||||
|
/** Rooms and teachers by week number or colour: "1-9н. 502н", "ауд.533н-б.н.,420н-з.н.". */
|
||||||
|
plan?: PlanEntry[];
|
||||||
type: string;
|
type: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, before, after } from "node:test";
|
import { test, before, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { setTimeout as sleep } from "node:timers/promises";
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { startApi } from "./support/nextcloud.js";
|
import { startApi } from "./support/nextcloud.js";
|
||||||
|
|
||||||
// The university is asked politely: every request names the service and its owner, and while
|
// 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.equal(catalog.failingSince, null);
|
||||||
assert.ok(!(await (await fetch(api.base + "/api/catalog")).json()).stale);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ test("Преподаватель по неделям и аудитория по
|
|||||||
const byColor = peopleAndRooms({ raw: lesson.text, room: "", teacher: "", plan: lesson.plan }, "2026");
|
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-10", { n: 11, color: "white" }), ["504Н"]);
|
||||||
assert.deepEqual(roomsOn(byColor, "2026-11-17", { n: 12, color: "green" }), ["524Н"]);
|
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("1-9н. 502н"), true);
|
||||||
assert.equal(onlyPlace("13,15 н. РБ (л.з) Сивацкий Д.С.209н"), false);
|
assert.equal(onlyPlace("13,15 н. РБ (л.з) Сивацкий Д.С.209н"), false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user