initial commit

This commit is contained in:
2026-10-16 22:08:45 +03:00
commit 19ee41411b
97 changed files with 15141 additions and 0 deletions

17
.claude/launch.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "vite",
"runtimeExecutable": "env",
"runtimeArgs": ["API_PORT=3002", "npx", "vite", "--host", "127.0.0.1", "--port", "5173"],
"port": 5173
},
{
"name": "api",
"runtimeExecutable": "env",
"runtimeArgs": ["PORT=3002", "node", "--watch", "server/index.js"],
"port": 3002
}
]
}

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.data
.git
test
*.tsbuildinfo
.impeccable
.claude
artifacts
.env
*.log
.DS_Store

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.data/
*.tsbuildinfo
.DS_Store

34
.impeccable/config.json Normal file
View File

@@ -0,0 +1,34 @@
{
"buildPath": "code",
"hook": {
"enabled": true,
"limits": {
"maxFindings": 5,
"maxChars": 8000
}
},
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "overused-font",
"value": "roboto flex",
"createdAt": "2026-10-16T16:58:25.598Z",
"reason": "Agent: surface brief pins Material 3 Expressive with Roboto Flex (user-confirmed in PRODUCT.md)"
},
{
"rule": "bounce-easing",
"value": "cubic-bezier(0.42, 1.67, 0.21, 0.9)",
"createdAt": "2026-10-16T16:58:25.615Z",
"reason": "Agent: M3 Expressive fast-spatial spring token, used only for the day-indicator shape morph"
},
{
"rule": "overused-font",
"value": "roboto",
"createdAt": "2026-10-16T16:58:52.322Z",
"reason": "Agent: surface brief pins Material 3 Expressive with Roboto Flex (PRODUCT.md, user-confirmed); Roboto is its system fallback"
}
]
}
}

View File

@@ -0,0 +1,5 @@
{
"hook": {
"consent": "accepted"
}
}

View File

@@ -0,0 +1 @@
{"version":1,"sessions":{"01a13c7f-2064-7712-a08f-c0512bba9197":{"updatedAt":1792083512470,"files":{"/Users/xtimms/Projects/psu-schedule/src/App.tsx":{"editCount":1,"findings":[],"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]}}},"517f765a-68b5-47d1-b966-31dd338440a6":{"updatedAt":1792177361129,"files":{"/Users/xtimms/Projects/psu-schedule/src/icons.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]},"/Users/xtimms/Projects/psu-schedule/shared/weeks.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/calendar.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/calendar.ts":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/source.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/sources.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/lessons.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/schedules.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/directory.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Search.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/test/directory.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Groups.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true}},"footerShown":true}}}

View File

@@ -0,0 +1,6 @@
{
"files": ["index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,12 @@
[
{
"antipattern": "overused-font",
"name": "Overused font",
"description": "Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.",
"severity": "warning",
"category": "slop",
"file": "/Users/xtimms/Projects/psu-schedule/src/styles.css",
"line": 2,
"snippet": "font-family: \"Roboto"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,42 @@
[
{
"bodyFont": "\"Roboto Flex\", Roboto, system-ui, sans-serif",
"conditionSize": "14px",
"lessons": 11,
"metaSize": "16px",
"scrollWidth": 1440,
"slots": 5,
"subjectSize": "20px",
"width": 1440
},
{
"bodyFont": "\"Roboto Flex\", Roboto, system-ui, sans-serif",
"conditionSize": "14px",
"lessons": 11,
"metaSize": "16px",
"scrollWidth": 390,
"slots": 5,
"subjectSize": "20px",
"width": 390
},
{
"bodyFont": "\"Roboto Flex\", Roboto, system-ui, sans-serif",
"conditionSize": "14px",
"lessons": 11,
"metaSize": "16px",
"scrollWidth": 320,
"slots": 5,
"subjectSize": "20px",
"width": 320
},
{
"bodyFont": "\"Roboto Flex\", Roboto, system-ui, sans-serif",
"conditionSize": "14px",
"lessons": 11,
"metaSize": "16px",
"scrollWidth": 487,
"slots": 5,
"subjectSize": "20px",
"width": 487
}
]

BIN
.impeccable/review/menu.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1,30 @@
---
version: 1
slug: "src-app-tsx"
primary_target: "src/App.tsx"
related_targets: ["src/styles.css"]
---
# Расписание — Material 3 Expressive
Primary target: src/App.tsx. Route: /. Visitor mode: Operate. Build path: code-first.
Пользователь задал Material 3 Expressive и подтвердил: главный экран только для расписания; импорт, установка и сведения об источнике находятся в меню. Удаляются слоганы, онлайн-индикатор и декоративные подписи. Существующие данные, импорт, фильтры, выбор группы и офлайн-доступ сохраняются.
## Direction contract
THESIS: Расписание как персональный план дня: сразу видны группа, дни и занятия, без лендингового заголовка и информационных боковых карточек.
OWN-WORLD: Material 3 Expressive, заданный пользователем: Roboto Flex, фиолетовый primary, тональные нейтральные поверхности, контрастный текст, крупные формы, соединённые группы кнопок и сегментированные списки. Основной текст 16 px, вспомогательные подписи не мельче 14 px.
STORY: Студент выбирает группу, переключает день, читает временной интервал и связанные занятия. Условия недель остаются в записи; полная строка Word и действия доступны через детали и меню.
FIRST VIEWPORT: Компактная панель «Расписание», выбор группы 23-БА с контекстом ФЭФ, переключатель день/неделя, семь дней, поиск и первые занятия. На широком экране — navigation rail и ограниченная ширина контента; на телефоне — navigation bar. Главная интеракция: индикатор дня меняет форму и тон при выборе, нажатие даёт короткую обратную связь; reduced-motion сохраняет мгновенное изменение.
FORM: Персональный ежедневник с группировкой по времени, позиция 6 в списке из семи структур (agenda, сетка недели, задачи, хронология, учебные курсы, ежедневник, табло). Seed d359f5e9. Material 3 Expressive закреплён запросом пользователя и имеет приоритет над посторонними стилистическими направлениями. Альтернативные формы не подменяют заданную систему.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
## Scope boundaries
Без изменения API, Word-парсера и правил недель. Пользовательские данные остаются доступными. Нельзя представлять варианты документа как вычисленные занятия конкретной календарной даты. Подтверждённое направление не требует конкурса несвязанных визуальных стилей. Иллюстрации не нужны; существующая SVG-иконка приложения остаётся функциональной иконкой.

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY index.html tsconfig.json vite.config.ts ./
COPY src ./src
COPY shared ./shared
COPY public ./public
COPY scripts ./scripts
RUN npm run build && npm prune --omit=dev
FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates libreoffice-writer && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
COPY server ./server
COPY shared ./shared
RUN mkdir .data && chown node:node .data
USER node
ENV HOST=0.0.0.0 PORT=3001
EXPOSE 3001
CMD ["node", "server/index.js"]

100
PRODUCT.md Normal file
View File

@@ -0,0 +1,100 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Основная аудитория — студенты всех факультетов Полоцкого государственного университета. Пользователь подтвердил этот охват при инициализации контекста 15 октября 2026 года.
Первый проверенный сценарий — ФЭФ, группа 23-БА. Это начальная группа для проверки качества, а не ограничение аудитории продукта.
## Product Purpose
Сделать университетское расписание из Word удобным для просмотра со смартфона. Студент выбирает свою группу и читает занятия по дням, без необходимости разбирать большую таблицу Word на маленьком экране.
Успешный основной сценарий: найти расписание своей группы, понять время, предмет, преподавателя, аудиторию и условия проведения занятия; при необходимости сверить запись с исходным документом.
## Positioning
Независимый PWA-клиент для существующих расписаний ПГУ. Механизм продукта — преобразование таблиц Word из университетского источника в структурированное расписание с сохранением исходных условий и доступа к оригиналу.
Университет остаётся источником данных. Официальный статус приложения и партнёрство с университетом не подтверждены; такие заявления добавлять нельзя.
## Operating Context
- Университет публикует документы в публичном [каталоге Nextcloud](https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027). Сервер сам выбирает папку текущего учебного года (с 1 сентября) и находит таблицу недель и звонков по смыслу имени; состояние источников видно в `/api/health`.
- Каталог организован по факультету, форме обучения, разделу и курсу. Один Word-файл может содержать несколько групп, общие занятия и подгруппы.
- Основной пользовательский язык текущего приложения — русский. Календарные подписи используют часовой пояс Europe/Minsk.
- Существуют два входа: выбор документа университета и импорт собственного Word с предпросмотром перед сохранением.
- Открытое расписание можно сохранить в браузере и просматривать при недоступной сети. Обновление из университетского источника требует подключения.
- Помимо основного расписания пользователь может искать предмет, преподавателя или аудиторию, переключать дни и открывать полную исходную запись.
## Capabilities and Constraints
### Реализованный MVP
- Просмотр по дням и на неделю, поиск и ссылка на оригинал Word.
- Выбор группы — отдельный экран: поиск по номеру группы во всём каталоге, недавние группы, просмотр каталога по факультету, курсу и форме. Студент думает группами, а не файлами: имена документов в выборе не показываются. Первый запуск не открывает чужую группу.
- Календарь учебных недель: переключатель недель с датами, режим «По неделям» (только занятия выбранной недели) и режим «Все строки Word» для сверки. Занятия других недель доступны в свёрнутом блоке, у карточек — статус по датам, в деталях — сетка недель семестра.
- Номера пар и текущая пара по университетскому расписанию звонков.
- Список изменений для группы при перепубликации документа (сравнение с копией на устройстве).
- Выбор своей подгруппы и скрытие отдельных занятий; хранятся только в браузере.
- Ссылка и QR-код на расписание группы для распространения внутри группы; личные настройки по ссылке не передаются.
- Поиск по университету: занятия преподавателя или аудитории на дату и свободные аудитории на пару, по всем документам каталога, с явной оговоркой «по расписаниям из Word» и числом учтённых документов. Только онлайн.
- Раздел сессии: экзамены, зачёты и консультации группы с обратным отсчётом. Реальные документы экзаменационной сессии ещё не публиковались; распознавание проверено на образцах.
- Разбор `.docx`, включая объединённые ячейки, общие занятия, отдельные колонки и альтернативные строки. Старые `.doc` требуют серверной конвертации через LibreOffice; максимальный размер импорта — 10 МБ.
- Сохранение последних 12 расписаний и выбранной группы в текущем браузере. Очистка данных браузера удаляет сохранённые расписания.
- PWA с офлайн-кэшем интерфейса, иконок и локальных шрифтов. Установка на телефон требует подходящего браузера и HTTPS; разработка возможна на localhost.
- При недоступности источника сохранённые данные обозначаются явно. Кэширование не означает, что расписание проверено на актуальность.
- В текущем MVP нет учётных записей, платежей и аналитики. Загруженные пользователем Word-файлы не сохраняются на сервере; извлечённое расписание сохраняется в браузере. Сервер кэширует данные университетского каталога.
### Точность и границы интерпретации
- Год документа каталога берётся из папки («2026–2027»), а не из шапки Word: в части файлов шапка осталась от прошлого года (26-КБ, 25-КБ — «2025/2026»).
- Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель» из корня того же каталога (файл от 26 августа 2026 года): неделя 1 — 01.09–06.09.2026 (начинается со вторника), нечётные недели белые, чётные зелёные, 18 недель до 03.01.2027. Звонки берутся из «Расписание звонков.docx» там же.
- Пометка недель в белой или зелёной строке относится только к неделям этого цвета («1-13н» в белой строке — недели 1, 3, …, 13). Проверка 16 октября 2026 года: все 53 пометки в цветных строках распознанных документов согласуются с этим правилом.
- Нераспознанная пометка (например, опечатка «6-12-16н») не скрывает занятие: оно показывается с предупреждением «сверьте с Word». Исходная пометка всегда видна в деталях.
- Таблица недель делится на семестры; расписание использует только недели своего семестра (по тексту Word, датам сессии или текущей дате). Вне своего семестра или без опубликованной таблицы приложение не угадывает даты и показывает все строки Word.
- Сохранённое расписание сверяется с каталогом при запуске: перепубликованный файл обновляется тихо, новый документ группы (следующий семестр или год) только предлагается, пропавший документ обозначается явно.
- Условия вроде `7,9,11н.`, конкретные даты, примечания, подгруппы и особое время занятия должны оставаться доступными. Неизвестное окончание занятия не выдумывается.
- В общем файле студенту показываются занятия выбранной группы и общие занятия; дисциплины соседней группы не должны попадать в его расписание.
- Универсальность парсера для произвольных Word-документов не доказана. Нераспознанная структура должна давать понятную ошибку, а сложная запись — сохранять исходный текст для сверки.
- PDF, Excel и сканы пока не импортируются. Фоновое обновление при закрытом приложении не реализовано.
## Evidence on Hand
- `README.md` — текущие возможности, запуск и ограничения реализации.
- `server/source.js`, `server/parser.js` — фактический источник и правила извлечения данных.
- `test/fixtures/` — реальные Word-документы нескольких факультетов и метаданные источников; основной образец — `fef-23-ba.docx`.
- `test/parser.test.js` — проверки разделения групп, общих занятий, условий, времени и распознавания таблиц.
- `VALIDATION.md` — протокол первого этапа от 15 октября 2026 года: девять прошедших тестов, сборка, браузерные проверки и сохранённое расписание при остановленном сервере. В том снимке каталога было 113 Word-документов, для 23-БА распознано 33 записи. Это результаты того этапа, а не обещание неизменных данных источника.
- `artifacts/mobile-390.jpg`, `artifacts/desktop-1440.jpg`, `artifacts/offline-390.jpg` — снимки существующего интерфейса и состояния без сервера.
- Публикация на HTTPS-хостинг, установка на физический телефон и Docker-сборка в указанном протоколе не подтверждены.
## Product Principles
1. Главная задача — быстро прочитать расписание своей группы на смартфоне.
2. Достоверность важнее догадки: сохранять условия, примечания и возможность сверки с Word.
3. Разделять данные групп корректно, сохраняя общие занятия.
4. Делать сохранённое расписание доступным при сбое связи и ясно сообщать о непроверенной актуальности.
5. Развивать общий клиент для всех факультетов; изменения парсинга проверять на реальных документах, включая исходный сценарий 23-БА.
## Accessibility & Inclusion
Исходная потребность пользователя — удобное и понятное чтение на мобильном устройстве. При дальнейшей работе сохранять читаемый текст, доступные действия на узком экране и текстовые условия занятий: одного цвета строки недостаточно для объяснения расписания.
Отдельный обязательный уровень соответствия WCAG пользователем пока не установлен.
## Brand Commitments
Пользователь закрепил Material 3 Expressive для PWA: крупный читаемый текст и функциональные подписи без декоративных слоганов. Главный экран содержит расписание; импорт, установка и сведения об источнике доступны через меню.
## Open Decisions
- Таблица недель весеннего семестра и папка следующего учебного года ещё не опубликованы; автоматика рассчитана на тот же формат таблиц. Если университет его изменит, сервер сообщит «таблица не распознана», а приложение покажет строки Word до правки парсера.
- Хостинг, публичный адрес и порядок регулярной публикации не выбраны. Локальная сборка не означает опубликованный сервис.
- Другие языки, роли кроме студентов, уведомления и интеграции с календарями не утверждены как обязательные функции.

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Расписание ПГУ
Первый рабочий PWA-клиент для расписаний Полоцкого государственного университета. При первом запуске предлагает найти свою группу; исходный сценарий проверки — **ФЭФ, 23-БА** из общего документа `23-ЛГ_23-БА.docx` за 2026–2027 учебный год.
## Запуск
Нужны Node.js 22.12+ (проверено на 24.14.1), npm и curl.
```sh
npm ci
npm run build
npm start
```
Откройте http://127.0.0.1:3001. Этот режим включает service worker, установку PWA и офлайн-кэш. Для разработки с обновлением интерфейса:
```sh
npm run dev
```
Vite работает на http://127.0.0.1:5173 и проксирует `/api` на порт 3001 (другой порт — через `API_PORT`). В режиме разработки service worker не регистрируется. `PORT` и `HOST` настраивают сервер; при размещении нужен HTTPS. Для доступа с телефона используйте HTTPS-хостинг либо локальный HTTPS-туннель.
## Возможности
- Живой каталог публичного Nextcloud университета: факультет, форма, курс и поиск по названию группы/файла. Проверенный каталог содержит 113 документов Word.
- Расписание по дням или на всю неделю, поиск предмета, преподавателя и аудитории.
- Экран «Группы»: поиск группы по номеру в любом написании («23-БА», «23 ба», «23ба») по всем документам каталога, без учёта фильтров; одно нажатие открывает нужный документ и группу. Ниже — текущая группа с подгруппой и соседними группами документа, недавние группы (открываются и без сети) и каталог чипами: факультет → курс → форма обучения. Первый запуск начинается с этого экрана, а не с чьей-то группы (`/api/groups`).
- Общие ячейки попадают в каждую соответствующую группу, чужие занятия отсекаются.
- Отдельные карточки для альтернативных занятий и условий по неделям. Сохраняются исходный текст, цвет ячейки, подгруппы, даты и ограничения.
- Учебные недели по таблице университета: переключатель недель с датами, показ только занятий выбранной недели, статус занятия («ещё 4 занятия · до 27 нояб.», «начнётся 20 нояб.») и сетка недель в деталях. Режим «Все строки Word» показывает документ как есть.
- Номера пар и текущая пара («Идёт · ещё 25 мин») по расписанию звонков.
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 20–30 секунд, дальше из кэша) и считает занятость с учётом недель, цвета строк, семестра и разовых переносов аудиторий вида «432 (07.10-ауд.272)». Дистанционные занятия («200 (ДО)») аудиторию не занимают. Каждый ответ сообщает, сколько документов учтено; «свободна» означает «свободна по расписаниям из Word», мероприятия и закрытые аудитории расписание не знает. Работает только онлайн.
- **Сессия**: экзамены, зачёты и консультации группы из всех документов каталога (распознаются по «(экз.)», «(зач.)», «(конс.)» и словам «Экзамен», «Зачёт», «Консультация»). На главном экране — карточка с обратным отсчётом до ближайшего экзамена или зачёта, полный список — в листе «Сессия». Список сохраняется для просмотра без сети. В текущем каталоге документов экзаменационной сессии ещё нет; формат проверен на образцах, а не на реальных файлах университета.
- Ссылка и QR-код на расписание группы (`/?doc=<id>&group=<группа>`) из меню «Поделиться расписанием». Ссылка открывает документ из каталога и нужную группу; подгруппа и скрытые занятия не передаются. Устаревшая ссылка (документ убран из каталога) открывает поиск группы. Для импортированных файлов ссылка недоступна.
- Фильтр белых/зелёных строк. Объединённое на обе строки занятие показывается в обоих вариантах без дублирования.
- Импорт `.docx` и `.doc` до 10 МБ с предварительным просмотром перед сохранением.
- Сохранение последних 12 документов и выбранной группы в этом браузере. После первой успешной загрузки собранной PWA интерфейс, шрифты и расписание доступны без сервера/сети.
- Манифест PWA, PNG-иконки, установка на главный экран и ссылка на исходный Word.
## Источник и обновления
[Каталог университета](https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ) читается через публичный DAV. Сервер обращается только к этой общей папке. Другие ссылки не принимаются.
**Учебный год выбирается сам.** Сервер читает корень каталога, находит папки вида `2026-2027` и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а `/api/health` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. «Обновить» принудительно перечитывает всё. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
`GET /api/health` без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется.
```sh
npm run sync
```
Серверный кэш находится в `.data/` (исключён из Git). Загрузки пользователей обрабатываются в памяти; временные файлы конвертации `.doc` удаляются. Данные из университетского каталога могут сохраняться в серверном кэше. Для офлайн-просмотра расписания хранятся в `localStorage`; очистка данных браузера удалит сохранённые документы. Аналитики и учётных записей нет.
## Границы парсера
Основной парсер читает XML и сетку таблиц `.docx`, включая горизонтальные и вертикальные объединения ячеек. Поддерживаются проверенные дневные шаблоны ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, а также заочные установочные сессии с отдельными колонками предмета, аудитории и преподавателя. Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку.
**Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы». Текстовые даты вроде «15 октября» привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет.
Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, оно имеет приоритет; окончание не выдумывается.
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
## Проверки
```sh
npm test
npm run build
```
Тесты используют реальные Word-документы источника в `test/fixtures/` с URL и метаданными рядом. Проверяются изоляция 23-БА/23-ЛГ, общие события, объединения ячеек, варианты недель, отдельное время кураторского часа, даты сессии и отклонение повреждённых файлов, а также таблица недель и звонков, разбор пометок с учётом цвета строки и согласие всех пометок 23-БА с таблицей.
## Docker
```sh
docker compose up -d --build
```
Приложение откроется на http://localhost:3001. Кэш каталога, таблицы недель и распознанных документов хранится в томе `psu-data` и переживает пересборку. Первый запуск разбирает все документы каталога (около полуминуты), поэтому проверка здоровья (`/api/health`) даёт контейнеру минуту на старт. Порт и адрес меняются переменными `APP_PORT` и `APP_BIND`.
Для установки PWA на телефон нужен HTTPS. Профиль `https` поднимает Caddy, который сам получает сертификат Let's Encrypt (домен должен указывать на сервер, порты 80 и 443 открыты):
```sh
DOMAIN=schedule.example.by docker compose --profile https up -d --build
```
Если HTTPS уже даёт ваш обратный прокси, профиль не нужен; приложение можно закрыть от внешней сети через `APP_BIND=127.0.0.1`. Контейнер включает curl и LibreOffice для `.doc`. Файл `docker-compose.yml` проверен `docker compose config`; сборка образа в этой проверке не запускалась.
## Структура
- `src/App.tsx` — интерфейс, каталог и импорт с предпросмотром.
- `src/storage.ts` — сохранённые расписания, предпочтения и календарь недель.
- `src/calendar.ts`, `src/icons.tsx` — подписи дат и пар, иконки Material Symbols.
- `shared/weeks.js` — разбор пометок недель и расчёт дат, общий для сервера и браузера.
- `shared/lessons.js` — устойчивый ключ занятия, сравнение версий расписания, фильтр подгруппы и скрытых занятий.
- `shared/updates.js` — правила проверки обновлений сохранённого расписания.
- `server/directory.js` — индекс всех документов: преподаватели и аудитории из текста ячеек, занятость на дату, свободные аудитории, экзамены группы (`/api/directory/search`, `/api/directory/day`, `/api/directory/free`, `/api/exams`).
- `server/schedules.js` — разбор документа каталога с дисковым кэшем.
- `src/Search.tsx` — экран «Поиск».
- `server/source.js` — DAV-каталог, фиксированный источник и кэш; таблица недель и звонков из корня каталога (`/api/calendar`, кэш 6 часов).
- `server/calendar.js` — разбор таблицы недель и расписания звонков.
- `server/parser.js` — таблицы `.docx`, условия и преобразование `.doc`.
- `server/index.js` — API и выдача собранной PWA.
- `scripts/serviceworker.js` — генерация полного офлайн-кэша при сборке.
Это независимый клиент; университет остаётся источником расписания. Шрифт Roboto Flex поставляется локально под SIL Open Font License (`public/fonts/OFL-RobotoFlex.txt`).
Тема выбирается в меню: «Авто» (как в системе), «Светлая» или «Тёмная». Выбор хранится в браузере и применяется до первой отрисовки страницы, вместе с цветом строки состояния. QR-коды строятся в браузере библиотекой `qrcode-generator` (MIT). Иконки — Material Symbols Rounded (`@material-symbols/svg-400`, Apache 2.0), встроены в сборку и работают без сети. Интерфейс использует веб-адаптацию Material 3 Expressive: токены цвета и типографики, тональные поверхности, выразительные формы, навигационную панель и rail. Главный экран содержит расписание; импорт, установка и сведения о Word доступны в меню.

16
VALIDATION.md Normal file
View File

@@ -0,0 +1,16 @@
# Проверка первого этапа
Дата: 15 октября 2026 года, Europe/Minsk.
- `npm test`: 9 тестов прошли. Реальные документы ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, включая два шаблона заочной сессии.
- `npm run build`: TypeScript и production-сборка Vite прошли. Service worker предварительно сохраняет 12 ресурсов, включая приложение, локальные шрифты, манифест и PNG-иконки.
- Живой DAV-каталог: 113 Word-документов. API выбранного документа вернул HTTP 200, группы 23-ЛГ и 23-БА. Для 23-БА — 33 записи.
- API импорта `.docx`: HTTP 200, 2 группы, 67 записей всего, 33 записи для 23-БА; факультет и курс извлечены из заголовка. Неизвестный ID документа вернул HTTP 404.
- `.doc`: документ ФЭФ преобразован в старый Word через LibreOffice, затем импортирован обратно. Сохранились 2 группы, 67 записей всего и 33 записи 23-БА. Это проверка преобразованного образца, а не всех исторических `.doc` университета.
- Браузер: поиск по Войтенок, фильтр зелёной строки, исходная запись и аудитория 430, поиск документа 23-БА в каталоге, импорт через выбор файла и сохранение после предпросмотра.
- Размеры браузера: 320×740, 390×844, 1440×1000. `scrollWidth` совпадает с шириной страницы, горизонтального переполнения нет. Это проверка адаптивного интерфейса в браузере, не запуск на физическом телефоне.
- Офлайн: локальный сервер остановлен; перезагрузка страницы сохранила интерфейс и расписание 23-БА. Появилось сообщение «Каталог недоступен. Открыто сохранённое расписание». Переключение на понедельник показало 7 записей, включая кураторский час 16:50. Остановка сервера проверяет недоступность всех ресурсов этого приложения; соединение устройства с интернетом отдельно не отключалось.
Снимки: `artifacts/mobile-390.jpg`, `artifacts/offline-390.jpg`, `artifacts/desktop-1440.jpg`.
Установка на физический телефон, Docker-сборка и публикация на HTTPS-хостинг не проверялись. Автоматическое вычисление недели семестра пока не реализовано: интерфейс явно показывает все варианты и сохраняет условия из Word.

BIN
artifacts/desktop-1440.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
artifacts/mobile-390.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
artifacts/offline-390.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

66
docker-compose.yml Normal file
View File

@@ -0,0 +1,66 @@
# Расписание ПГУ.
#
# docker compose up -d --build → http://localhost:3001
# DOMAIN=schedule.example.by docker compose --profile https up -d --build
# → https://schedule.example.by
#
# Установка PWA на телефон требует HTTPS: для публичного адреса включите
# профиль https (Caddy сам получит сертификат Let's Encrypt) либо поставьте
# приложение за свой обратный прокси.
services:
app:
build: .
image: psu-schedule:latest
restart: unless-stopped
ports:
# Без HTTPS-прокси приложение доступно напрямую; за прокси можно
# ограничить локальным адресом: APP_BIND=127.0.0.1
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3001}:3001"
environment:
HOST: 0.0.0.0
PORT: 3001
TZ: Europe/Minsk
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
volumes:
# Кэш каталога, таблицы недель и распознанных документов.
# Переживает пересборку образа; удаление тома только замедлит первый запуск.
- psu-data:/app/.data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3001/api/health"]
interval: 30s
timeout: 5s
retries: 3
# Первый запуск разбирает все документы каталога (около полуминуты).
start_period: 60s
caddy:
image: caddy:2.8.4-alpine
profiles: ["https"]
restart: unless-stopped
depends_on:
app:
condition: service_healthy
environment:
DOMAIN: ${DOMAIN:-}
# DOMAIN проверяется внутри контейнера: обычный `docker compose up`
# без профиля https не должен его требовать.
entrypoint: ["/bin/sh", "-c"]
command:
- >-
caddy reverse-proxy
--from "$${DOMAIN:?Укажите DOMAIN, например schedule.example.by}"
--to app:3001
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
# Сертификаты: без тома Caddy запрашивал бы их заново при каждом запуске.
- caddy-data:/data
- caddy-config:/config
volumes:
psu-data:
caddy-data:
caddy-config:

47
index.html Normal file
View File

@@ -0,0 +1,47 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
name="theme-color"
content="#fef7ff"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#141218"
media="(prefers-color-scheme: dark)"
/>
<meta
name="description"
content="Расписание Полоцкого государственного университета: занятия по дням, импорт из Word и просмотр без интернета."
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="icon" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Расписание ПГУ</title>
<script>
// The chosen theme applies before the first paint (see src/theme.ts).
try {
var theme = localStorage.getItem("psu-theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
document
.querySelectorAll('meta[name="theme-color"]')
.forEach(function (meta) {
meta.content = theme === "dark" ? "#141218" : "#fef7ff";
});
}
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4707
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "psu-schedule",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k \"node --watch server/index.js\" \"vite --host 127.0.0.1\"",
"build": "node scripts/icons.js && tsc -b && vite build && node scripts/serviceworker.js",
"start": "node server/index.js",
"test": "node --test test/*.test.js",
"sync": "node scripts/sync.js",
"icons": "node scripts/icons.js"
},
"dependencies": {
"@material-symbols/svg-400": "^0.48.0",
"adm-zip": "^0.5.16",
"cheerio": "^1.1.2",
"express": "^5.1.0",
"fast-xml-parser": "^5.3.0",
"multer": "^2.0.2",
"qrcode-generator": "^2.0.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.1",
"sharp": "^0.35.5",
"typescript": "~5.9.3",
"vite": "^7.1.0",
"vite-plugin-svgr": "^5.2.0"
}
}

View File

@@ -0,0 +1,93 @@
Copyright 2017 The Roboto Flex Project Authors (https://github.com/TypeNetwork/Roboto-Flex)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

93
public/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

1
public/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="128" fill="#6750a4"/><path d="M142 172h228v200H142z" fill="none" stroke="#fff" stroke-width="24" stroke-linejoin="round"/><path d="M142 226h228M204 146v48M308 146v48" fill="none" stroke="#fff" stroke-width="24" stroke-linecap="round"/><path d="m205 296 35 35 69-71" fill="none" stroke="#ddd9ff" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1,26 @@
{
"id": "/",
"name": "Расписание ПГУ",
"short_name": "Расписание ПГУ",
"description": "Удобное расписание Полоцкого государственного университета",
"lang": "ru",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#fef7ff",
"theme_color": "#6750a4",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

52
public/sw.js Normal file
View File

@@ -0,0 +1,52 @@
const CACHE = "psu-shell-__REVISION__";
const ASSETS = /* build assets */ [];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(ASSETS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith("psu-shell-") && key !== CACHE)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (
event.request.method !== "GET" ||
url.origin !== self.location.origin ||
url.pathname.startsWith("/api/")
)
return;
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
event.waitUntil(
caches.open(CACHE).then((cache) => cache.put(event.request, copy)),
);
}
return response;
})
.catch(
async () =>
(await caches.match(event.request)) ||
(event.request.mode === "navigate"
? await caches.match("/")
: Response.error()),
),
);
});

7
scripts/icons.js Normal file
View File

@@ -0,0 +1,7 @@
import sharp from "sharp";
for (const size of [192, 512])
await sharp("public/icon.svg")
.resize(size, size)
.png()
.toFile(`public/icon-${size}.png`);
console.log("Иконки PWA созданы.");

27
scripts/serviceworker.js Normal file
View File

@@ -0,0 +1,27 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const assets = [
"/",
"/manifest.webmanifest",
"/icon.svg",
"/icon-192.png",
"/icon-512.png",
...(await readdir("dist/assets"))
.filter((f) => /\.(js|css)$/.test(f))
.map((f) => "/assets/" + f),
...(await readdir("dist/fonts"))
.filter((f) => f === "RobotoFlex-Variable.ttf")
.map((f) => "/fonts/" + f),
];
const rev = createHash("sha256")
.update(assets.join("|"))
.digest("hex")
.slice(0, 12);
const template = await readFile("public/sw.js", "utf8");
await writeFile(
"dist/sw.js",
template
.replace("__REVISION__", rev)
.replace("/* build assets */ []", JSON.stringify(assets)),
);
console.log(`Офлайн-кэш: ${assets.length} ресурсов (${rev}).`);

9
scripts/sync.js Normal file
View File

@@ -0,0 +1,9 @@
import { getCatalog } from "../server/source.js";
const catalog = await getCatalog(true);
if (catalog.stale) {
console.error(catalog.error);
process.exitCode = 1;
} else
console.log(
`Каталог обновлён: ${catalog.files.length} Word-документов, ${catalog.updatedAt}`,
);

59
server/calendar.js Normal file
View File

@@ -0,0 +1,59 @@
import { readDocx } from "./parser.js";
const toIso = (d, m, y) =>
`${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
const time = (h, m) => `${h.padStart(2, "0")}:${m}`;
// "Расписание белых и зеленых недель.docx": pairs of cells "номер | дд.мм.гггг-дд.мм.гггг".
export function parseWeeksDocx(buffer) {
const { tables } = readDocx(buffer);
const weeks = [];
for (const { rows } of tables) {
const header = rows[0] || [];
const colorOf = (col) => {
const label = header.find((c) => c.col === col)?.text.toLowerCase() || "";
return /зел[её]н/.test(label) ? "green" : /бел/.test(label) ? "white" : null;
};
for (const row of rows)
row.forEach((cell, i) => {
const range = cell.text.match(
/(\d{1,2})\.(\d{1,2})\.(\d{4})\s*[-–]\s*(\d{1,2})\.(\d{1,2})\.(\d{4})/,
);
const n = Number(row[i - 1]?.text.match(/^\d{1,2}$/)?.[0]);
if (!range || !n) return;
weeks.push({
n,
start: toIso(range[1], range[2], range[3]),
end: toIso(range[4], range[5], range[6]),
color: colorOf(cell.col) || (n % 2 ? "white" : "green"),
});
});
}
weeks.sort((a, b) => a.start.localeCompare(b.start));
if (!weeks.length)
throw new Error("Таблица недель не распознана.");
return weeks;
}
// "Расписание звонков.docx": "1 пара | 8.30 – 9.10 9.15 – 9.55".
export function parseBellsDocx(buffer) {
const { tables } = readDocx(buffer);
const bells = [];
for (const { rows } of tables)
for (const row of rows) {
const pair = Number(row[0]?.text.match(/^(\d)\s*пара/i)?.[1]);
const times = [
...row
.slice(1)
.map((c) => c.text)
.join(" ")
.matchAll(/([0-2]?\d)[.:]([0-5]\d)/g),
].map((m) => time(m[1], m[2]));
if (!pair || times.length < 2) continue;
const halves = [];
for (let i = 0; i + 1 < times.length; i += 2)
halves.push([times[i], times[i + 1]]);
bells.push({ pair, start: times[0], end: times.at(-1), halves });
}
return bells.sort((a, b) => a.pair - b.pair);
}

456
server/directory.js Normal file
View File

@@ -0,0 +1,456 @@
// University-wide directory: every catalog document parsed once and searched
// by teacher, room and date. Answers come from the Word files only, so every
// response carries its coverage and the caller must present it as such.
import { getCatalog, getCalendar } from "./source.js";
import { scheduleFor } from "./schedules.js";
import {
semesterView,
lessonDates,
todayIso,
weekday,
weekIndexOf,
} from "../shared/weeks.js";
let index = null,
building = null;
const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu;
// "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits.
const ROOM_TOKEN = /[А-ЯA-Z]\d-\d{2}|\d{2,4}\/\d{2,4}|\d{2,4}[А-ЯA-Z]{0,2}/gu;
const ROOM_TEXT = "[А-ЯA-Z]\\d-\\d{2}|\\d{2,4}(?:\\s?(?!и(?![а-яё]))[А-Яа-я]{1,2}(?![а-яё]))?";
// "(13.10-ауд.510н)", "14.10. -ауд. 333", "07.09. ауд. 414", "07.10-203мк": on that date the lesson moves.
const OVERRIDE = new RegExp(
`(\\d{1,2})\\.(\\d{1,2})\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`,
"giu",
);
const DATE = /\d{1,2}\.\d{1,2}(?:\.\d{2,4})?\.?/g;
// Week lists inside a room note: "ауд.205;6,8н.-…", "1-9н.".
const WEEKS = /\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*н(?:ед)?\.?|\d{1,2}\s*н(?:ед)?\./giu;
const MENTION = /(?:ауд\.?|(?<![А-Яа-яЁё])а\.)\s*([^\n(]{0,40})/giu;
const HYPHEN_ROOM = /[А-ЯA-Z]\d-\d{2}/gu;
export const normalize = (s) =>
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s.]/g, "");
export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim();
// Word files mix Latin look-alikes into room numbers ("12C" with a Latin C).
const LATIN = { A: "А", B: "В", C: "С", E: "Е", H: "Н", K: "К", M: "М", O: "О", P: "Р", T: "Т", X: "Х" };
export const roomKey = (s) =>
(s || "")
.replace(/\s+/g, "")
.toUpperCase()
.replace(/[ABCEHKMOPTX]/g, (c) => LATIN[c])
.replace(/\.$/, "");
/** Ordinary numbered rooms only: "129", "207Г". Distance learning "200(ДО)" is not a free room. */
export const isPhysicalRoom = (room) =>
/^(\d{2,4}[А-ЯA-Z]{0,2}|\d{2,4}\/\d{2,4}|[А-ЯA-Z]\d-\d{2})$/.test(room) &&
!/ДО$/.test(room);
const isoFor = (day, month, year) => {
const m = Number(month),
y = m >= 8 ? Number(year) : Number(year) + 1;
return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12;
const tokens = (text) =>
(text || "")
.toUpperCase()
// "210 мк" → "210МК"; "129 и 130" stays two rooms.
.replace(/(\d{2,4})\s+(?!И(?![А-ЯЁ]))([А-ЯЁ]{1,2})(?![А-ЯЁ])/gu, "$1$2")
.match(ROOM_TOKEN) || [];
/**
* Rooms in a cell or room field. Dates, week lists and dated moves are taken out
* first so "ауд. 432 14.10. -ауд. 333" gives room 432 and a move to 333 on 14 October.
* "200 (ДО)" is distance learning and occupies no room.
*/
export function parseRooms(text, year) {
let rest = (text || "").replace(/\u00a0/g, " ");
const overrides = [];
rest = rest.replace(OVERRIDE, (match, d, m, room) => {
if (!validDate(d, m)) return match;
if (year) overrides.push({ date: isoFor(d, m, year), room: roomKey(room) });
return " ";
});
// "200 (ДО)" or "200ДО": distance learning.
const distance = /\(\s*до\s*\)|\d\s*до(?![а-яё])/i.test(rest);
rest = rest.replace(/\([^)]*\)?/g, " ").replace(DATE, " ").replace(WEEKS, " ");
return {
rooms: distance ? [] : [...new Set(tokens(rest).map(roomKey).filter((r) => !/ДО$/.test(r)))],
overrides,
distance,
rest,
};
}
/** Every teacher and room named in a cell, not only the first one the parser picked. */
export function peopleAndRooms(lesson, year) {
const teachers = new Set();
if (lesson.teacher) teachers.add(personName(lesson.teacher));
for (const m of (lesson.raw || "").matchAll(PERSON)) teachers.add(personName(m[0]));
const cell = parseRooms(lesson.raw, year);
const rooms = new Set();
// Rooms written after "ауд." / "а." and the "А1-15" style anywhere in the cell.
for (const m of cell.rest.matchAll(MENTION)) for (const t of tokens(m[1])) rooms.add(roomKey(t));
for (const m of cell.rest.toUpperCase().matchAll(HYPHEN_ROOM)) rooms.add(roomKey(m[0]));
// The parser's room field only counts when the cell really names that room
// (it can pick "12" out of a date like "17.12" or "05" out of "В0-05").
const field = parseRooms(lesson.room, year);
const cleanCell = cell.rest.toUpperCase().replace(/\s+/g, "");
for (const r of field.rooms)
if (
![...rooms].some((x) => x.endsWith(`-${r}`)) &&
new RegExp(`(^|[^\\dА-ЯA-Z-])${r}(?![\\dА-Я])`, "u").test(cleanCell)
)
rooms.add(r);
const overrides = [...cell.overrides, ...field.overrides].filter(
(o, i, all) => all.findIndex((x) => x.date === o.date && x.room === o.room) === i,
);
return {
teachers: [...teachers],
rooms: cell.distance || field.distance ? [] : [...rooms],
overrides,
distance: cell.distance || field.distance,
};
}
/** Rooms a lesson uses on a date: a dated replacement wins over the usual room. */
export function roomsOn(entry, date) {
const moved = entry.overrides.filter((o) => o.date === date).map((o) => o.room);
return moved.length ? moved : entry.rooms;
}
export function buildIndex(docs) {
const entries = [];
for (const doc of docs)
for (const lesson of doc.schedule.lessons)
entries.push({
doc,
lesson,
...peopleAndRooms(lesson, doc.schedule.academicYear),
});
const teachers = new Map(),
rooms = new Map();
for (const e of entries) {
for (const t of e.teachers) {
const key = normalize(t),
item = teachers.get(key) || { name: t, count: 0, faculties: new Set() };
item.count++;
if (e.doc.schedule.source.faculty) item.faculties.add(e.doc.schedule.source.faculty);
teachers.set(key, item);
}
for (const r of [...e.rooms, ...e.overrides.map((o) => o.room)])
rooms.set(r, (rooms.get(r) || 0) + 1);
}
return { entries, teachers, rooms };
}
async function build(catalog) {
const docs = [],
failed = [],
queue = [...catalog.files];
await Promise.all(
Array.from({ length: 4 }, async () => {
while (queue.length) {
const file = queue.shift();
try {
docs.push({ id: file.id, file, schedule: await scheduleFor(file) });
} catch (error) {
failed.push({ name: file.name, error: error.message });
}
}
}),
);
index = {
catalogAt: catalog.updatedAt,
year: catalog.year,
builtAt: new Date().toISOString(),
total: catalog.files.length,
docs,
failed,
...buildIndex(docs),
};
return index;
}
/** The current index; rebuilt in the background when the catalog changes. */
export async function getIndex() {
const catalog = await getCatalog();
if ((!index || index.catalogAt !== catalog.updatedAt) && !building)
building = build(catalog).finally(() => {
building = null;
});
if (!index) await building;
return index;
}
export const indexStatus = () =>
index
? {
documents: index.docs.length,
total: index.total,
failed: index.failed.length,
builtAt: index.builtAt,
rebuilding: Boolean(building),
}
: { documents: 0, rebuilding: Boolean(building) };
const minutes = (t) => {
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
};
/**
* Whether a lesson takes place on a date: "yes", "no", "maybe" (the week mark
* is unreadable) or "unknown" (the document's semester is not in the week table).
*/
export function occurrence(entry, date, calendar, views = new Map()) {
const { lesson, doc } = entry;
if (lesson.date) return lesson.date === date ? "yes" : "no";
if (lesson.dates?.length) return lesson.dates.includes(date) ? "yes" : "no";
if (lesson.day !== weekday(date)) return "no";
const key = `${doc.id}|${date}`;
if (!views.has(key)) views.set(key, semesterView(calendar, doc.schedule, date));
const view = views.get(key);
if (!view) return "unknown";
const dates = lessonDates(lesson, view);
if (dates) return dates.includes(date) ? "yes" : "no";
const week = view.weeks[weekIndexOf(view, date)];
if (week && lesson.variant !== "all" && (week.color === "white") !== (lesson.variant === "upper"))
return "no";
return week ? "maybe" : "no";
}
function coverage(idx, unknownDocs) {
return {
documents: idx.docs.length - unknownDocs.size,
total: idx.total,
failed: idx.failed.map((f) => f.name),
withoutSemester: [...unknownDocs].map((id) => idx.docs.find((d) => d.id === id)?.file.name).filter(Boolean),
year: idx.year,
};
}
export function search(idx, query) {
const q = normalize(query);
if (q.length < 2) return { teachers: [], rooms: [] };
const teachers = [...idx.teachers.entries()]
.filter(([key]) => key.includes(q))
.map(([, t]) => ({ name: t.name, count: t.count, faculties: [...t.faculties] }))
.sort((a, b) => Number(!normalize(a.name).startsWith(q)) - Number(!normalize(b.name).startsWith(q)) || b.count - a.count)
.slice(0, 20);
const roomQuery = roomKey(query);
const rooms = [...idx.rooms.entries()]
.filter(([room]) => room.startsWith(roomQuery))
.map(([room, count]) => ({ room, count }))
.sort((a, b) => a.room.localeCompare(b.room, "ru", { numeric: true }))
.slice(0, 20);
return { teachers, rooms };
}
/** One teacher's or room's lessons on a date, merged across groups and documents. */
export function dayOf(idx, calendar, { teacher, room }, date) {
const views = new Map(),
unknownDocs = new Set();
const want = teacher ? normalize(teacher) : null,
wantRoom = room ? roomKey(room) : null;
const merged = new Map();
for (const entry of idx.entries) {
if (want && !entry.teachers.some((t) => normalize(t) === want)) continue;
if (
wantRoom &&
!entry.rooms.includes(wantRoom) &&
!entry.overrides.some((o) => o.room === wantRoom)
)
continue;
const status = occurrence(entry, date, calendar, views);
if (status === "unknown") unknownDocs.add(entry.doc.id);
if (status !== "yes" && status !== "maybe") continue;
const rooms = roomsOn(entry, date);
if (wantRoom && !rooms.includes(wantRoom)) continue;
const l = entry.lesson;
const key = [l.start, normalize(l.subject), rooms.join(","), entry.teachers.join(",")].join("|");
const item = merged.get(key) || {
start: l.start,
end: l.end,
subject: l.subject,
type: l.type,
teachers: entry.teachers,
rooms,
moved: rooms !== entry.rooms,
distance: entry.distance,
groups: [],
documents: [],
condition: l.condition,
uncertain: false,
};
for (const g of l.groups) if (!item.groups.includes(g)) item.groups.push(g);
if (!item.documents.some((d) => d.id === entry.doc.id))
item.documents.push({ id: entry.doc.id, name: entry.doc.file.name });
if (status === "maybe") item.uncertain = true;
merged.set(key, item);
}
return {
date,
lessons: [...merged.values()].sort((a, b) => a.start.localeCompare(b.start)),
coverage: coverage(idx, unknownDocs),
};
}
/** Rooms with no lesson overlapping a bell pair on a date, by the published schedules. */
export function freeRooms(idx, calendar, date, pair) {
const bell = calendar?.bells.find((b) => b.pair === pair);
if (!bell) return null;
const from = minutes(bell.start),
to = minutes(bell.end);
const views = new Map(),
unknownDocs = new Set(),
busy = new Map(),
maybe = new Map();
const endOf = (l) =>
l.end ? minutes(l.end) : minutes(calendar.bells.find((b) => b.start === l.start)?.end || l.start) || minutes(l.start) + 80;
for (const entry of idx.entries) {
const rooms = roomsOn(entry, date).filter(isPhysicalRoom);
if (!rooms.length) continue;
const l = entry.lesson,
start = minutes(l.start),
end = Math.max(endOf(l), start + 45);
if (end <= from || start >= to) continue;
const status = occurrence(entry, date, calendar, views);
if (status === "unknown") unknownDocs.add(entry.doc.id);
const target = status === "yes" ? busy : status === "maybe" ? maybe : null;
if (!target) continue;
for (const r of rooms)
target.set(r, [...(target.get(r) || []), { subject: l.subject, groups: l.groups, condition: l.condition }]);
}
const all = [...idx.rooms.keys()].filter(isPhysicalRoom);
const free = all.filter((r) => !busy.has(r) && !maybe.has(r)).sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
const groups = new Map();
for (const r of free) {
const letter = r.match(/[А-ЯA-Z]+$/)?.[0] || r.match(/^[А-ЯA-Z]/)?.[0] || "";
groups.set(letter, [...(groups.get(letter) || []), r]);
}
return {
date,
pair,
bell,
free: [...groups.entries()]
.sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b, "ru")))
.map(([letter, list]) => ({ letter, rooms: list })),
freeCount: free.length,
busyCount: busy.size,
maybe: [...maybe.entries()].filter(([r]) => !busy.has(r)).map(([room, lessons]) => ({ room, lessons })),
knownRooms: all.length,
coverage: coverage(idx, unknownDocs),
};
}
export async function directoryCalendar() {
try {
return await getCalendar();
} catch {
return null;
}
}
export { todayIso };
export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация"];
/** Dated exams, credits and consultations of a group across every document. */
export function examsOf(idx, group, faculty) {
const seen = new Map();
for (const { doc, lesson, teachers, rooms } of idx.entries) {
if (!EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue;
if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue;
const dates = lesson.date ? [lesson.date] : lesson.dates || [];
for (const date of dates) {
const key = [date, lesson.start, normalize(lesson.subject), lesson.type].join("|");
if (seen.has(key)) continue;
seen.set(key, {
date,
start: lesson.start,
end: lesson.end,
type: lesson.type,
subject: lesson.subject,
teachers,
rooms,
document: { id: doc.id, name: doc.file.name, period: doc.schedule.period },
});
}
}
return [...seen.values()].sort(
(a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start),
);
}
/** "23-БА", "23 ба" and "23ба" are the same group for searching. */
export const groupKey = (s) =>
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s\-–_.]/g, "");
/** Every group of every document, with the catalog place it came from. */
export function groupEntries(idx) {
const list = [];
for (const { file, schedule } of idx.docs)
for (const group of schedule.groups)
list.push({
group,
key: groupKey(group),
fileId: file.id,
file: file.name,
faculty: file.faculty || schedule.source.faculty || "",
course: file.course || schedule.source.course || "",
form: file.form || "",
period: schedule.period || "",
modified: file.modified || null,
});
return list;
}
const count = (items, pick) => {
const map = new Map();
for (const item of items) {
const value = pick(item);
if (value) map.set(value, (map.get(value) || 0) + 1);
}
return [...map.entries()]
.map(([name, n]) => ({ name, count: n }))
.sort((a, b) => a.name.localeCompare(b.name, "ru", { numeric: true }));
};
/**
* Groups matching a query and/or catalog filters. The query ignores filters on
* purpose: a student typing their group must find it in any faculty.
*/
export function findGroups(idx, { q = "", faculty = "", course = "", form = "" } = {}) {
const all = groupEntries(idx);
const key = groupKey(q);
let results;
if (key.length >= 2) {
const rank = (e) => (e.key === key ? 0 : e.key.startsWith(key) ? 1 : 2);
results = all
.filter((e) => e.key.includes(key))
.sort(
(a, b) =>
rank(a) - rank(b) ||
a.group.localeCompare(b.group, "ru", { numeric: true }) ||
a.period.localeCompare(b.period, "ru"),
)
.slice(0, 50);
} else if (faculty || course || form) {
results = all
.filter(
(e) =>
(!faculty || e.faculty === faculty) &&
(!course || e.course === course) &&
(!form || e.form === form),
)
.sort((a, b) => a.group.localeCompare(b.group, "ru", { numeric: true }))
.slice(0, 300);
} else results = [];
const inFaculty = all.filter((e) => !faculty || e.faculty === faculty);
return {
results,
facets: {
faculties: count(all, (e) => e.faculty),
courses: count(inFaculty, (e) => e.course),
forms: count(inFaculty, (e) => e.form),
},
total: all.length,
year: idx.year,
};
}

186
server/index.js Normal file
View File

@@ -0,0 +1,186 @@
import express from "express";
import { fileURLToPath } from "node:url";
import multer from "multer";
import { parseWord, PARSER_VERSION } from "./parser.js";
import { scheduleFor } from "./schedules.js";
import {
getIndex,
indexStatus,
search,
dayOf,
freeRooms,
examsOf,
findGroups,
directoryCalendar,
todayIso,
} from "./directory.js";
import {
getCatalog,
getCalendar,
documentFor,
request,
sourceStatus,
} from "./source.js";
const app = express();
app.disable("x-powered-by");
app.use("/api", (_req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
// Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
res.json({
ok: true,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),
});
});
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
const badRequest = (res, error) => res.status(400).json({ error });
// University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => {
try {
res.json(search(await getIndex(), String(req.query.q || "").slice(0, 60)));
} catch (e) {
next(e);
}
});
app.get("/api/directory/day", async (req, res, next) => {
try {
const date = req.query.date || todayIso();
const teacher = String(req.query.teacher || "").slice(0, 80),
room = String(req.query.room || "").slice(0, 20);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
if (!teacher && !room) return badRequest(res, "Укажите преподавателя или аудиторию.");
res.json(dayOf(await getIndex(), await directoryCalendar(), { teacher, room }, date));
} catch (e) {
next(e);
}
});
app.get("/api/directory/free", async (req, res, next) => {
try {
const date = req.query.date || todayIso(),
pair = Number(req.query.pair);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const calendar = await directoryCalendar();
const result = freeRooms(await getIndex(), calendar, date, pair);
if (!result) return badRequest(res, "Нет такой пары в расписании звонков.");
res.json(result);
} catch (e) {
next(e);
}
});
app.get("/api/groups", async (req, res, next) => {
try {
const text = (name) => String(req.query[name] || "").slice(0, 120);
res.json(
findGroups(await getIndex(), {
q: text("q"),
faculty: text("faculty"),
course: text("course"),
form: text("form"),
}),
);
} catch (e) {
next(e);
}
});
app.get("/api/exams", async (req, res, next) => {
try {
const group = String(req.query.group || "").slice(0, 40);
if (!group) return badRequest(res, "Укажите группу.");
res.json({
group,
exams: examsOf(await getIndex(), group, String(req.query.faculty || "") || null),
});
} catch (e) {
next(e);
}
});
app.get("/api/catalog", async (req, res, next) => {
try {
res.json(await getCatalog(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/calendar", async (req, res, next) => {
try {
res.json(await getCalendar(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/schedule/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.json(await scheduleFor(file, { refresh: req.query.refresh === "1" }));
} catch (e) {
next(e);
}
});
app.get("/api/original/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.set("Content-Type", "application/octet-stream");
res.set(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
);
res.send(await request(file.path));
} catch (e) {
next(e);
}
});
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
});
app.post("/api/import", upload.single("file"), async (req, res, next) => {
try {
if (!req.file)
return res.status(400).json({ error: "Выберите файл Word." });
// Multer decodes multipart names as latin1 by default.
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
if (!/\.docx?$/i.test(name))
return res
.status(400)
.json({ error: "Поддерживаются файлы .docx и .doc." });
res.json(await parseWord(req.file.buffer, { name, uploaded: true }));
} catch (e) {
e.status = 422;
next(e);
}
});
const dist = fileURLToPath(new URL("../dist/", import.meta.url));
app.use(express.static(dist, { maxAge: 0 }));
app.get("/{*path}", (req, res, next) =>
req.path.startsWith("/api/")
? res.status(404).json({ error: "Адрес не найден." })
: res.sendFile(dist + "index.html", (err) => err && next(err)),
);
app.use((error, _req, res, _next) => {
console.error(error.message);
res
.status(error.code === "LIMIT_FILE_SIZE" ? 413 : error.status || 503)
.json({
error:
error.code === "LIMIT_FILE_SIZE"
? "Файл больше 10 МБ. Выберите документ меньшего размера."
: error.status === 422
? error.message
: error.status === 404
? error.message
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
});
});
const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`API и PWA: http://127.0.0.1:${port}`);
// Read the week table and parse the whole catalog in the background,
// so the first visit and the first search do not wait for the university.
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
});

530
server/parser.js Normal file
View File

@@ -0,0 +1,530 @@
import AdmZip from "adm-zip";
import { load } from "cheerio";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
const run = promisify(execFile);
export const PARSER_VERSION = 7;
const days = [
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
];
const clean = (value) =>
value
.replace(/\u00a0/g, " ")
.replace(/[\t ]+/g, " ")
.replace(/\s*\n\s*/g, "\n")
.trim();
const groupsIn = (value) =>
[
...value.matchAll(
/\b\d{2}\s*[-–]?\s*[А-ЯЁа-яё]{2,8}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu,
),
].map((m) =>
m[0]
.replace(/\s+/g, "")
.replace(/–/g, "-")
.replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2"),
);
function timesIn(value) {
const times = [...value.matchAll(/\b([0-2]?\d)[.:]([0-5]\d)\b/g)].map(
(m) => `${m[1].padStart(2, "0")}:${m[2]}`,
);
return times.length >= 2
? { start: times[0], end: times.at(-1), original: value }
: null;
}
function conditionIn(value) {
const m = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)(?:\s*\.)?\s*/i,
);
if (m) return m[0].trim();
// Some university cells omit the week suffix (for example 1-8,10-17).
const range = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*/i,
);
return range?.[0].trim() || "";
}
export function details(raw) {
const condition = conditionIn(raw);
let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
const leading = rest.match(
/^(экзамен|зач[её]т|консультация)(?![а-яё])[\s.:–—-]*/i,
);
if (leading) rest = rest.slice(leading[0].length);
const kind = rest.match(
/\((л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
);
// Whole words only: "консультирования" in a subject name is not a consultation.
const sessionWord = (leading?.[1] ||
rest.match(/(?<![а-яё])(экзамен|зач[её]т|консультация)(?![а-яё])/i)?.[1] ||
"").toLowerCase();
const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u);
const room =
rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) ||
rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u);
let subject = rest
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length)
.trim()
.replace(/[–—,\-]+$/, "")
.trim();
if (!subject) subject = rest;
const token =
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
(sessionWord.startsWith("экз")
? "экз"
: sessionWord.startsWith("зач")
? "зач"
: sessionWord.startsWith("конс")
? "конс"
: undefined);
return {
subject,
teacher: person?.[0] || "",
room: room?.[1].trim().replace(/\s+/g, "") || "",
type:
token === "лз" || token === "лаб"
? "Лабораторная"
: token === "пз" || token === "пр"
? "Практика"
: token === "л" || token === "лек"
? "Лекция"
: token === "сем"
? "Семинар"
: token === "экз"
? "Экзамен"
: token === "зач"
? "Зачёт"
: token === "конс"
? "Консультация"
: "Занятие",
condition,
};
}
// Split only at an explicit new week condition. Keep unfamiliar wording intact.
function alternatives(raw) {
return raw
.split(
/\n(?=(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)/i,
)
.filter(Boolean);
}
export function readDocx(buffer) {
let zip;
try {
zip = new AdmZip(buffer);
} catch {
throw new Error("Файл повреждён или не является документом Word.");
}
const entry = zip.getEntry("word/document.xml");
if (!entry)
throw new Error(
"В файле нет документа Word. Выберите .docx с таблицей расписания.",
);
if (entry.header.size > 20 * 1024 * 1024 || zip.getEntries().length > 2000)
throw new Error("Документ слишком большой для импорта.");
const xml = entry.getData().toString("utf8");
const $ = load(xml.replace(/(<\/?)(?:w:)/g, "$1"), { xmlMode: true });
const text = (node) =>
clean(
$(node)
.find("p")
.map((_, p) =>
$(p)
.find("t, br, tab")
.map((_, t) =>
t.name === "br" ? "\n" : t.name === "tab" ? " " : $(t).text(),
)
.get()
.join(""),
)
.get()
.join("\n"),
);
const tables = $("body > tbl")
.map((_, tbl) => {
const rows = $(tbl)
.children("tr")
.map((_, tr) => {
let col = Number(
$(tr).children("trPr").find("gridBefore").attr("w:val") || 0,
);
return [
$(tr)
.children("tc")
.map((_, tc) => {
const span = Number(
$(tc).children("tcPr").find("gridSpan").attr("w:val") || 1,
);
const vm = $(tc).children("tcPr").find("vMerge");
const cell = {
text: text(tc),
col,
span,
merge: vm.length ? vm.attr("w:val") || "continue" : null,
color:
$(tc).children("tcPr").find("shd").attr("w:fill") || null,
};
col += span;
return cell;
})
.get(),
];
})
.get();
return [{ rows }];
})
.get();
return {
tables,
text: text($("body")[0]),
notes: $("body > p")
.map((_, p) =>
clean(
$(p)
.find("t")
.map((_, t) => $(t).text())
.get()
.join(""),
),
)
.get()
.filter((s) => s && !/декан|начальник|_{3}|^утверждаю/i.test(s)),
};
}
export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer);
const lessons = [],
groups = new Set(),
warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
const academicYear =
String(source.year || "").match(/^(20\d{2})/)?.[1] ||
document.text.match(/(20\d{2})\s*[/–-]\s*20\d{2}/)?.[1];
// Text dates like "15 октября" need the autumn year; fall back to the file date.
const modified = source.modified ? new Date(source.modified) : null;
const yearStart =
academicYear ||
(modified && Number.isFinite(modified.getTime())
? modified.getUTCFullYear() - (modified.getUTCMonth() < 7 ? 1 : 0)
: null);
let seenRows = 0,
skippedRows = 0;
for (const { rows } of document.tables) {
let ranges = [],
header = -1;
for (let i = 0; i < rows.length; i++) {
const found = rows[i].flatMap((cell) =>
groupsIn(cell.text).map((group) => ({
group,
col: cell.col,
end: cell.col + cell.span,
})),
);
if (
found.length &&
rows[i].some((cell) => /^(дни|день|часы|пара|время)/i.test(cell.text))
) {
ranges = found;
header = i;
break;
}
}
if (header < 0) continue;
ranges.forEach((r) => groups.add(r.group));
let day = null,
date = null,
time = null,
pair = null,
timeCol = null;
// Session schedules use separate room, subject and teacher columns.
const session = rows[header].some((cell) => /пара/i.test(cell.text));
let sessionColumns = null;
if (session) {
const columns = rows.slice(header, header + 3).flat();
const room = columns.find((c) => /^ауд/i.test(c.text));
const subject = columns.find((c) => /название|дисциплин/i.test(c.text));
const teacher = columns.find((c) => /фамил|преподавател/i.test(c.text));
sessionColumns =
room && subject
? { room: room.col, subject: subject.col, teacher: teacher?.col }
: null;
}
for (let i = header + 1; i < rows.length; i++) {
const row = rows[i];
const dayCell = row.find((c) =>
days.some((d) => c.text.toLowerCase().startsWith(d)),
);
if (dayCell) {
day =
days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1;
const m = dayCell.text.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/);
date = m
? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`
: null;
time = null;
pair = null;
}
if (!day) continue;
const timeCell = row.find(
(c) => c.col < ranges[0].col && timesIn(c.text),
);
if (timeCell) {
time = timesIn(timeCell.text);
timeCol = timeCell.col;
pair = session ? row.find((c) => c.col === 1)?.text : null;
}
if (!time) continue;
seenRows++;
const continuedTime =
row.find((c) => c.col === timeCol)?.merge === "continue";
const pairedTime =
timeCell?.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === timeCol)?.merge === "continue";
const variant = continuedTime ? "lower" : pairedTime ? "upper" : "all";
const emit = (cell, targetGroups, raw, overrides = {}) => {
if (!raw.trim()) return;
const spansBoth =
cell.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
for (const alternative of alternatives(raw)) {
const data = details(alternative);
const dateCandidate = alternative.match(
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
);
const singleDate =
dateCandidate &&
Number(dateCandidate[1]) >= 1 &&
Number(dateCandidate[1]) <= 31 &&
Number(dateCandidate[2]) >= 1 &&
Number(dateCandidate[2]) <= 12
? dateCandidate
: null;
const explicitTime = alternative.match(
/^([0-2]?\d)([.:])([0-5]\d)\s+/,
);
const ownTime =
explicitTime &&
Number(explicitTime[1]) <= 23 &&
(explicitTime[2] === ":" || Number(explicitTime[3]) > 12)
? `${explicitTime[1].padStart(2, "0")}:${explicitTime[3]}`
: null;
const oneDate =
singleDate && (singleDate[3] || academicYear)
? `${singleDate[3] || academicYear}-${singleDate[2]}-${singleDate[1]}`
: null;
if (singleDate) {
data.condition = singleDate[0].trim();
data.subject = details(
alternative.slice(singleDate[0].length),
).subject;
}
if (ownTime) {
data.subject = details(
alternative.slice(explicitTime[0].length),
).subject;
data.condition = "";
}
const subgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
const id = createHash("sha256")
.update([source.name, i, cell.col, alternative].join("|"))
.digest("hex")
.slice(0, 16);
lessons.push({
id,
day,
date: date || oneDate,
pair: pair || null,
...time,
...(ownTime ? { start: ownTime, end: null } : {}),
...data,
...overrides,
groups: targetGroups,
subgroups,
variant: spansBoth ? "all" : variant,
weekRanges:
date || oneDate ? null : parseWeekRanges(data.condition),
dates:
date || oneDate || data.condition
? []
: parseTextDates(alternative, yearStart),
sourceColor:
cell.color &&
!["auto", "FFFFFF"].includes(cell.color.toUpperCase())
? cell.color
: null,
raw: alternative,
});
}
};
if (sessionColumns) {
const cell = row.find((c) => c.col === sessionColumns.subject);
if (!cell || cell.merge === "continue" || !cell.text) continue;
const room = row.find((c) => c.col === sessionColumns.room)?.text || "";
const teacher =
row.find((c) => c.col === sessionColumns.teacher)?.text || "";
emit(
cell,
ranges.map((r) => r.group),
[cell.text, teacher, room].filter(Boolean).join("\n"),
{ room, teacher },
);
} else {
let count = 0;
for (const cell of row) {
if (
cell.merge === "continue" ||
!cell.text ||
cell.col < ranges[0].col
)
continue;
const targetGroups = ranges
.filter((r) => cell.col < r.end && cell.col + cell.span > r.col)
.map((r) => r.group);
if (!targetGroups.length) continue;
emit(cell, targetGroups, cell.text);
count++;
}
if (!count) skippedRows++;
}
}
}
if (!lessons.length)
throw new Error(
"Таблица расписания не распознана. Нужны названия дней, время и заголовки групп. Исходный файл не изменён.",
);
if (lessons.some((l) => l.variant !== "all"))
warnings.add(
"Белые строки относятся к нечётным неделям, зелёные — к чётным, по таблице недель университета. Цвет строк сохранён как в Word.",
);
if (
lessons.some(
(l) =>
l.condition ||
/нед|\d\s*н[.\s]|сентября|октября|ноября|декабря/i.test(l.raw),
)
)
warnings.add(
"Номера недель и даты рассчитаны по таблице недель университета. Исходная пометка сохранена в карточке; при сомнениях сверяйте её с Word.",
);
if (lessons.some((l) => l.subgroups.length))
warnings.add(
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
);
return {
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
groups: [...groups],
lessons: lessons.sort(
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
),
notes: document.notes,
warnings: [...warnings],
source: {
faculty:
source.faculty ||
[
[/финансово-экономическ/iu, "Финансово-экономический факультет"],
[
/факультета информационных технологий/iu,
"Факультет информационных технологий",
],
[
/факультета компьютерных наук и электроники/iu,
"Факультет компьютерных наук и электроники",
],
[/гуманитарного факультета/iu, "Гуманитарный факультет"],
[/инженерно-строительн/iu, "Инженерно-строительный факультет"],
[/механико-технологическ/iu, "Механико-технологический факультет"],
[/юридического факультета/iu, "Юридический факультет"],
].find(([pattern]) => pattern.test(document.text))?.[1],
course:
source.course ||
(document.text.match(/студентов\s+(\d)\s+курса/iu)?.[1]
? `${document.text.match(/студентов\s+(\d)\s+курса/iu)[1]} курс`
: undefined),
...source,
importedAt: new Date().toISOString(),
},
schemaVersion: PARSER_VERSION,
// Start year of the academic year printed in Word ("2026/2027" → "2026").
academicYear: academicYear || (yearStart ? String(yearStart) : null),
stats: {
lessons: lessons.length,
groups: groups.size,
rows: seenRows,
emptyRows: skippedRows,
},
format: "docx",
period: /экзаменационн/i.test(document.text)
? "Экзаменационная сессия"
: /установочн/i.test(document.text)
? "Установочная сессия"
: /осенний семестр/i.test(document.text)
? "Осенний семестр"
: /весенний семестр/i.test(document.text)
? "Весенний семестр"
: "Расписание из Word",
};
}
export async function parseWord(buffer, source = {}) {
if (buffer.subarray(0, 2).toString() === "PK")
return parseDocx(buffer, source);
if (buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1")
throw new Error("Выберите настоящий файл Word (.docx или .doc).");
const dir = await mkdtemp(join(tmpdir(), "psu-word-"));
try {
const input = join(dir, "schedule.doc"),
output = join(dir, "schedule.docx");
await writeFile(input, buffer);
await run(
process.env.LIBREOFFICE_BIN || "soffice",
[
"-env:UserInstallation=file://" + join(dir, "profile"),
"--headless",
"--convert-to",
"docx",
"--outdir",
dir,
input,
],
{ timeout: 30000 },
);
const parsed = parseDocx(await readFile(output), source);
parsed.format = "doc";
parsed.warnings.unshift(
"Старый .doc преобразован в .docx. Проверьте таблицу и условия по оригиналу.",
);
return parsed;
} catch (error) {
if (error.code === "ENOENT")
throw new Error(
"Для старых .doc на сервере нужен LibreOffice. Можно сохранить документ как .docx в Word и импортировать его.",
);
throw error;
} finally {
await rm(dir, { recursive: true, force: true });
}
}

34
server/schedules.js Normal file
View File

@@ -0,0 +1,34 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { parseWord, PARSER_VERSION } from "./parser.js";
import { request } from "./source.js";
const data = fileURLToPath(new URL("../.data/parsed/", import.meta.url));
/** A parsed catalog document, from the disk cache while the file and parser are unchanged. */
export async function scheduleFor(file, { refresh = false } = {}) {
const cache = data + file.id + ".json";
if (!refresh)
try {
const cached = JSON.parse(await readFile(cache, "utf8"));
if (
cached.schemaVersion === PARSER_VERSION &&
cached.source.modified === file.modified
)
return cached;
} catch {}
const buffer = await request(file.path);
const parsed = await parseWord(buffer, {
name: file.name,
url: file.url,
faculty: file.faculty,
modified: file.modified,
fileId: file.id,
form: file.form,
course: file.course,
year: file.year,
});
await mkdir(data, { recursive: true });
await writeFile(cache, JSON.stringify(parsed));
return parsed;
}

400
server/source.js Normal file
View File

@@ -0,0 +1,400 @@
import { XMLParser } from "fast-xml-parser";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
import {
academicStartYear,
assignSemesters,
todayIso,
weekIndexOf,
} from "../shared/weeks.js";
const run = promisify(execFile);
const ORIGIN = "https://nextcloud.psu.by";
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
const YEAR_FOLDER = /^(\d{4})-(\d{4})$/;
const DATA = fileURLToPath(new URL("../.data/", import.meta.url));
const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false });
const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []);
const DISCOVERY_TTL = 15 * 60 * 1000,
CATALOG_TTL = 15 * 60 * 1000,
CALENDAR_TTL = 6 * 60 * 60 * 1000;
let catalogue, pending, calendar, calendarPending, sources, sourcesPending;
const status = { catalogError: null, calendarError: null };
export async function request(path, method = "GET") {
// Only the university share is reachable; listings and catalog entries supply the paths.
if (
!path.startsWith(SHARE) ||
/[\r\n]/.test(path) ||
/(^|\/)\.\.(\/|$)/.test(decodeURIComponent(path)) ||
!["GET", "PROPFIND"].includes(method)
)
throw new Error("Недопустимый путь документа.");
const url = ORIGIN + path;
// curl also works with the system trust store on macOS. Certificate checks stay enabled.
const args = [
"--fail",
"--silent",
"--show-error",
"--max-time",
"25",
"--max-filesize",
"10485760",
"--max-redirs",
"0",
"--request",
method,
];
if (method === "PROPFIND") args.push("--header", "Depth: 1");
args.push(url);
const { stdout } = await run("curl", args, {
encoding: "buffer",
maxBuffer: 11 * 1024 * 1024,
timeout: 28000,
});
return stdout;
}
export function decodeListing(xml, root = SHARE) {
const entries = arr(parser.parse(xml).multistatus?.response);
return entries
.map((entry) => {
const props =
arr(entry.propstat).find((p) => p.status?.includes("200"))?.prop || {};
return {
path: entry.href,
folder: props.resourcetype?.collection !== undefined,
modified: props.getlastmodified || null,
size: Number(props.getcontentlength || 0),
};
})
.filter((e) => typeof e.path === "string" && e.path.startsWith(root));
}
const nameOf = (path) =>
decodeURIComponent(path).replace(/\/$/, "").split("/").at(-1);
export const isWeeksTable = (name) =>
/\.docx$/i.test(name) &&
/бел/i.test(name) &&
/зел[её]н/i.test(name) &&
/недел/i.test(name);
export const isBellSchedule = (name) =>
/\.docx$/i.test(name) && /звонк/i.test(name);
const isReference = (name) => isWeeksTable(name) || isBellSchedule(name);
export function fileMetadata(entry) {
const pieces = decodeURIComponent(entry.path).slice(SHARE.length).split("/");
const year = YEAR_FOLDER.test(pieces[0]) ? pieces.shift() : "";
const name = pieces.at(-1);
return {
...entry,
id: createHash("sha256").update(entry.path).digest("hex").slice(0, 24),
name,
year: year.replace("-", "–"),
faculty: pieces[0],
form: pieces[1] || "",
course: pieces.find((s) => /^\d курс$/.test(s)) || "",
section: pieces.slice(2, -1).join(" / "),
url: ORIGIN + entry.path,
};
}
/**
* The folder of the current academic year (from 1 September), else the newest
* earlier one, else the oldest later one. Folders look like "2026-2027".
*/
export function pickYearFolder(names, today = todayIso()) {
const target = academicStartYear(today);
const years = names
.map((name) => ({ name, m: name.match(YEAR_FOLDER) }))
.filter(({ m }) => m && Number(m[2]) === Number(m[1]) + 1)
.map(({ name, m }) => ({ name, start: Number(m[1]) }))
.sort((a, b) => b.start - a.start);
const expected = `${target}-${target + 1}`;
const pick =
years.find((y) => y.start === target) ||
years.find((y) => y.start < target) ||
years.at(-1);
return pick
? { name: pick.name, label: pick.name.replace("-", "–"), current: pick.start === target, expected }
: null;
}
/** Reference documents: newest first, the year folder before the share root. */
export function pickReferences(rootEntries, yearEntries = []) {
const files = (entries, scope) =>
entries
.filter((e) => !e.folder)
.map((e) => ({ ...e, name: nameOf(e.path), scope }));
const all = [...files(yearEntries, "year"), ...files(rootEntries, "root")];
const order = (a, b) =>
(a.scope === b.scope ? 0 : a.scope === "year" ? -1 : 1) ||
new Date(b.modified || 0) - new Date(a.modified || 0);
return {
weeks: all.filter((f) => isWeeksTable(f.name)).sort(order),
bells: all.filter((f) => isBellSchedule(f.name)).sort(order)[0] || null,
};
}
/** Lists the share root and the chosen year folder; cached for 15 minutes. */
async function discover(refresh = false) {
if (!refresh && sources && Date.now() - sources.checkedAt < DISCOVERY_TTL)
return sources;
if (!sourcesPending)
sourcesPending = (async () => {
const root = decodeListing(
(await request(SHARE, "PROPFIND")).toString(),
).filter((e) => e.path.toLowerCase() !== SHARE.toLowerCase());
const year = pickYearFolder(
root.filter((e) => e.folder).map((e) => nameOf(e.path)),
);
if (!year)
throw new Error("В каталоге университета нет папки учебного года.");
const yearRoot = SHARE + encodeURIComponent(year.name) + "/";
const yearEntries = decodeListing(
(await request(yearRoot, "PROPFIND")).toString(),
).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase());
return (sources = {
checkedAt: Date.now(),
year,
yearRoot: root.find((e) => nameOf(e.path) === year.name)?.path || yearRoot,
references: pickReferences(root, yearEntries),
});
})().finally(() => {
sourcesPending = null;
});
return sourcesPending;
}
async function crawl() {
const source = await discover(true);
let queue = [source.yearRoot],
files = [],
count = 0;
while (queue.length) {
if (count + queue.length > 180) throw new Error("Каталог слишком большой.");
const batch = queue.splice(0, 5);
count += batch.length;
const results = await Promise.all(
batch.map(async (path) =>
decodeListing((await request(path, "PROPFIND")).toString()).filter(
(e) => e.path.toLowerCase() !== path.toLowerCase(),
),
),
);
for (const entries of results)
for (const entry of entries) {
if (entry.folder) queue.push(entry.path);
else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path))
files.push(fileMetadata(entry));
}
}
const result = {
// Week tables and bell schedules are reference documents, not timetables.
files: files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)),
otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length,
updatedAt: new Date().toISOString(),
stale: false,
year: source.year.label,
currentYear: source.year.current,
expectedYear: source.year.expected.replace("-", "–"),
};
await mkdir(DATA, { recursive: true });
await writeFile(DATA + "catalog.json", JSON.stringify(result));
status.catalogError = null;
return (catalogue = result);
}
async function readCatalogCache() {
if (catalogue) return;
try {
catalogue = JSON.parse(await readFile(DATA + "catalog.json", "utf8"));
} catch {
// An initial source snapshot can be used during development after an interrupted sync.
try {
const files = JSON.parse(
await readFile(DATA + "source-catalog.json", "utf8"),
);
catalogue = {
files: files
.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name))
.map(fileMetadata),
updatedAt: null,
stale: true,
year: files[0] ? fileMetadata(files[0]).year : "",
};
} catch {}
}
}
export async function getCatalog(refresh = false) {
await readCatalogCache();
const expected = (() => {
const y = academicStartYear(todayIso());
return `${y}–${y + 1}`;
})();
// A cached catalog of last year is refreshed as soon as the new year begins.
const fresh =
catalogue &&
Date.now() - new Date(catalogue.updatedAt).getTime() < CATALOG_TTL &&
(catalogue.year === expected || catalogue.currentYear === false);
if (!refresh && fresh) return catalogue;
if (!pending)
pending = crawl()
.catch((error) => {
status.catalogError = error.message;
if (catalogue)
return {
...catalogue,
stale: true,
error: "Источник недоступен. Показана сохранённая копия каталога.",
};
throw error;
})
.finally(() => {
pending = null;
});
return pending;
}
export async function documentFor(id) {
const catalog = await getCatalog();
const file = catalog.files.find((f) => f.id === id);
if (!file) {
const error = new Error("Документ не найден в каталоге. Обновите список.");
error.status = 404;
throw error;
}
return file;
}
/** Merges every week table found (newest wins on overlap) and splits it into semesters. */
export function mergeWeekTables(tables) {
const byStart = new Map();
for (const weeks of tables)
for (const week of weeks)
if (!byStart.has(week.start)) byStart.set(week.start, week);
return assignSemesters([...byStart.values()]);
}
async function loadCalendar() {
const { references } = await discover(true);
if (!references.weeks.length)
throw new Error("Таблица белых и зелёных недель не найдена в каталоге.");
const tables = [];
for (const file of references.weeks)
try {
tables.push(parseWeeksDocx(await request(file.path)));
} catch (error) {
// One damaged copy must not hide a readable newer or older one.
console.error(`${file.name}: ${error.message}`);
}
if (!tables.length) throw new Error("Таблица недель не распознана.");
let bells = [];
if (references.bells)
try {
bells = parseBellsDocx(await request(references.bells.path));
} catch (error) {
console.error(`${references.bells.name}: ${error.message}`);
}
const { weeks, semesters } = mergeWeekTables(tables);
const result = {
weeks,
semesters,
// Keep the last known bells if the bell schedule became unreadable.
bells: bells.length ? bells : calendar?.bells || [],
source: {
weeks: references.weeks[0].modified,
bells: references.bells?.modified || null,
weeksFiles: references.weeks.map((f) => f.name),
bellsFile: references.bells?.name || null,
},
updatedAt: new Date().toISOString(),
stale: false,
};
await mkdir(DATA, { recursive: true });
await writeFile(DATA + "calendar.json", JSON.stringify(result));
status.calendarError = null;
return (calendar = result);
}
async function readCalendarCache() {
if (calendar) return;
try {
calendar = JSON.parse(await readFile(DATA + "calendar.json", "utf8"));
// Caches written before semesters existed are upgraded in place.
if (!calendar.semesters) calendar = { ...calendar, ...assignSemesters(calendar.weeks) };
} catch {}
}
export async function getCalendar(refresh = false) {
await readCalendarCache();
if (
!refresh &&
calendar &&
Date.now() - new Date(calendar.updatedAt).getTime() < CALENDAR_TTL
)
return calendar;
if (!calendarPending)
calendarPending = loadCalendar()
.catch((error) => {
status.calendarError = error.message;
if (calendar) return { ...calendar, stale: true, error: error.message };
throw error;
})
.finally(() => {
calendarPending = null;
});
return calendarPending;
}
/** What the server currently uses, for /api/health. Never touches the network. */
export async function sourceStatus(today = todayIso()) {
await Promise.all([readCatalogCache(), readCalendarCache()]);
const warnings = [];
const expected = academicStartYear(today);
const expectedYear = `${expected}–${expected + 1}`;
if (catalogue && catalogue.year !== expectedYear)
warnings.push(
`Папка учебного года ${expectedYear} не найдена или каталог ещё не обновлён; используется ${catalogue.year || "неизвестный год"}.`,
);
if (status.catalogError)
warnings.push(`Каталог не обновился: ${status.catalogError}`);
if (!calendar) warnings.push("Таблица недель ещё не загружена.");
else if (weekIndexOf(calendar, today) < 0)
warnings.push(
"Сегодняшняя дата вне таблицы недель: приложение показывает все строки Word.",
);
if (status.calendarError)
warnings.push(`Таблица недель не обновилась: ${status.calendarError}`);
const age = (iso) =>
iso ? Math.round((Date.now() - new Date(iso).getTime()) / 60000) : null;
return {
today,
year: sources?.year || (catalogue ? { label: catalogue.year } : null),
catalog: catalogue
? {
year: catalogue.year,
files: catalogue.files.length,
updatedAt: catalogue.updatedAt,
ageMinutes: age(catalogue.updatedAt),
stale: Boolean(catalogue.stale || status.catalogError),
}
: null,
calendar: calendar
? {
weeksFiles: calendar.source.weeksFiles || [],
weeksModified: calendar.source.weeks,
bellsFile: calendar.source.bellsFile || null,
semesters: (calendar.semesters || []).map((s) => ({
id: s.id,
label: s.label,
start: s.start,
end: s.end,
weeks: calendar.weeks.filter((w) => w.semester === s.id).length,
})),
bells: calendar.bells.length,
updatedAt: calendar.updatedAt,
ageMinutes: age(calendar.updatedAt),
coversToday: weekIndexOf(calendar, today) >= 0,
stale: Boolean(calendar.stale || status.calendarError),
}
: null,
warnings,
};
}

45
shared/lessons.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
interface LessonLike {
day: number;
date: string | null;
start: string;
end: string | null;
subject: string;
teacher: string;
room: string;
type: string;
condition: string;
variant: "all" | "upper" | "lower";
groups: string[];
subgroups: { group: string; label: string }[];
}
export interface FieldChange {
field: string;
label: string;
from: string | number;
to: string | number;
}
export interface ScheduleDiff<L> {
added: L[];
removed: L[];
changed: { before: L; after: L; fields: FieldChange[] }[];
}
export interface PersonalPrefs {
subgroup?: string | null;
hidden?: string[];
}
export function lessonKey(lesson: LessonLike, group: string): string;
export function diffSchedules<L extends LessonLike>(
before: { lessons: L[] } | null | undefined,
after: { lessons: L[] } | null | undefined,
group: string,
): ScheduleDiff<L>;
export function changeCount(diff: ScheduleDiff<unknown> | null | undefined): number;
export function personalFilter(
lesson: LessonLike,
group: string,
prefs: PersonalPrefs | null | undefined,
): "shown" | "hidden" | "other-subgroup";
export function subgroupsOf(
schedule: { lessons: LessonLike[] } | null | undefined,
group: string,
): string[];

105
shared/lessons.js Normal file
View File

@@ -0,0 +1,105 @@
// Lesson identity and schedule comparison, shared by the browser and the tests.
// Lesson ids depend on the table row, so they change whenever the university
// republishes a file; these keys describe what a student would call "the same lesson".
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
const subgroupOf = (lesson, group) =>
lesson.subgroups?.find((s) => s.group === group)?.label || "";
/** Same day, time, subject, row colour and subgroup: survives republishing. */
export function lessonKey(lesson, group) {
return [
lesson.date || lesson.day,
lesson.start,
norm(lesson.subject),
lesson.variant,
subgroupOf(lesson, group),
norm(lesson.condition),
].join("|");
}
// Looser identity for pairing a lesson whose time or week mark changed.
const looseKey = (lesson, group) =>
[norm(lesson.subject), lesson.variant, subgroupOf(lesson, group), norm(lesson.type)].join("|");
const FIELDS = [
["start", "Начало"],
["end", "Окончание"],
["day", "День"],
["date", "Дата"],
["room", "Аудитория"],
["teacher", "Преподаватель"],
["condition", "Недели"],
["type", "Вид занятия"],
];
/**
* What changed for one group between two versions of the same document.
* Returns { added, removed, changed } where changed items list field differences.
*/
export function diffSchedules(before, after, group) {
const pick = (s) => (s?.lessons || []).filter((l) => l.groups.includes(group));
const removed = [],
added = [],
changed = [];
const old = new Map();
for (const lesson of pick(before)) {
const key = lessonKey(lesson, group);
old.set(key, [...(old.get(key) || []), lesson]);
}
const unmatched = [];
for (const lesson of pick(after)) {
const key = lessonKey(lesson, group),
list = old.get(key);
if (list?.length) {
const was = list.shift();
const fields = differences(was, lesson);
if (fields.length) changed.push({ before: was, after: lesson, fields });
} else unmatched.push(lesson);
}
const left = [...old.values()].flat();
// Pair the rest by subject on the same day first, then by subject alone (a lesson moved to another day).
for (const sameDay of [true, false])
for (const lesson of [...unmatched]) {
const index = left.findIndex(
(l) =>
looseKey(l, group) === looseKey(lesson, group) &&
(!sameDay || (l.date || l.day) === (lesson.date || lesson.day)),
);
if (index < 0) continue;
const [was] = left.splice(index, 1);
unmatched.splice(unmatched.indexOf(lesson), 1);
changed.push({ before: was, after: lesson, fields: differences(was, lesson) });
}
added.push(...unmatched);
removed.push(...left);
const order = (l) => `${l.date || ""}${l.day}${l.start}`;
added.sort((a, b) => order(a).localeCompare(order(b)));
removed.sort((a, b) => order(a).localeCompare(order(b)));
changed.sort((a, b) => order(a.after).localeCompare(order(b.after)));
return { added, removed, changed };
}
function differences(before, after) {
return FIELDS.filter(([field]) => (before[field] ?? "") !== (after[field] ?? "")).map(
([field, label]) => ({ field, label, from: before[field] ?? "", to: after[field] ?? "" }),
);
}
export const changeCount = (diff) =>
diff ? diff.added.length + diff.removed.length + diff.changed.length : 0;
/** Applies the student's own choices: their subgroup and the lessons they hid. */
export function personalFilter(lesson, group, prefs) {
const own = subgroupOf(lesson, group);
if (prefs?.subgroup && own && own !== prefs.subgroup) return "other-subgroup";
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
return "shown";
}
/** Subgroup labels present for a group, in column order. */
export function subgroupsOf(schedule, group) {
const labels = new Set();
for (const lesson of schedule?.lessons || [])
if (lesson.groups.includes(group)) {
const label = subgroupOf(lesson, group);
if (label) labels.add(label);
}
return [...labels].sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
}

22
shared/updates.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
interface CatalogFileLike {
id: string;
name: string;
faculty: string;
modified: string;
}
interface SavedLike {
schemaVersion?: number;
source: {
fileId?: string;
modified?: string;
importedAt: string;
faculty?: string;
};
}
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,
group: string,
options?: { dismissed?: string[]; schemaVersion?: number },
): { changed: F | null; newer: F | null; missing: boolean } | null;

47
shared/updates.js Normal file
View File

@@ -0,0 +1,47 @@
// Decides what a saved schedule needs after the catalog was read again.
// Runs in the browser; kept free of DOM code so the rules are testable in Node.
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const time = (value) => Date.parse(value || "") || 0;
/** "23-БА" matches "23-ЛГ_23-БА.docx" but not "23-БАз" or "123-БА". */
export function namesGroup(fileName, group) {
if (!group) return false;
const base = fileName.replace(/\.docx?$/i, "");
return new RegExp(
`(^|[^0-9A-Za-zА-ЯЁа-яё])${escape(group)}(?![0-9A-Za-zА-ЯЁа-яё])`,
"iu",
).test(base);
}
/**
* changed: the same document was republished (or parsed by an older version) — 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;
const fileId = saved?.source?.fileId;
if (!fileId || !catalog?.files || catalog.stale) return null;
const current = catalog.files.find((f) => f.id === fileId) || null;
const savedTime = time(saved.source.modified) || time(saved.source.importedAt);
const newer =
catalog.files
.filter(
(f) =>
f.id !== fileId &&
!dismissed.includes(f.id) &&
namesGroup(f.name, group) &&
time(f.modified) > savedTime &&
(!saved.source.faculty || f.faculty === saved.source.faculty),
)
.sort((a, b) => time(b.modified) - time(a.modified))[0] || null;
const changed =
current &&
(current.modified !== saved.source.modified ||
(saved.schemaVersion || 0) < schemaVersion)
? current
: null;
if (!changed && !newer && current) return null;
return { changed, newer, missing: !current };
}

84
shared/weeks.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
export type WeekRange = [number, number | null];
export interface CalendarWeek {
n: number;
start: string;
end: string;
color: "white" | "green";
semester?: string;
}
export interface Semester {
id: string;
kind: "autumn" | "spring";
year: number;
label: string;
start: string;
end: string;
}
export interface Bell {
pair: number;
start: string;
end: string;
halves: [string, string][];
}
export interface AcademicCalendar {
weeks: CalendarWeek[];
semesters?: Semester[];
bells: Bell[];
source: {
weeks?: string | null;
bells?: string | null;
weeksFiles?: string[];
bellsFile?: string | null;
};
updatedAt: string | null;
stale?: boolean;
error?: string;
}
/** A calendar narrowed to the semester a schedule belongs to. */
export interface SemesterCalendar extends AcademicCalendar {
semester: Semester;
}
interface ScheduleLike {
lessons: { date: string | null }[];
period?: string;
academicYear?: string | number | null;
}
interface WeekLesson {
day: number;
date: string | null;
dates?: string[];
condition: string;
variant: "all" | "upper" | "lower";
weekRanges?: WeekRange[] | null;
}
export function parseWeekRanges(condition: string): WeekRange[] | null;
export function parseTextDates(raw: string, year: string | number): string[];
export function isoDate(time: number): string;
export function addDays(iso: string, days: number): string;
export function weekday(iso: string): number;
export function todayIso(now?: Date): string;
export function weekIndexOf(
calendar: AcademicCalendar | null | undefined,
iso: string,
): number;
export function dateInWeek(week: CalendarWeek, day: number): string | null;
export function lessonWeeks(lesson: WeekLesson, lastWeek: number): number[] | null;
export function lessonDates(
lesson: WeekLesson,
calendar: AcademicCalendar | null | undefined,
): string[] | null;
export function academicStartYear(iso: string): number;
export function assignSemesters(weeks: CalendarWeek[]): {
weeks: CalendarWeek[];
semesters: Semester[];
};
export function semesterFor(
calendar: AcademicCalendar | null | undefined,
schedule: ScheduleLike | null | undefined,
today: string,
): Semester | null;
export function semesterView(
calendar: AcademicCalendar | null | undefined,
schedule: ScheduleLike | null | undefined,
today: string,
): SemesterCalendar | null;

223
shared/weeks.js Normal file
View File

@@ -0,0 +1,223 @@
// Week rules shared by the Word parser (server) and the PWA (browser).
// The university publishes "Расписание белых и зеленых недель": week 1 starts on
// 1 September, odd weeks are white and even weeks are green. A week mark inside a
// white or green table row only applies to weeks of that row's colour.
const MONTHS = [
"январ",
"феврал",
"март",
"апрел",
"ма[яй]",
"июн",
"июл",
"август",
"сентябр",
"октябр",
"ноябр",
"декабр",
];
const DAY_MS = 86400000;
/**
* "6-13н", "1-4,6-11н.", "с 3н.", "со 2 нед.", "1-8,10-17" → [[6, 13]], [[1, 4], [6, 11]], [[3, null]]…
* Returns null when the text is empty or not a pure week list.
*/
export function parseWeekRanges(condition) {
let text = (condition || "").trim().toLowerCase();
if (!text) return null;
const from = text.match(/^(?:с|со)\s*(\d{1,2})\s*(?:н(?:ед)?)?[.\s]*$/);
if (from) return [[Number(from[1]), null]];
// "с 4-16н" reads as an ordinary range.
text = text
.replace(/^(?:с|со)\s+/, "")
.replace(/\s*н(?:ед)?[.\s]*$/, "")
.trim();
if (!/^\d{1,2}(?:\s*[-–,]\s*\d{1,2})*$/.test(text)) return null;
const ranges = [];
for (const part of text.split(/\s*,\s*/)) {
const bounds = part.split(/\s*[-–]\s*/).map(Number);
// "6-12-16" is a typo in the source; leave it for the student to check.
if (bounds.length > 2) return null;
const [a, b = a] = bounds;
if (!a || !b || a > b || b > 60) return null;
ranges.push([a, b]);
}
return ranges;
}
/** "10 сентября, 15 октября" → ISO dates inside the academic year that starts in `year`. */
export function parseTextDates(raw, year) {
if (!raw || !year) return [];
const pattern = new RegExp(`(\\d{1,2})\\s+(${MONTHS.join("|")})[а-я]*`, "giu");
const dates = [];
for (const m of raw.matchAll(pattern)) {
const month =
MONTHS.findIndex((stem) => new RegExp(`^${stem}`, "iu").test(m[2])) + 1;
const day = Number(m[1]);
if (!month || day < 1 || day > 31) continue;
const y = month >= 8 ? Number(year) : Number(year) + 1;
dates.push(
`${y}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
);
}
return [...new Set(dates)].sort();
}
const toTime = (iso) => Date.parse(`${iso}T00:00:00Z`);
export const isoDate = (time) => new Date(time).toISOString().slice(0, 10);
export const addDays = (iso, days) => isoDate(toTime(iso) + days * DAY_MS);
/** 1 = Monday … 7 = Sunday */
export const weekday = (iso) => ((new Date(toTime(iso)).getUTCDay() + 6) % 7) + 1;
/** Today in Europe/Minsk as YYYY-MM-DD. */
export function todayIso(now = new Date()) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Minsk",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
/** Index of the calendar week that contains the date, or -1. */
export function weekIndexOf(calendar, iso) {
return calendar?.weeks.findIndex((w) => w.start <= iso && iso <= w.end) ?? -1;
}
/** Date of a weekday inside a calendar week; null when it falls outside the week (week 1 starts on Tuesday). */
export function dateInWeek(week, day) {
const monday = addDays(week.start, 1 - weekday(week.start));
const date = addDays(monday, day - 1);
return date < week.start || date > week.end ? null : date;
}
/** Week numbers in which a weekly lesson takes place, limited by the calendar length. */
export function lessonWeeks(lesson, lastWeek) {
const ranges =
lesson.weekRanges !== undefined
? lesson.weekRanges
: parseWeekRanges(lesson.condition);
const parity =
lesson.variant === "upper" ? 1 : lesson.variant === "lower" ? 0 : null;
if (lesson.condition && !ranges) return null;
const all = [];
for (const [a, b] of ranges || [[1, lastWeek]])
for (let n = a; n <= Math.min(b ?? lastWeek, lastWeek); n++) all.push(n);
const unique = [...new Set(all)].sort((x, y) => x - y);
const matching =
parity === null ? unique : unique.filter((n) => n % 2 === parity);
// A mark that contradicts its row colour is kept literally rather than dropped.
return matching.length ? matching : unique;
}
/** Concrete dates of a lesson; null when its weeks cannot be read from the condition. */
export function lessonDates(lesson, calendar) {
if (!calendar?.weeks.length) return null;
if (lesson.date) return [lesson.date];
if (lesson.dates?.length) return lesson.dates;
const numbers = lessonWeeks(lesson, calendar.weeks.at(-1).n);
if (!numbers) return null;
return numbers
.map((n) => calendar.weeks.find((w) => w.n === n))
.filter(Boolean)
.map((w) => dateInWeek(w, lesson.day))
.filter(Boolean);
}
/** The academic year starts on 1 September: 2026-08-31 → 2025, 2026-09-01 → 2026. */
export function academicStartYear(iso) {
const [year, month] = iso.split("-").map(Number);
return month >= 9 ? year : year - 1;
}
const daysBetween = (a, b) => Math.round((toTime(b) - toTime(a)) / DAY_MS);
/**
* Splits a week table into semesters. A new semester starts when numbering
* restarts or when more than three weeks separate two rows.
*/
export function assignSemesters(weeks) {
const sorted = [...weeks].sort((a, b) => a.start.localeCompare(b.start));
const groups = [];
for (const week of sorted) {
const group = groups.at(-1),
previous = group?.at(-1);
if (!previous || week.n <= previous.n || daysBetween(previous.end, week.start) > 21)
groups.push([week]);
else group.push(week);
}
const semesters = [];
const result = [];
for (const group of groups) {
const first = group[0],
month = Number(first.start.slice(5, 7)),
kind = month >= 7 ? "autumn" : "spring",
year = Number(first.start.slice(0, 4)) - (kind === "spring" ? 1 : 0);
let id = `${year}-${kind}`;
if (semesters.some((s) => s.id === id)) id += `-${semesters.length + 1}`;
semesters.push({
id,
kind,
year,
label: `${kind === "autumn" ? "Осенний" : "Весенний"} семестр ${year}/${String(year + 1).slice(2)}`,
start: first.start,
end: group.at(-1).end,
});
for (const week of group) result.push({ ...week, semester: id });
}
return { weeks: result, semesters };
}
/**
* The part of the week table that a schedule refers to. Week marks restart in
* every semester, so mixing them would give confidently wrong dates.
* Returns null when the semester cannot be determined unambiguously.
*/
export function semesterFor(calendar, schedule, today) {
if (!calendar?.weeks?.length) return null;
const { semesters } = calendar.semesters
? calendar
: assignSemesters(calendar.weeks);
const lessons = schedule?.lessons || [];
const dated = lessons.length > 0 && lessons.every((l) => l.date);
if (dated) {
const dates = lessons.map((l) => l.date).sort();
const target = dates.find((d) => d >= today) || dates[0];
return semesters.find((s) => s.start <= target && target <= s.end) || null;
}
const text = `${schedule?.period || ""}`;
const kind = /весенн/i.test(text) ? "spring" : /осенн/i.test(text) ? "autumn" : null;
const year = Number(schedule?.academicYear) || null;
if (kind) {
const matching = semesters.filter(
(s) => s.kind === kind && (!year || s.year === year),
);
if (matching.length === 1) return matching[0];
if (!year && matching.length > 1)
return (
matching.find((s) => s.start <= today && today <= s.end) ||
[...matching].sort(
(a, b) =>
Math.abs(daysBetween(today, a.start)) -
Math.abs(daysBetween(today, b.start)),
)[0]
);
return null;
}
return semesters.find((s) => s.start <= today && today <= s.end) || null;
}
/** Calendar narrowed to one semester; null when the schedule's semester is not in the table. */
export function semesterView(calendar, schedule, today) {
const semester = semesterFor(calendar, schedule, today);
if (!semester) return null;
const weeks = calendar.semesters
? calendar.weeks
: assignSemesters(calendar.weeks).weeks;
return {
...calendar,
weeks: weeks.filter((w) => w.semester === semester.id),
semester,
};
}

2425
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

384
src/Groups.tsx Normal file
View File

@@ -0,0 +1,384 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "./icons";
import { facultyShort, formShort } from "./labels";
/** A group the student opened before; kept per device. */
export interface RecentGroup {
group: string;
/** Catalog file id, or the saved schedule id for an imported Word file. */
document: string;
fileId?: string;
faculty?: string;
course?: string;
}
interface GroupResult {
group: string;
fileId: string;
file: string;
faculty: string;
course: string;
form: string;
period: string;
}
interface Facet {
name: string;
count: number;
}
interface GroupsResponse {
results: GroupResult[];
facets: { faculties: Facet[]; courses: Facet[]; forms: Facet[] };
total: number;
year: string;
}
const PLAIN_PERIOD = /^(осенний|весенний) семестр$|^расписание из word$/i;
const describe = (r: { faculty?: string; course?: string; form?: string; period?: string }) =>
[
r.faculty && facultyShort(r.faculty),
r.course,
r.form && formShort(r.form),
r.period && !PLAIN_PERIOD.test(r.period) ? r.period.toLowerCase() : "",
]
.filter(Boolean)
.join(" · ");
export default function Groups({
current,
siblings,
subgroups,
subgroup,
onSubgroup,
onSibling,
recent,
onRecent,
onOpen,
onImport,
online,
initialQuery,
firstRun,
}: {
current: { group: string; faculty?: string; course?: string; name: string } | null;
siblings: string[];
subgroups: string[];
subgroup: string | null;
onSubgroup: (label: string | null) => void;
onSibling: (group: string) => void;
recent: RecentGroup[];
onRecent: (item: RecentGroup) => void;
onOpen: (fileId: string, group: string) => Promise<boolean>;
onImport: () => void;
online: boolean;
initialQuery: string;
firstRun: boolean;
}) {
const [query, setQuery] = useState(initialQuery),
[faculty, setFaculty] = useState(current?.faculty || ""),
[course, setCourse] = useState(""),
[form, setForm] = useState(""),
[data, setData] = useState<GroupsResponse | null>(null),
[loading, setLoading] = useState(false),
[error, setError] = useState(""),
[opening, setOpening] = useState("");
const request = useRef(0),
input = useRef<HTMLInputElement>(null);
useEffect(() => setQuery(initialQuery), [initialQuery]);
useEffect(() => {
if (firstRun) input.current?.focus();
}, [firstRun]);
useEffect(() => {
if (!online) return;
const id = ++request.current;
setLoading(true);
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query.trim(), faculty, course, form });
const r = await fetch(`/api/groups?${params}`);
const body = await r.json();
if (!r.ok) throw new Error(body.error || "Не удалось загрузить группы.");
if (id === request.current) {
setData(body);
setError("");
}
} catch (e) {
if (id === request.current)
setError(
e instanceof TypeError
? "Сервер недоступен. Откройте группу из недавних или попробуйте позже."
: (e as Error).message,
);
} finally {
if (id === request.current) setLoading(false);
}
}, query ? 200 : 0);
return () => window.clearTimeout(timer);
}, [query, faculty, course, form, online]);
const searching = query.trim().length >= 2;
const open = async (r: GroupResult) => {
const key = `${r.fileId}|${r.group}`;
setOpening(key);
await onOpen(r.fileId, r.group);
setOpening("");
};
const results = data?.results || [];
const others = recent;
return (
<section className="groups-screen" aria-label="Выбор группы">
{firstRun && (
<div className="groups-hero">
<h2>Найдите свою группу</h2>
<p>Расписание откроется сразу и будет доступно без интернета.</p>
</div>
)}
<label className="search-bar">
<Icon name="search" />
<input
ref={input}
type="search"
inputMode="search"
autoComplete="off"
placeholder="Номер группы, например 23-БА"
aria-label="Номер группы"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query && (
<button
className="icon-button"
aria-label="Очистить поиск"
onClick={() => setQuery("")}
>
<Icon name="close" />
</button>
)}
</label>
{error && (
<div role="status" className="status-message error">
<Icon name="error" />
<span>{error}</span>
</div>
)}
{!online && (
<div role="status" className="status-message">
<Icon name="cloud_off" />
<span>
Нет сети: поиск по каталогу недоступен. Сохранённые группы открываются
и без интернета.
</span>
</div>
)}
{searching ? (
<div className="result-group" aria-live="polite">
<h3>
{loading && !results.length
? "Ищем…"
: results.length
? "Найденные группы"
: "Группа не найдена"}
</h3>
{results.length > 0 ? (
<GroupList results={results} opening={opening} onOpen={open} />
) : (
!loading && (
<div className="empty-state compact">
<p>
Группы «{query.trim()}» нет в каталоге {data?.year || "университета"}.
Проверьте номер или импортируйте свой Word.
</p>
<button className="secondary" onClick={onImport}>
<Icon name="upload_file" size={20} />
Импорт из Word
</button>
</div>
)
)}
</div>
) : (
<>
{current && (
<div className="current-group">
<div className="current-head">
<span className="menu-icon">
<Icon name="groups" fill />
</span>
<div>
<small>Сейчас открыта</small>
<strong>{current.group}</strong>
<span>{describe(current) || current.name}</span>
</div>
</div>
{subgroups.length > 0 && (
<div className="chip-row">
<span>Подгруппа</span>
{[null, ...subgroups].map((label) => {
const selected = (subgroup || null) === label;
return (
<button
key={label || "all"}
className={`filter-chip ${selected ? "selected" : ""}`}
aria-pressed={selected}
aria-label={label || "Все подгруппы"}
onClick={() => onSubgroup(label)}
>
{selected && <Icon name="check" size={18} />}
{label ? label.replace("Подгруппа ", "") : "Все"}
</button>
);
})}
</div>
)}
{siblings.length > 0 && (
<div className="chip-row">
<span>В этом же документе</span>
{siblings.map((g) => (
<button key={g} className="filter-chip" onClick={() => onSibling(g)}>
{g}
</button>
))}
</div>
)}
</div>
)}
{others.length > 0 && (
<div className="result-group">
<h3>Недавние</h3>
<div className="recent-chips">
{others.map((r) => (
<button key={`${r.document}|${r.group}`} onClick={() => onRecent(r)}>
<b>{r.group}</b>
<small>{describe(r) || "Импортированный Word"}</small>
</button>
))}
</div>
</div>
)}
{online && data && (
<div className="result-group catalog-browse">
<h3>Каталог {data.year}</h3>
<div className="chip-row" role="group" aria-label="Факультет">
{data.facets.faculties.map((f) => (
<button
key={f.name}
className={`filter-chip ${faculty === f.name ? "selected" : ""}`}
aria-pressed={faculty === f.name}
title={f.name}
onClick={() => {
setFaculty(faculty === f.name ? "" : f.name);
setCourse("");
setForm("");
}}
>
{faculty === f.name && <Icon name="check" size={18} />}
{facultyShort(f.name)}
</button>
))}
</div>
{faculty && (
<>
<div className="chip-row" role="group" aria-label="Курс">
{data.facets.courses.map((c) => (
<button
key={c.name}
className={`filter-chip ${course === c.name ? "selected" : ""}`}
aria-pressed={course === c.name}
onClick={() => setCourse(course === c.name ? "" : c.name)}
>
{course === c.name && <Icon name="check" size={18} />}
{c.name}
</button>
))}
</div>
{data.facets.forms.length > 1 && (
<div className="chip-row" role="group" aria-label="Форма обучения">
{data.facets.forms.map((f) => (
<button
key={f.name}
className={`filter-chip ${form === f.name ? "selected" : ""}`}
aria-pressed={form === f.name}
onClick={() => setForm(form === f.name ? "" : f.name)}
>
{form === f.name && <Icon name="check" size={18} />}
{formShort(f.name)}
</button>
))}
</div>
)}
{results.length > 0 ? (
<GroupList results={results} opening={opening} onOpen={open} />
) : (
!loading && <p className="coverage-note">Групп с такими условиями нет.</p>
)}
</>
)}
{!faculty && (
<p className="coverage-note">
Выберите факультет, чтобы посмотреть его группы, или введите номер
группы в поиске. Всего в каталоге {data.total}{" "}
{data.total % 10 === 1 && data.total % 100 !== 11 ? "группа" : "групп"}.
</p>
)}
</div>
)}
{online && !data && loading && (
<div className="schedule-skeleton" role="status" aria-label="Загружаем каталог">
<span />
</div>
)}
<div className="groups-footer">
<span>Вашей группы нет в каталоге?</span>
<button className="text-link" onClick={onImport}>
<Icon name="upload_file" size={20} />
Импортировать Word
</button>
</div>
</>
)}
</section>
);
}
function GroupList({
results,
opening,
onOpen,
}: {
results: GroupResult[];
opening: string;
onOpen: (r: GroupResult) => void;
}) {
return (
<div className="result-list">
{results.map((r) => {
const key = `${r.fileId}|${r.group}`;
return (
<button
key={key}
disabled={!!opening}
aria-busy={opening === key}
onClick={() => onOpen(r)}
>
<span className="menu-icon">
<Icon name="groups" />
</span>
<span>
<b>{r.group}</b>
<small>{describe(r)}</small>
</span>
{opening === key ? (
<Icon name="refresh" className="spin" />
) : (
<Icon name="chevron_right" />
)}
</button>
);
})}
</div>
);
}

592
src/Search.tsx Normal file
View File

@@ -0,0 +1,592 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "./icons";
import {
type AcademicCalendar,
addDays,
todayIso,
weekIndexOf,
weekday,
} from "../shared/weeks";
import {
bellFor,
dayNumber,
longDate,
minutes,
plural,
weekSpan,
clockMinutes,
} from "./calendar";
const SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
interface Coverage {
documents: number;
total: number;
failed: string[];
withoutSemester: string[];
year: string;
}
interface DayLesson {
start: string;
end: string | null;
subject: string;
type: string;
teachers: string[];
rooms: string[];
moved: boolean;
distance: boolean;
groups: string[];
documents: { id: string; name: string }[];
condition: string;
uncertain: boolean;
}
interface FreeResult {
bell: { pair: number; start: string; end: string };
free: { letter: string; rooms: string[] }[];
freeCount: number;
busyCount: number;
knownRooms: number;
maybe: { room: string; lessons: { subject: string; groups: string[]; condition: string }[] }[];
coverage: Coverage;
}
type Target = { kind: "teacher"; name: string; faculties?: string[] } | { kind: "room"; room: string };
async function api<T>(url: string): Promise<T> {
const r = await fetch(url).catch(() => {
throw new Error("Сервер недоступен. Поиск по университету работает только онлайн.");
});
const body = await r.json();
if (!r.ok) throw new Error(body.error || "Не удалось выполнить поиск.");
return body;
}
const RECENT = "psu-recent-search-v1";
function readRecent(): Target[] {
try {
const value = JSON.parse(localStorage.getItem(RECENT) || "[]");
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
function remember(target: Target) {
const same = (t: Target) =>
t.kind === target.kind &&
(t.kind === "teacher" ? t.name === (target as typeof t).name : t.room === (target as typeof t).room);
try {
localStorage.setItem(
RECENT,
JSON.stringify([target, ...readRecent().filter((t) => !same(t))].slice(0, 6)),
);
} catch {
/* Recent searches are a convenience only. */
}
}
/** Opens on today; on Sunday on Monday, when lessons resume. */
function startDate() {
const today = todayIso();
return weekday(today) === 7 ? addDays(today, 1) : today;
}
function CoverageNote({ coverage, free = false }: { coverage: Coverage; free?: boolean }) {
return (
<p className="coverage-note">
По расписаниям из Word: учтено {coverage.documents} из {coverage.total}{" "}
{plural(coverage.total, ["документа", "документов", "документов"])} каталога{" "}
{coverage.year}.
{coverage.withoutSemester.length > 0 &&
` Для ${coverage.withoutSemester.length} ${plural(coverage.withoutSemester.length, ["документа", "документов", "документов"])} семестр не найден в таблице недель — они не учтены.`}
{coverage.failed.length > 0 &&
` Не распознано: ${coverage.failed.length}.`}
{free &&
" Аудитория может быть занята мероприятием, консультацией или закрыта — расписание этого не знает."}
</p>
);
}
function DayPicker({
date,
onChange,
calendar,
}: {
date: string;
onChange: (date: string) => void;
calendar: AcademicCalendar | null;
}) {
const today = todayIso();
const monday = addDays(date, 1 - weekday(date));
const week = calendar?.weeks[weekIndexOf(calendar, date)];
return (
<div className="search-days">
<div className="week-switcher">
<button
className="icon-button"
aria-label="Предыдущая неделя"
onClick={() => onChange(addDays(date, -7))}
>
<Icon name="chevron_left" />
</button>
<div className="week-label" aria-live="polite">
<strong>{week ? `Неделя ${week.n}` : "Вне таблицы недель"}</strong>
{week && (
<span className={`week-chip ${week.color}`}>
{week.color === "white" ? "белая" : "зелёная"}
</span>
)}
<small>{weekSpan(monday, addDays(monday, 6))}</small>
</div>
{(date < addDays(today, 1 - weekday(today)) ||
date > addDays(today, 7 - weekday(today))) && (
<button className="today-button" onClick={() => onChange(startDate())}>
Сегодня
</button>
)}
<button
className="icon-button"
aria-label="Следующая неделя"
onClick={() => onChange(addDays(date, 7))}
>
<Icon name="chevron_right" />
</button>
</div>
<div className="day-strip dated" aria-label="Дни недели">
{SHORT.map((label, i) => {
const iso = addDays(monday, i);
return (
<button
key={iso}
className={`${iso === date ? "selected" : ""} ${iso === today ? "today" : ""}`}
aria-pressed={iso === date}
aria-label={`${DAYS[i]}, ${longDate(iso)}${iso === today ? ", сегодня" : ""}`}
onClick={() => onChange(iso)}
>
<span>{label}</span>
<span className="day-number">{dayNumber(iso)}</span>
</button>
);
})}
</div>
</div>
);
}
export default function Search({
calendar,
online,
now,
}: {
calendar: AcademicCalendar | null;
online: boolean;
now: number;
}) {
const [tab, setTab] = useState<"people" | "free">("people"),
[query, setQuery] = useState(""),
[results, setResults] = useState<{ teachers: { name: string; count: number; faculties: string[] }[]; rooms: { room: string; count: number }[] } | null>(null),
[target, setTarget] = useState<Target | null>(null),
[date, setDate] = useState(startDate),
[day, setDay] = useState<{ lessons: DayLesson[]; coverage: Coverage } | null>(null),
[free, setFree] = useState<FreeResult | null>(null),
[pair, setPair] = useState(() => {
const bells = calendar?.bells || [];
const m = clockMinutes(now);
return (
bells.find((b) => m < minutes(b.end))?.pair || bells[0]?.pair || 1
);
}),
[loading, setLoading] = useState(false),
[error, setError] = useState(""),
[recent, setRecent] = useState(readRecent),
[openMaybe, setOpenMaybe] = useState(false);
const request = useRef(0);
useEffect(() => {
if (query.trim().length < 2) return setResults(null);
const id = ++request.current;
const timer = window.setTimeout(async () => {
try {
const r = await api<NonNullable<typeof results>>(
`/api/directory/search?q=${encodeURIComponent(query.trim())}`,
);
if (id === request.current) {
setResults(r);
setError("");
}
} catch (e) {
if (id === request.current) setError((e as Error).message);
}
}, 250);
return () => window.clearTimeout(timer);
}, [query]);
useEffect(() => {
if (!target) return setDay(null);
const id = ++request.current;
setLoading(true);
const param =
target.kind === "teacher"
? `teacher=${encodeURIComponent(target.name)}`
: `room=${encodeURIComponent(target.room)}`;
api<{ lessons: DayLesson[]; coverage: Coverage }>(`/api/directory/day?${param}&date=${date}`)
.then((r) => id === request.current && (setDay(r), setError("")))
.catch((e) => id === request.current && setError(e.message))
.finally(() => id === request.current && setLoading(false));
}, [target, date]);
useEffect(() => {
if (tab !== "free") return;
const id = ++request.current;
setLoading(true);
api<FreeResult>(`/api/directory/free?date=${date}&pair=${pair}`)
.then((r) => id === request.current && (setFree(r), setError("")))
.catch((e) => id === request.current && setError(e.message))
.finally(() => id === request.current && setLoading(false));
}, [tab, date, pair]);
const open = (t: Target) => {
remember(t);
setRecent(readRecent());
setTarget(t);
};
const today = todayIso(new Date(now));
const clock = clockMinutes(now);
if (!online)
return (
<div className="empty-state">
<Icon name="cloud_off" size={40} />
<h3>Нет сети</h3>
<p>
Поиск преподавателей и аудиторий идёт по всем расписаниям университета
на сервере и работает только онлайн. Ваше расписание доступно без сети.
</p>
</div>
);
return (
<section className="search-screen" aria-label="Поиск по университету">
{!target && (
<div className="view-switch search-tabs" aria-label="Что искать">
<button
className={tab === "people" ? "selected" : ""}
aria-pressed={tab === "people"}
onClick={() => setTab("people")}
>
{tab === "people" && <Icon name="check" size={20} />}
Кто где
</button>
<button
className={tab === "free" ? "selected" : ""}
aria-pressed={tab === "free"}
onClick={() => setTab("free")}
>
{tab === "free" && <Icon name="check" size={20} />}
Свободные аудитории
</button>
</div>
)}
{error && (
<div role="status" className="status-message error">
<Icon name="error" />
<span>{error}</span>
</div>
)}
{tab === "people" && !target && (
<>
<label className="search-bar">
<Icon name="search" />
<input
type="search"
placeholder="Фамилия или номер аудитории"
aria-label="Преподаватель или аудитория"
value={query}
autoFocus
onChange={(e) => setQuery(e.target.value)}
/>
{query && (
<button
className="icon-button"
aria-label="Очистить поиск"
onClick={() => setQuery("")}
>
<Icon name="close" />
</button>
)}
</label>
{!results && recent.length > 0 && (
<div className="result-group">
<h3>Недавние</h3>
<div className="result-list">
{recent.map((t) => (
<button
key={t.kind === "teacher" ? `t${t.name}` : `r${t.room}`}
onClick={() => open(t)}
>
<span className="menu-icon">
<Icon name={t.kind === "teacher" ? "person" : "meeting_room"} />
</span>
<span>{t.kind === "teacher" ? t.name : `Аудитория ${t.room}`}</span>
<Icon name="chevron_right" />
</button>
))}
</div>
</div>
)}
{!results && !recent.length && (
<div className="empty-state">
<Icon name="person_search" size={40} />
<h3>Кто где и когда</h3>
<p>
Найдите преподавателя или аудиторию — покажем занятия на выбранный
день по всем расписаниям университета.
</p>
</div>
)}
{results && (
<>
{results.teachers.length > 0 && (
<div className="result-group">
<h3>Преподаватели</h3>
<div className="result-list">
{results.teachers.map((t) => (
<button
key={t.name}
onClick={() => open({ kind: "teacher", name: t.name, faculties: t.faculties })}
>
<span className="menu-icon">
<Icon name="person" />
</span>
<span>
{t.name}
<small>{t.faculties.join(", ") || "Факультет не указан"}</small>
</span>
<Icon name="chevron_right" />
</button>
))}
</div>
</div>
)}
{results.rooms.length > 0 && (
<div className="result-group">
<h3>Аудитории</h3>
<div className="room-chips">
{results.rooms.map((r) => (
<button key={r.room} onClick={() => open({ kind: "room", room: r.room })}>
<Icon name="meeting_room" size={18} />
{r.room}
</button>
))}
</div>
</div>
)}
{!results.teachers.length && !results.rooms.length && (
<div className="empty-state">
<Icon name="search_off" size={40} />
<h3>Никого не нашли</h3>
<p>
Проверьте фамилию или номер. Ищем только тех, кто указан в
расписаниях каталога.
</p>
</div>
)}
</>
)}
</>
)}
{tab === "people" && target && (
<>
<div className="target-header">
<button
className="icon-button"
aria-label="Назад к поиску"
onClick={() => setTarget(null)}
>
<Icon name="chevron_left" />
</button>
<span className="menu-icon">
<Icon name={target.kind === "teacher" ? "person" : "meeting_room"} />
</span>
<div>
<h2>{target.kind === "teacher" ? target.name : `Аудитория ${target.room}`}</h2>
{target.kind === "teacher" && target.faculties?.length ? (
<small>{target.faculties.join(", ")}</small>
) : null}
</div>
</div>
<DayPicker date={date} onChange={setDate} calendar={calendar} />
<h3 className="search-date">
{DAYS[weekday(date) - 1][0].toUpperCase() + DAYS[weekday(date) - 1].slice(1)},{" "}
{longDate(date)}
{date === today ? " · сегодня" : ""}
</h3>
{loading && !day ? (
<div className="schedule-skeleton" role="status" aria-label="Ищем занятия">
<span />
<span />
</div>
) : day && day.lessons.length ? (
<div className="lesson-list">
{day.lessons.map((l, i) => {
const bell = bellFor(calendar, l.start);
const end = l.end || bell?.end || null;
const now =
date === today && end && minutes(l.start) <= clock && clock < minutes(end);
return (
<section key={i} className={`time-slot ${now ? "now" : ""}`}>
<h3 className="slot-time">
<time>{l.start}</time>
<span>{end ? `— ${end}` : "конец не указан"}</span>
{bell && <span className="slot-pair">{bell.pair} пара</span>}
{now && <span className="live-chip on">Идёт сейчас</span>}
</h3>
<div className="slot-lessons">
<div className="lesson-card directory-card">
<span className="lesson-body">
<span className="lesson-tags">
<span
className={`type-tag ${l.type === "Лекция" ? "lecture" : l.type === "Практика" ? "practice" : l.type === "Лабораторная" ? "lab" : "other"}`}
>
{l.type}
</span>
{l.uncertain && <span className="shared-tag">Сверьте недели</span>}
{l.moved && <span className="shared-tag">Перенос аудитории</span>}
{l.distance && <span className="shared-tag">Дистанционно</span>}
</span>
<strong>{l.subject}</strong>
<span className="lesson-meta">
<span>
<Icon name="groups" size={20} />
{l.groups.join(", ")}
</span>
{target.kind === "teacher" ? (
l.rooms.length > 0 && (
<span>
<Icon name="location_on" size={20} />
Ауд. {l.rooms.join(", ")}
</span>
)
) : (
l.teachers.length > 0 && (
<span>
<Icon name="person" size={20} />
{l.teachers.join(", ")}
</span>
)
)}
</span>
{l.condition && (
<span className="condition">
<Icon name="event_repeat" size={20} />
<span>Пометка в Word: {l.condition}</span>
</span>
)}
</span>
</div>
</div>
</section>
);
})}
</div>
) : (
day && (
<div className="empty-state">
<Icon name="event_busy" size={40} />
<h3>Занятий нет</h3>
<p>
{target.kind === "teacher"
? "В этот день у преподавателя нет занятий по расписаниям."
: "В этот день аудитория не занята по расписаниям."}
</p>
</div>
)
)}
{day && <CoverageNote coverage={day.coverage} />}
</>
)}
{tab === "free" && (
<>
<DayPicker date={date} onChange={setDate} calendar={calendar} />
<div className="pair-picker" aria-label="Пара">
{(calendar?.bells || []).map((b) => (
<button
key={b.pair}
className={b.pair === pair ? "selected" : ""}
aria-pressed={b.pair === pair}
aria-label={`${b.pair} пара, ${b.start}–${b.end}`}
onClick={() => setPair(b.pair)}
>
<b>{b.pair}</b>
<small>{b.start}</small>
</button>
))}
</div>
{free && (
<>
<h3 className="search-date">
{longDate(date)} · {free.bell.pair} пара, {free.bell.start}–{free.bell.end}
</h3>
<p className="free-summary">
<strong>
Свободно {free.freeCount} {plural(free.freeCount, ["аудитория", "аудитории", "аудиторий"])}
</strong>{" "}
из {free.knownRooms} известных · занято {free.busyCount}
</p>
{free.free.map((g) => (
<div className="result-group" key={g.letter || "plain"}>
<h3>{g.letter ? `Номера с «${g.letter}»` : "Номера без буквы"}</h3>
<div className="room-chips">
{g.rooms.map((room) => (
<button
key={room}
onClick={() => {
setTab("people");
open({ kind: "room", room });
}}
>
{room}
</button>
))}
</div>
</div>
))}
{free.maybe.length > 0 && (
<details
className="other-weeks"
open={openMaybe}
onToggle={(e) => setOpenMaybe((e.target as HTMLDetailsElement).open)}
>
<summary>
<span>
Возможно заняты · {free.maybe.length}
</span>
<Icon name="keyboard_arrow_down" />
</summary>
<ul className="maybe-list">
{free.maybe.map((m) => (
<li key={m.room}>
<b>{m.room}</b>
{m.lessons.map((l, i) => (
<span key={i}>
{l.subject} · {l.groups.join(", ")} · пометка «{l.condition}» не распознана
</span>
))}
</li>
))}
</ul>
</details>
)}
<CoverageNote coverage={free.coverage} free />
</>
)}
{loading && !free && (
<div className="schedule-skeleton" role="status" aria-label="Ищем свободные аудитории">
<span />
</div>
)}
</>
)}
</section>
);
}

98
src/calendar.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { AcademicCalendar, Bell } from "../shared/weeks";
import type { Lesson } from "./types";
const format = (iso: string, options: Intl.DateTimeFormatOptions) =>
new Date(`${iso}T12:00:00Z`).toLocaleDateString("ru-RU", {
...options,
timeZone: "UTC",
});
export const longDate = (iso: string) =>
format(iso, { day: "numeric", month: "long" });
export const shortDate = (iso: string) =>
format(iso, { day: "numeric", month: "short" }).replace(" ", "\u00a0");
export const dayNumber = (iso: string) => format(iso, { day: "numeric" });
/** "12–18 октября" or "28 сентября – 4 октября". */
export function weekSpan(start: string, end: string) {
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
return sameMonth
? `${dayNumber(start)}–${longDate(end)}`
: `${longDate(start)} – ${longDate(end)}`;
}
export const plural = (n: number, forms: [string, string, string]) => {
const tail = n % 100;
return tail >= 11 && tail <= 14
? forms[2]
: n % 10 === 1
? forms[0]
: n % 10 >= 2 && n % 10 <= 4
? forms[1]
: forms[2];
};
export const minutes = (time: string) => {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
};
export function bellFor(calendar: AcademicCalendar | null, start: string) {
return calendar?.bells.find((b) => b.start === start) || null;
}
export function pairLabel(bell: Bell | null, lesson: Lesson) {
if (bell) return `${bell.pair} пара`;
const session = String(lesson.pair || "").match(/\d/);
return session ? `${session[0]} пара` : "";
}
/** Whether the lesson needs a dated status at all: plain weekly lessons don't. */
export const hasWeekLimit = (lesson: Lesson) =>
Boolean(lesson.condition || lesson.date || lesson.dates?.length);
/** Human status from concrete dates, relative to today. */
export function lessonStatus(dates: string[], today: string) {
if (!dates.length) return "Нет занятий в этом семестре";
const upcoming = dates.filter((d) => d >= today);
if (dates.length === 1)
return dates[0] < today
? `Прошло ${shortDate(dates[0])}`
: dates[0] === today
? "Сегодня"
: longDate(dates[0]);
if (!upcoming.length)
return `Занятия закончились · последнее ${shortDate(dates.at(-1)!)}`;
if (dates[0] > today)
return `Начнётся ${shortDate(dates[0])} · ${dates.length} ${plural(dates.length, ["занятие", "занятия", "занятий"])}`;
if (upcoming.length === 1)
return `Последнее занятие — ${shortDate(upcoming[0])}`;
return `Ещё ${upcoming.length} ${plural(upcoming.length, ["занятие", "занятия", "занятий"])} · до ${shortDate(upcoming.at(-1)!)}`;
}
/** Minutes since midnight in Minsk, whatever the device time zone. */
export const clockMinutes = (now: number) => {
const [h, m] = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZone: "Europe/Minsk",
})
.format(now)
.split(":")
.map(Number);
return h * 60 + m;
};
/** Whole days from today to a date (both YYYY-MM-DD). */
export const daysUntil = (today: string, iso: string) =>
Math.round(
(Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86400000,
);
export const countdown = (days: number) =>
days === 0
? "сегодня"
: days === 1
? "завтра"
: days < 0
? "прошло"
: `через ${days} ${plural(days, ["день", "дня", "дней"])}`;

125
src/icons.tsx Normal file
View File

@@ -0,0 +1,125 @@
import type { ComponentType, SVGProps } from "react";
import ArrowForward from "@material-symbols/svg-400/rounded/arrow_forward.svg?react";
import CalendarMonth from "@material-symbols/svg-400/rounded/calendar_month.svg?react";
import CalendarMonthFill from "@material-symbols/svg-400/rounded/calendar_month-fill.svg?react";
import Check from "@material-symbols/svg-400/rounded/check.svg?react";
import ChevronLeft from "@material-symbols/svg-400/rounded/chevron_left.svg?react";
import ChevronRight from "@material-symbols/svg-400/rounded/chevron_right.svg?react";
import Close from "@material-symbols/svg-400/rounded/close.svg?react";
import CloudOff from "@material-symbols/svg-400/rounded/cloud_off.svg?react";
import Description from "@material-symbols/svg-400/rounded/description.svg?react";
import Download from "@material-symbols/svg-400/rounded/download.svg?react";
import ErrorIcon from "@material-symbols/svg-400/rounded/error.svg?react";
import EventBusy from "@material-symbols/svg-400/rounded/event_busy.svg?react";
import EventRepeat from "@material-symbols/svg-400/rounded/event_repeat.svg?react";
import FolderOpen from "@material-symbols/svg-400/rounded/folder_open.svg?react";
import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg?react";
import Groups from "@material-symbols/svg-400/rounded/groups.svg?react";
import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react";
import Info from "@material-symbols/svg-400/rounded/info.svg?react";
import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react";
import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react";
import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg?react";
import OpenInNew from "@material-symbols/svg-400/rounded/open_in_new.svg?react";
import Person from "@material-symbols/svg-400/rounded/person.svg?react";
import Refresh from "@material-symbols/svg-400/rounded/refresh.svg?react";
import Schedule from "@material-symbols/svg-400/rounded/schedule.svg?react";
import Search from "@material-symbols/svg-400/rounded/search.svg?react";
import SearchOff from "@material-symbols/svg-400/rounded/search_off.svg?react";
import UploadFile from "@material-symbols/svg-400/rounded/upload_file.svg?react";
import ContentCopy from "@material-symbols/svg-400/rounded/content_copy.svg?react";
import Difference from "@material-symbols/svg-400/rounded/difference.svg?react";
import Share from "@material-symbols/svg-400/rounded/share.svg?react";
import Visibility from "@material-symbols/svg-400/rounded/visibility.svg?react";
import VisibilityOff from "@material-symbols/svg-400/rounded/visibility_off.svg?react";
import LinkIcon from "@material-symbols/svg-400/rounded/link.svg?react";
import PersonSearch from "@material-symbols/svg-400/rounded/person_search.svg?react";
import MeetingRoom from "@material-symbols/svg-400/rounded/meeting_room.svg?react";
import DoorOpen from "@material-symbols/svg-400/rounded/door_open.svg?react";
import History from "@material-symbols/svg-400/rounded/history.svg?react";
import School from "@material-symbols/svg-400/rounded/school.svg?react";
import HourglassTop from "@material-symbols/svg-400/rounded/hourglass_top.svg?react";
import TravelExplore from "@material-symbols/svg-400/rounded/travel_explore.svg?react";
import TravelExploreFill from "@material-symbols/svg-400/rounded/travel_explore-fill.svg?react";
type Svg = ComponentType<SVGProps<SVGSVGElement>>;
const icons = {
arrow_forward: ArrowForward,
calendar_month: CalendarMonth,
check: Check,
chevron_left: ChevronLeft,
chevron_right: ChevronRight,
close: Close,
cloud_off: CloudOff,
description: Description,
download: Download,
error: ErrorIcon,
event_busy: EventBusy,
event_repeat: EventRepeat,
filter_list: FilterList,
folder_open: FolderOpen,
groups: Groups,
info: Info,
keyboard_arrow_down: KeyboardArrowDown,
location_on: LocationOn,
mobile_arrow_down: MobileArrowDown,
more_horiz: MoreHoriz,
more_vert: MoreVert,
open_in_new: OpenInNew,
person: Person,
refresh: Refresh,
schedule: Schedule,
search: Search,
search_off: SearchOff,
upload_file: UploadFile,
content_copy: ContentCopy,
difference: Difference,
share: Share,
visibility: Visibility,
visibility_off: VisibilityOff,
link: LinkIcon,
person_search: PersonSearch,
meeting_room: MeetingRoom,
door_open: DoorOpen,
history: History,
school: School,
hourglass_top: HourglassTop,
travel_explore: TravelExplore,
} satisfies Record<string, Svg>;
// Filled variants for selected states (navigation, toggles), per Material Symbols FILL axis.
const filled: Partial<Record<IconName, Svg>> = {
calendar_month: CalendarMonthFill,
groups: GroupsFill,
travel_explore: TravelExploreFill,
};
export type IconName = keyof typeof icons;
export function Icon({
name,
size = 24,
fill = false,
className,
}: {
name: IconName;
size?: number;
fill?: boolean;
className?: string;
}) {
const Glyph = (fill && filled[name]) || icons[name];
return (
<Glyph
className={className ? `icon ${className}` : "icon"}
width={size}
height={size}
aria-hidden="true"
focusable="false"
/>
);
}

12
src/labels.ts Normal file
View File

@@ -0,0 +1,12 @@
const FACULTIES: Record<string, string> = {
"Финансово-экономический факультет": "ФЭФ",
"Факультет информационных технологий": "ФИТ",
"Факультет компьютерных наук и электроники": "ФКНЭ",
"Гуманитарный факультет": "ГФ",
"Инженерно-строительный факультет": "ИСФ",
"Механико-технологический факультет": "МТФ",
"Юридический факультет": "ЮФ",
};
export const facultyShort = (s = "") => FACULTIES[s] || s;
/** "Дневная форма" → "дневная". */
export const formShort = (s = "") => s.replace(/\s*форма$/i, "").toLowerCase();

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
if (import.meta.env.PROD && "serviceWorker" in navigator)
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(console.error);
});

213
src/storage.ts Normal file
View File

@@ -0,0 +1,213 @@
import type { Lesson, Schedule } from "./types";
import type { PersonalPrefs, ScheduleDiff } from "../shared/lessons";
import type { RecentGroup } from "./Groups";
import type { AcademicCalendar } from "../shared/weeks";
const KEY = "psu-schedules-v1";
export function readSaved(): Schedule[] {
try {
const value = JSON.parse(localStorage.getItem(KEY) || "[]");
return Array.isArray(value)
? value.filter(
(s) =>
s &&
Array.isArray(s.groups) &&
Array.isArray(s.lessons) &&
s.source,
)
: [];
} catch {
return [];
}
}
export function saveSchedule(schedule: Schedule) {
const list = [
schedule,
...readSaved().filter((s) =>
s.source.fileId
? s.source.fileId !== schedule.source.fileId
: s.id !== schedule.id,
),
].slice(0, 12);
localStorage.setItem(KEY, JSON.stringify(list));
return list;
}
export function readPreference() {
try {
return JSON.parse(localStorage.getItem("psu-preference") || "null");
} catch {
return null;
}
}
export function savePreference(id: string, group: string) {
try {
localStorage.setItem("psu-preference", JSON.stringify({ id, group }));
} catch {
/* Preference saving must not hide a loaded schedule. */
}
}
const CALENDAR = "psu-calendar-v1";
export function readCalendar(): AcademicCalendar | null {
try {
const value = JSON.parse(localStorage.getItem(CALENDAR) || "null");
return value && Array.isArray(value.weeks) && Array.isArray(value.bells)
? value
: null;
} catch {
return null;
}
}
export function saveCalendar(calendar: AcademicCalendar) {
try {
localStorage.setItem(CALENDAR, JSON.stringify(calendar));
} catch {
/* The calendar is small; a full storage only costs offline week labels. */
}
}
const DISMISSED = "psu-dismissed-updates";
/** Documents the student chose not to switch to; offers for them stay hidden. */
export function readDismissed(): string[] {
try {
const value = JSON.parse(localStorage.getItem(DISMISSED) || "[]");
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
} catch {
return [];
}
}
export function dismissUpdate(id: string) {
try {
localStorage.setItem(
DISMISSED,
JSON.stringify([id, ...readDismissed().filter((v) => v !== id)].slice(0, 50)),
);
} catch {
/* Without storage the offer simply returns next launch. */
}
}
const PERSONAL = "psu-personal-v1";
/** Per document and group: the student's subgroup and hidden lessons. Never leaves the device. */
export type PersonalMap = Record<string, PersonalPrefs>;
export function readPersonal(): PersonalMap {
try {
const value = JSON.parse(localStorage.getItem(PERSONAL) || "{}");
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
} catch {
return {};
}
}
export function savePersonal(map: PersonalMap) {
try {
localStorage.setItem(PERSONAL, JSON.stringify(map));
} catch {
/* Settings stay for this session only. */
}
}
/** The document a schedule came from, stable across republished versions. */
export const documentKey = (s: Schedule) =>
s.source.fileId || `upload:${s.source.name}`;
const CHANGES = "psu-last-changes-v1";
export interface ChangeRecord {
document: string;
group: string;
name: string;
at: string;
from?: string;
to?: string;
diff: ScheduleDiff<Lesson>;
}
export function readChanges(): ChangeRecord[] {
try {
const value = JSON.parse(localStorage.getItem(CHANGES) || "[]");
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
export function saveChanges(record: ChangeRecord) {
try {
localStorage.setItem(
CHANGES,
JSON.stringify(
[
record,
...readChanges().filter(
(r) => r.document !== record.document || r.group !== record.group,
),
].slice(0, 12),
),
);
} catch {
/* The banner still shows the changes for this session. */
}
}
const EXAMS = "psu-exams-v1";
export interface Exam {
date: string;
start: string;
end: string | null;
type: string;
subject: string;
teachers: string[];
rooms: string[];
document: { id: string; name: string; period?: string };
}
/** Last known exams per group, so the countdown works offline. */
export function readExams(key: string): Exam[] {
try {
const value = JSON.parse(localStorage.getItem(EXAMS) || "{}")[key];
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
export function saveExams(key: string, exams: Exam[]) {
try {
const all = JSON.parse(localStorage.getItem(EXAMS) || "{}");
all[key] = exams;
localStorage.setItem(EXAMS, JSON.stringify(all));
} catch {
/* Offline countdown is a convenience. */
}
}
const RECENT_GROUPS = "psu-recent-groups-v1";
export function readRecentGroups(): RecentGroup[] {
try {
const value = JSON.parse(localStorage.getItem(RECENT_GROUPS) || "[]");
return Array.isArray(value) ? value.filter((v) => v && v.group && v.document) : [];
} catch {
return [];
}
}
export function rememberGroup(item: RecentGroup) {
try {
const list = [
item,
...readRecentGroups().filter(
(r) => r.group !== item.group || r.document !== item.document,
),
].slice(0, 8);
localStorage.setItem(RECENT_GROUPS, JSON.stringify(list));
return list;
} catch {
return readRecentGroups();
}
}
export type Theme = "system" | "light" | "dark";
const THEME = "psu-theme";
// index.html reads the same key before the first paint, so the page never flashes the other theme.
export function readTheme(): Theme {
try {
const value = localStorage.getItem(THEME);
return value === "light" || value === "dark" ? value : "system";
} catch {
return "system";
}
}
export function saveTheme(theme: Theme) {
try {
if (theme === "system") localStorage.removeItem(THEME);
else localStorage.setItem(THEME, theme);
} catch {
/* The theme still applies for this session. */
}
}

2628
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

24
src/theme.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Theme } from "./storage";
const SURFACE = { light: "#fef7ff", dark: "#141218" };
/** Applies a theme to <html> and the browser chrome (status bar, title bar). */
export function applyTheme(theme: Theme) {
const root = document.documentElement;
if (theme === "system") delete root.dataset.theme;
else root.dataset.theme = theme;
// Two theme-color metas carry media queries for the system theme; an explicit
// choice gives both the same colour so whichever one matches shows it.
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
const own = meta.media.includes("dark") ? SURFACE.dark : SURFACE.light;
meta.content = theme === "system" ? own : SURFACE[theme];
}
}
/** Cross-fades the switch where supported; instant with reduced motion. */
export function switchTheme(theme: Theme) {
const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown };
if (doc.startViewTransition && !matchMedia("(prefers-reduced-motion: reduce)").matches)
doc.startViewTransition(() => applyTheme(theme));
else applyTheme(theme);
}

64
src/types.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { WeekRange } from "../shared/weeks";
export interface Lesson {
id: string;
day: number;
date: string | null;
start: string;
end: string | null;
original: string;
subject: string;
teacher: string;
room: string;
type: string;
condition: string;
groups: string[];
subgroups: { group: string; label: string }[];
variant: "all" | "upper" | "lower";
sourceColor: string | null;
raw: string;
pair?: string | number | null;
weekRanges?: WeekRange[] | null;
dates?: string[];
}
export interface Schedule {
id: string;
groups: string[];
lessons: Lesson[];
notes: string[];
warnings: string[];
source: {
name: string;
url?: string;
faculty?: string;
form?: string;
course?: string;
modified?: string;
importedAt: string;
fileId?: string;
uploaded?: boolean;
};
stats: { lessons: number; groups: number };
format: string;
period?: string;
schemaVersion?: number;
academicYear?: string | null;
}
export interface SourceFile {
id: string;
name: string;
year?: string;
faculty: string;
form: string;
course: string;
modified: string;
url: string;
}
export interface Catalog {
files: SourceFile[];
updatedAt: string | null;
stale: boolean;
error?: string;
year: string;
currentYear?: boolean;
expectedYear?: string;
}

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

168
test/directory.test.js Normal file
View File

@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx, details } from "../server/parser.js";
import { parseWeeksDocx } from "../server/calendar.js";
import { mergeWeekTables } from "../server/source.js";
import {
peopleAndRooms,
buildIndex,
occurrence,
search,
dayOf,
freeRooms,
examsOf,
isPhysicalRoom,
} from "../server/directory.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
bells: [
{ pair: 1, start: "08:30", end: "09:55", halves: [] },
{ pair: 2, start: "10:05", end: "11:30", halves: [] },
{ pair: 3, start: "11:45", end: "13:10", halves: [] },
],
source: {},
};
const doc = (id, schedule) => ({ id, file: { id, name: `${id}.docx` }, schedule });
const fef = doc("fef", parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx", faculty: "ФЭФ" }));
const rooms = (raw, room = "") => peopleAndRooms({ raw, room, teacher: "" }, "2026");
test("Аудитории: реальные формулировки из Word", () => {
assert.deepEqual(rooms("Охрана труда (л.з.) Лемачко С.Н. ауд.205;6,8н.- Горунова С.А.", "205").rooms, ["205"]);
const moved = rooms("История (л.) Сумко Е.В. ауд. 432 14.10. -ауд. 333", "432");
assert.deepEqual(moved.rooms, ["432"]);
assert.deepEqual(moved.overrides, [{ date: "2026-10-14", room: "333" }]);
assert.deepEqual(rooms("Материаловедение (л.) Фруцкий В.А. ауд. 106н 17.10. -210 мк").overrides, [
{ date: "2026-10-17", room: "210МК" },
]);
// A typo glues the room to the date: "22417.09." is room 224 and a move on 17 September.
const glued = rooms("Информационные технологии (л.) Моняк Т.М. ауд. 22417.09.- ауд.332", "22417");
assert.deepEqual([glued.rooms, glued.overrides[0].room], [["224"], "332"]);
assert.deepEqual(rooms("Единый день информирования 10.09;15.10;19.11;17.12", "12").rooms, []);
assert.deepEqual(rooms("Информационный час ауд. В0-05", "05").rooms, ["В0-05"]);
assert.deepEqual(rooms("Физкультура ауд. 526н325н502н").rooms, ["526Н", "325Н", "502Н"]);
assert.deepEqual(rooms("Предмет (л.) Иванов И.И. ауд.129, Петров П.П. ауд. 130").teachers, [
"Иванов И.И.",
"Петров П.П.",
]);
assert.equal(rooms("Линейная алгебра (л.)\nЗабелендик О.Н.\n200 (ДО)", "200 (ДО)").distance, true);
assert.equal(rooms("Сети (л.) Лукьянов А.О. 200ДО").rooms.length, 0);
assert.deepEqual(rooms("История (л) Емельянчик О.А. 21/28 (дискозал)").rooms, ["21/28"]);
assert.ok(isPhysicalRoom("207Г") && isPhysicalRoom("А1-15") && !isPhysicalRoom("3"));
});
test("Занятость по дате учитывает недели, цвет строки и семестр", () => {
const idx = buildIndex([fef]);
const find = (subject, day) =>
idx.entries.find((e) => e.lesson.subject === subject && e.lesson.day === day && e.lesson.groups.includes("23-БА"));
// Friday 16 October 2026 is week 7: "1-4,6-11н." runs, "12-15н." does not.
assert.equal(occurrence(find("PR-менеджмент", 5), "2026-10-16", calendar), "yes");
const later = idx.entries.find((e) => e.lesson.condition === "12-15н." && e.lesson.day === 5);
assert.equal(occurrence(later, "2026-10-16", calendar), "no");
assert.equal(occurrence(later, "2026-11-20", calendar), "yes");
// An autumn schedule does not run in March.
assert.equal(occurrence(find("PR-менеджмент", 5), "2027-03-05", calendar), "no");
// A spring document without a spring week table: nothing is guessed.
const spring = doc("spring", { ...fef.schedule, period: "Весенний семестр" });
const entry = { ...find("PR-менеджмент", 5), doc: spring };
assert.equal(occurrence(entry, "2027-03-05", calendar), "unknown");
});
test("Поиск, день преподавателя и свободные аудитории", () => {
const idx = buildIndex([fef]);
idx.docs = [fef];
idx.total = 1;
idx.failed = [];
idx.year = "2026–2027";
assert.equal(search(idx, "селез").teachers[0].name, "Селезнева А.А.");
assert.equal(search(idx, "4").teachers.length, 0);
const day = dayOf(idx, calendar, { teacher: "Селезнева А.А." }, "2026-10-16");
assert.deepEqual(day.lessons.map((l) => [l.start, l.subject, l.rooms.join()]), [
["08:30", "Инновационный менеджмент", "333"],
]);
const room = dayOf(idx, calendar, { room: "129" }, "2026-10-16");
assert.ok(room.lessons.some((l) => l.subject === "PR-менеджмент"));
const free = freeRooms(idx, calendar, "2026-10-16", 2);
const freeList = free.free.flatMap((g) => g.rooms);
assert.ok(!freeList.includes("129"), "PR-менеджмент занимает 129 на 2-й паре");
assert.ok(freeList.includes("333"), "333 занята только на 1-й паре");
assert.equal(free.coverage.documents, 1);
assert.equal(freeRooms(idx, calendar, "2026-10-16", 9), null);
});
test("Экзамены, зачёты и консультации распознаются и собираются по группе", () => {
assert.deepEqual(
["Экзамен. Финансы организаций Иванов И.И. ауд.129", "Финансы (экз.) Иванов И.И.", "Зачет: Физическая культура Петров П.П.", "Консультация Финансы Иванов И.И."]
.map((raw) => [details(raw).type, details(raw).subject]),
[
["Экзамен", "Финансы организаций"],
["Экзамен", "Финансы"],
["Зачёт", "Физическая культура"],
["Консультация", "Финансы"],
],
);
assert.equal(details("Психология профессионального консультирования (л.) Сидоров С.С.").type, "Лекция");
const exam = (date, start, type, subject) => ({
id: date + start,
day: 1,
date,
start,
end: null,
subject,
teacher: "Иванов И.И.",
room: "129",
type,
condition: "",
groups: ["23-БА"],
subgroups: [],
variant: "all",
raw: `${type} ${subject} Иванов И.И. ауд.129`,
});
const session = doc("session", {
lessons: [
exam("2027-01-14", "09:00", "Экзамен", "Финансы"),
exam("2027-01-13", "15:00", "Консультация", "Финансы"),
exam("2027-01-14", "09:00", "Экзамен", "Финансы"),
{ ...exam("2027-01-20", "09:00", "Экзамен", "Маркетинг"), groups: ["23-ЛГ"] },
],
source: { faculty: "ФЭФ" },
period: "Экзаменационная сессия",
});
const idx = buildIndex([fef, session]);
const list = examsOf(idx, "23-БА", "ФЭФ");
assert.deepEqual(list.map((e) => [e.date, e.type, e.subject, e.rooms.join()]), [
["2027-01-13", "Консультация", "Финансы", "129"],
["2027-01-14", "Экзамен", "Финансы", "129"],
]);
assert.equal(examsOf(idx, "23-БА", "ФИТ").length, 0);
});
test("Поиск группы: любое написание, без учёта фильтров, с фасетами каталога", async () => {
const { findGroups, groupKey } = await import("../server/directory.js");
const fit = parseDocx(fixture("fit-daily"), { name: "24-ИТ.docx" });
const docs = [
{ id: "fef", file: { id: "fef", name: "23-ЛГ_23-БА.docx", faculty: "ФЭФ", course: "4 курс", form: "Дневная форма" }, schedule: fef.schedule },
{ id: "fit", file: { id: "fit", name: "24-ИТ.docx", faculty: "ФИТ", course: "3 курс", form: "Дневная форма" }, schedule: fit },
];
const idx = { docs, year: "2026–2027" };
assert.equal(groupKey("23 ба"), groupKey("23-БА"));
for (const q of ["23-БА", "23 ба", "23ба"])
assert.deepEqual(findGroups(idx, { q }).results.map((r) => r.group), ["23-БА"]);
// The query ignores the faculty filter: a student must find their group anywhere.
assert.deepEqual(
findGroups(idx, { q: "24-ит", faculty: "ФЭФ" }).results.map((r) => r.group),
["24-ИТ-1", "24-ИТ-2"],
);
const browse = findGroups(idx, { faculty: "ФИТ" });
assert.deepEqual(browse.results.map((r) => [r.group, r.fileId]), [
["24-ИТ-1", "fit"],
["24-ИТ-2", "fit"],
]);
assert.deepEqual(browse.facets.faculties.map((f) => f.name), ["ФИТ", "ФЭФ"]);
assert.deepEqual(browse.facets.courses.map((c) => c.name), ["3 курс"]);
assert.equal(findGroups(idx, {}).results.length, 0);
assert.equal(findGroups(idx, {}).total, 4);
});

BIN
test/fixtures/bells.docx vendored Normal file

Binary file not shown.

6
test/fixtures/bells.docx.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Расписание звонков.docx",
"url": "https://nextcloud.psu.by/public.php/dav/files/no5zKbMrns6j5jQ/%D0%A0%D0%B0%D1%81%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5%20%D0%B7%D0%B2%D0%BE%D0%BD%D0%BA%D0%BE%D0%B2.docx",
"modified": "Wed, 12 Feb 2025 07:55:59 GMT",
"size": 16744
}

BIN
test/fixtures/fef-23-ba.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fef-23-ba.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b8%d0%bd%d0%b0%d0%bd%d1%81%d0%be%d0%b2%d0%be-%d1%8d%d0%ba%d0%be%d0%bd%d0%be%d0%bc%d0%b8%d1%87%d0%b5%d1%81%d0%ba%d0%b8%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/4%20%d0%ba%d1%83%d1%80%d1%81/23-%d0%9b%d0%93_23-%d0%91%d0%90.docx",
"name": "23-ЛГ_23-БА.docx",
"folder": false,
"modified": "Mon, 12 Oct 2026 11:45:13 GMT",
"size": "40019"
}

BIN
test/fixtures/fit-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fit-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%b8%d0%bd%d1%84%d0%be%d1%80%d0%bc%d0%b0%d1%86%d0%b8%d0%be%d0%bd%d0%bd%d1%8b%d1%85%20%d1%82%d0%b5%d1%85%d0%bd%d0%be%d0%bb%d0%be%d0%b3%d0%b8%d0%b9/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/3%20%d0%ba%d1%83%d1%80%d1%81/24-%d0%98%d0%a2.docx",
"name": "24-ИТ.docx",
"folder": false,
"modified": "Mon, 12 Oct 2026 06:29:18 GMT",
"size": "44719"
}

BIN
test/fixtures/fit.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fit.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%b8%d0%bd%d1%84%d0%be%d1%80%d0%bc%d0%b0%d1%86%d0%b8%d0%be%d0%bd%d0%bd%d1%8b%d1%85%20%d1%82%d0%b5%d1%85%d0%bd%d0%be%d0%bb%d0%be%d0%b3%d0%b8%d0%b9/%d0%97%d0%b0%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a3%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%d0%be%d1%81%d0%b5%d0%bd%d1%8c%202026-2027%20%d1%83%d1%87.%d0%b3/26-%d0%98%d0%a2%d0%b7%d1%81(ver.8.0).docx",
"name": "26-ИТзс(ver.8.0).docx",
"folder": false,
"modified": "Fri, 11 Sep 2026 11:38:07 GMT",
"size": "38898"
}

BIN
test/fixtures/fkne-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fkne-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%ba%d0%be%d0%bc%d0%bf%d1%8c%d1%8e%d1%82%d0%b5%d1%80%d0%bd%d1%8b%d1%85%20%d0%bd%d0%b0%d1%83%d0%ba%20%d0%b8%20%d1%8d%d0%bb%d0%b5%d0%ba%d1%82%d1%80%d0%be%d0%bd%d0%b8%d0%ba%d0%b8/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/3%20%d0%ba%d1%83%d1%80%d1%81/24-%d0%9a%d0%91_24-%d0%9a%d0%a4.docx",
"name": "24-КБ_24-КФ.docx",
"folder": false,
"modified": "Fri, 09 Oct 2026 12:15:18 GMT",
"size": "47417"
}

BIN
test/fixtures/fkne.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fkne.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%ba%d0%be%d0%bc%d0%bf%d1%8c%d1%8e%d1%82%d0%b5%d1%80%d0%bd%d1%8b%d1%85%20%d0%bd%d0%b0%d1%83%d0%ba%20%d0%b8%20%d1%8d%d0%bb%d0%b5%d0%ba%d1%82%d1%80%d0%be%d0%bd%d0%b8%d0%ba%d0%b8/%d0%97%d0%b0%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a3%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%d0%be%d1%81%d0%b5%d0%bd%d1%8c%202026-2027%20%d1%83%d1%87.%d0%b3/26-%d0%ad%d0%ad%d0%a2%d0%b7%d1%81%20%20%20(%d1%83%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%202026-%202027)%20(3).docx",
"name": "26-ЭЭТзс (установочная сессия 2026- 2027) (3).docx",
"folder": false,
"modified": "Mon, 14 Sep 2026 13:05:15 GMT",
"size": "32155"
}

BIN
test/fixtures/gf-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/gf-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%93%d1%83%d0%bc%d0%b0%d0%bd%d0%b8%d1%82%d0%b0%d1%80%d0%bd%d1%8b%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/2%20%d0%ba%d1%83%d1%80%d1%81/25-%d0%a0%d0%93%d0%a4-1_25-%d0%a0%d0%93%d0%a4-2.docx",
"name": "25-РГФ-1_25-РГФ-2.docx",
"folder": false,
"modified": "Wed, 16 Sep 2026 09:24:04 GMT",
"size": "39149"
}

BIN
test/fixtures/isf-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/isf-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%98%d0%bd%d0%b6%d0%b5%d0%bd%d0%b5%d1%80%d0%bd%d0%be-%d1%81%d1%82%d1%80%d0%be%d0%b8%d1%82%d0%b5%d0%bb%d1%8c%d0%bd%d1%8b%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/1%20%d0%ba%d1%83%d1%80%d1%81/26-%d0%9f%d0%93%d0%a1-1_26-%d0%9f%d0%93%d0%a1-2%20(ver.13.0).docx",
"name": "26-ПГС-1_26-ПГС-2 (ver.13.0).docx",
"folder": false,
"modified": "Mon, 05 Oct 2026 09:11:46 GMT",
"size": "43263"
}

BIN
test/fixtures/weeks.docx vendored Normal file

Binary file not shown.

6
test/fixtures/weeks.docx.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Расписание белых и зеленых недель.docx",
"url": "https://nextcloud.psu.by/public.php/dav/files/no5zKbMrns6j5jQ/%D0%A0%D0%B0%D1%81%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5%20%D0%B1%D0%B5%D0%BB%D1%8B%D1%85%20%D0%B8%20%D0%B7%D0%B5%D0%BB%D0%B5%D0%BD%D1%8B%D1%85%20%D0%BD%D0%B5%D0%B4%D0%B5%D0%BB%D1%8C.docx",
"modified": "Wed, 26 Aug 2026 10:38:45 GMT",
"size": 16051
}

82
test/lessons.test.js Normal file
View File

@@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx } from "../server/parser.js";
import {
lessonKey,
diffSchedules,
changeCount,
personalFilter,
subgroupsOf,
} from "../shared/lessons.js";
const fef = () =>
parseDocx(readFileSync(new URL("./fixtures/fef-23-ba.docx", import.meta.url)), {
name: "23-ЛГ_23-БА.docx",
});
const find = (s, group, test) =>
s.lessons.find((l) => l.groups.includes(group) && test(l));
test("Ключ занятия не зависит от строки таблицы и переживает перепубликацию", () => {
const a = fef(),
b = fef();
// Same content parsed twice: same keys, no changes.
assert.deepEqual(
a.lessons.map((l) => lessonKey(l, "23-БА")),
b.lessons.map((l) => lessonKey(l, "23-БА")),
);
assert.equal(changeCount(diffSchedules(a, b, "23-БА")), 0);
const moved = { ...a.lessons[0], id: "other-row" };
assert.equal(lessonKey(moved, "23-БА"), lessonKey(a.lessons[0], "23-БА"));
});
test("Сравнение версий: аудитория, перенос, новое и удалённое занятие", () => {
const before = fef(),
after = fef();
const room = find(after, "23-БА", (l) => l.day === 5 && l.start === "08:30");
room.room = "333А";
const pr = find(after, "23-БА", (l) => l.day === 5 && l.subject === "PR-менеджмент");
pr.start = "11:45";
const risk = find(after, "23-БА", (l) => l.day === 3 && l.subject === "Риск-менеджмент");
after.lessons.splice(after.lessons.indexOf(risk), 1);
after.lessons.push({ ...pr, id: "new", day: 6, start: "08:30", subject: "Новый предмет" });
// A change for the other group must not reach 23-БА.
find(after, "23-ЛГ", (l) => l.room).room = "999";
const diff = diffSchedules(before, after, "23-БА");
assert.deepEqual(diff.added.map((l) => l.subject), ["Новый предмет"]);
assert.deepEqual(diff.removed.map((l) => l.subject), ["Риск-менеджмент"]);
assert.deepEqual(
diff.changed.map((c) => [c.after.subject, c.fields.map((f) => `${f.field}:${f.from}→${f.to}`)]),
[
["Инновационный менеджмент", ["room:333→333А"]],
["PR-менеджмент", ["start:10:05→11:45"]],
],
);
assert.equal(changeCount(diff), 4);
});
test("Изменённая пометка недель видна как изменение, а не как новое занятие", () => {
const before = fef(),
after = fef();
find(after, "23-БА", (l) => l.condition === "6-13н").condition = "6-14н";
const diff = diffSchedules(before, after, "23-БА");
assert.equal(diff.added.length + diff.removed.length, 0);
assert.deepEqual(diff.changed[0].fields.map((f) => [f.label, f.from, f.to]), [
["Недели", "6-13н", "6-14н"],
]);
});
test("Подгруппа и скрытые занятия фильтруются только для своей группы", () => {
const s = fef();
assert.deepEqual(subgroupsOf(s, "23-ЛГ"), ["Подгруппа 1", "Подгруппа 2"]);
assert.deepEqual(subgroupsOf(s, "23-БА"), []);
const first = find(s, "23-ЛГ", (l) => l.subgroups.some((g) => g.label === "Подгруппа 1"));
const second = find(s, "23-ЛГ", (l) => l.subgroups.some((g) => g.label === "Подгруппа 2"));
const whole = find(s, "23-ЛГ", (l) => !l.subgroups.length);
const prefs = { subgroup: "Подгруппа 2", hidden: [lessonKey(whole, "23-ЛГ")] };
assert.equal(personalFilter(first, "23-ЛГ", prefs), "other-subgroup");
assert.equal(personalFilter(second, "23-ЛГ", prefs), "shown");
assert.equal(personalFilter(whole, "23-ЛГ", prefs), "hidden");
assert.equal(personalFilter(first, "23-ЛГ", null), "shown");
});

142
test/parser.test.js Normal file
View File

@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx, parseWord } from "../server/parser.js";
import { decodeListing, fileMetadata } from "../server/source.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const fef = () => parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" });
test("ФЭФ: 23-БА не получает дисциплины 23-ЛГ", () => {
const s = fef(),
ba = s.lessons.filter((l) => l.groups.includes("23-БА"));
assert.deepEqual(s.groups, ["23-ЛГ", "23-БА"]);
assert.equal(ba.length, 33);
assert.ok(ba.some((l) => l.subject === "Бизнес-анализ"));
assert.ok(
!ba.some((l) =>
/логистик|Экспедирование|Управление запасами/i.test(l.subject),
),
);
assert.ok(
s.lessons
.filter((l) => l.groups.includes("23-ЛГ"))
.some((l) => /Финансовая логистика/.test(l.subject)),
);
});
test("ФЭФ: общая ячейка принадлежит обеим группам", () => {
const shared = fef().lessons.filter((l) =>
l.subject.startsWith("Единый день информирования"),
);
assert.equal(shared.length, 1);
assert.deepEqual(shared[0].groups, ["23-ЛГ", "23-БА"]);
assert.match(shared[0].raw, /15 октября/);
assert.equal(shared[0].start, "13:40");
});
test("ФЭФ: варианты недель, цвет, преподаватель и аудитория сохранены", () => {
const s = fef();
const lessons = s.lessons.filter(
(l) =>
l.day === 4 &&
l.groups.includes("23-БА") &&
l.subject === "Бизнес-анализ",
);
assert.deepEqual(
lessons.map((l) => [l.condition, l.variant, l.room, l.teacher]),
[
["7,9,11н.", "upper", "430", "Войтенок Ж.Е."],
["4-12н", "lower", "430", "Войтенок Ж.Е."],
],
);
assert.equal(lessons[1].sourceColor, "CCFFCC");
assert.equal(lessons[0].start, "10:05");
assert.equal(lessons[0].end, "11:30");
const plan = s.lessons.find(
(l) =>
l.subject === "Бизнес-планирование" && l.start === "11:45" && l.day === 4,
);
assert.equal(plan.condition, "1-8,10-17");
assert.ok(s.warnings.length);
});
test("ФЭФ: кураторский час в 16.50 не становится невозможной датой", () => {
const l = fef().lessons.find(
(l) =>
l.groups.includes("23-БА") &&
l.day === 1 &&
l.subject.includes("кураторский"),
);
assert.equal(l.start, "16:50");
assert.equal(l.end, null);
assert.equal(l.date, null);
assert.equal(l.condition, "");
for (const lesson of fef().lessons)
if (lesson.date)
assert.ok(Number.isFinite(new Date(lesson.date).getTime()));
});
test("ФЭФ: объединённое занятие не дублируется на зелёной строке", () => {
const s = fef();
const l = s.lessons.filter(
(l) =>
l.day === 2 &&
l.groups.includes("23-БА") &&
l.subject === "Управление проектами" &&
l.start === "15:20",
);
assert.equal(l.length, 1);
assert.equal(l[0].variant, "all");
});
test("Дневные таблицы четырёх факультетов распознаются со своими группами", () => {
for (const [name, groups] of [
["fit-daily", ["24-ИТ-1", "24-ИТ-2"]],
["fkne-daily", ["24-КБ", "24-КФ"]],
["isf-daily", ["26-ПГС-1", "26-ПГС-2"]],
["gf-daily", ["25-РГФ-1", "25-РГФ-2"]],
]) {
const s = parseDocx(fixture(name), { name });
assert.deepEqual(s.groups, groups);
assert.ok(s.lessons.length > 20);
assert.ok(
s.lessons.every(
(l) => l.day >= 1 && l.day <= 7 && /^\d{2}:\d{2}$/.test(l.start),
),
);
assert.ok(
s.lessons.every((l) => l.groups.every((g) => groups.includes(g))),
);
}
});
test("Сессии: отдельные колонки времени, аудитории и преподавателя", () => {
const s = parseDocx(fixture("fit"), { name: "26-ИТзс.docx" }),
l = s.lessons[0];
assert.deepEqual(s.groups, ["26-ИТзс"]);
assert.equal(l.date, "2026-09-17");
assert.equal(l.start, "11:00");
assert.equal(l.end, "11:30");
assert.equal(l.teacher, "Петрович О.Н.");
assert.equal(l.room, "220Д");
assert.deepEqual(l.subgroups, []);
assert.match(l.raw, /Петрович/);
assert.equal(s.period, "Установочная сессия");
const f = parseDocx(fixture("fkne"), {});
assert.deepEqual(f.groups, ["26-ЭЭТзс"]);
assert.ok(f.lessons.every((l) => l.date));
});
test("Импорт отклоняет повреждённые и посторонние файлы", async () => {
assert.throws(() => parseDocx(Buffer.from("broken")), /повреждён/);
await assert.rejects(
() => parseWord(Buffer.from("not a word file")),
/настоящий файл Word/,
);
});
test("DAV: метаданные вложенных документов и только доверенный каталог", () => {
const xml = `<d:multistatus xmlns:d="DAV:"><d:response><d:href>/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>https://evil.example/file.docx</d:href></d:response></d:multistatus>`;
const entries = decodeListing(xml);
assert.equal(entries.length, 1);
assert.equal(entries[0].folder, true);
const metadata = JSON.parse(
readFileSync(new URL("./fixtures/fef-23-ba.docx.json", import.meta.url)),
);
const file = fileMetadata(metadata);
assert.equal(file.faculty, "Финансово-экономический факультет");
assert.equal(file.course, "4 курс");
assert.equal(file.name, "23-ЛГ_23-БА.docx");
});

183
test/sources.test.js Normal file
View File

@@ -0,0 +1,183 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
pickYearFolder,
pickReferences,
isWeeksTable,
isBellSchedule,
mergeWeekTables,
fileMetadata,
decodeListing,
} from "../server/source.js";
import { parseWeeksDocx } from "../server/calendar.js";
import { parseDocx } from "../server/parser.js";
import {
addDays,
semesterFor,
semesterView,
lessonDates,
academicStartYear,
} from "../shared/weeks.js";
import { scheduleUpdate, namesGroup } from "../shared/updates.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const autumn = parseWeeksDocx(fixture("weeks"));
// A spring table in the same format: numbering restarts at 1 on Monday 8 February.
const spring = Array.from({ length: 17 }, (_, i) => ({
n: i + 1,
start: addDays("2027-02-08", i * 7),
end: addDays("2027-02-08", i * 7 + 6),
color: i % 2 ? "green" : "white",
}));
const bothSemesters = { ...mergeWeekTables([spring, autumn]), bells: [], source: {} };
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
test("Учебный год: папка меняется 1 сентября, без новой папки остаётся прошлая", () => {
const folders = ["2025-2026", "2026-2027", "Майноры", "2026-2028"];
assert.equal(academicStartYear("2026-08-31"), 2025);
assert.equal(pickYearFolder(folders, "2026-08-31").name, "2025-2026");
assert.deepEqual(pickYearFolder(folders, "2026-09-01"), {
name: "2026-2027",
label: "2026–2027",
current: true,
expected: "2026-2027",
});
const late = pickYearFolder(folders, "2027-09-01");
assert.equal(late.name, "2026-2027");
assert.equal(late.current, false);
assert.equal(late.expected, "2027-2028");
// Only next year's folder published early: still better than nothing.
assert.equal(pickYearFolder(["2027-2028"], "2027-08-20").name, "2027-2028");
assert.equal(pickYearFolder(["Майноры"], "2026-10-16"), null);
});
test("Справочные файлы находятся по смыслу имени, папка года важнее корня", () => {
assert.ok(isWeeksTable("Расписание белых и зеленых недель.docx"));
assert.ok(isWeeksTable("Расписание белых и зелёных недель 2027.docx"));
assert.ok(!isWeeksTable("Расписание белых и зеленых недель.doc"));
assert.ok(!isWeeksTable("23-ЛГ_23-БА.docx"));
assert.ok(isBellSchedule("Расписание звонков (новое).docx"));
const entry = (path, modified, folder = false) => ({ path, modified, folder, size: 1 });
const refs = pickReferences(
[
entry(SHARE + encodeURIComponent("Расписание белых и зеленых недель.docx"), "Wed, 26 Aug 2026 10:00:00 GMT"),
entry(SHARE + encodeURIComponent("Расписание звонков.docx"), "Wed, 12 Feb 2025 07:55:59 GMT"),
entry(SHARE + "2026-2027/", null, true),
],
[
entry(SHARE + "2026-2027/" + encodeURIComponent("Белые и зелёные недели, весна.docx"), "Mon, 25 Jan 2027 09:00:00 GMT"),
],
);
assert.deepEqual(
refs.weeks.map((f) => [f.name, f.scope]),
[
["Белые и зелёные недели, весна.docx", "year"],
["Расписание белых и зеленых недель.docx", "root"],
],
);
assert.equal(refs.bells.name, "Расписание звонков.docx");
});
test("DAV: год берётся из пути документа, чужие ссылки отбрасываются", () => {
const xml = `<d:multistatus xmlns:d="DAV:"><d:response><d:href>${SHARE}2027-2028/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>https://evil.example/file.docx</d:href></d:response></d:multistatus>`;
assert.equal(decodeListing(xml).length, 1);
const metadata = JSON.parse(
readFileSync(new URL("./fixtures/fef-23-ba.docx.json", import.meta.url)),
);
const file = fileMetadata(metadata);
assert.equal(file.year, "2026–2027");
assert.equal(file.faculty, "Финансово-экономический факультет");
});
test("Таблица с двумя семестрами делится по сбросу нумерации", () => {
const { semesters, weeks } = bothSemesters;
assert.deepEqual(
semesters.map((s) => [s.id, s.label, s.start, s.end]),
[
["2026-autumn", "Осенний семестр 2026/27", "2026-09-01", "2027-01-03"],
["2026-spring", "Весенний семестр 2026/27", "2027-02-08", "2027-06-06"],
],
);
assert.equal(weeks.filter((w) => w.semester === "2026-spring").length, 17);
// The same table read twice (root and year folder) does not duplicate weeks.
assert.equal(mergeWeekTables([autumn, autumn]).weeks.length, 18);
});
test("Расписание получает недели своего семестра, а не первого попавшегося", () => {
const fef = parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" });
assert.equal(semesterFor(bothSemesters, fef, "2027-03-01").id, "2026-autumn");
const springSchedule = { ...fef, period: "Весенний семестр" };
const view = semesterView(bothSemesters, springSchedule, "2026-10-16");
assert.equal(view.semester.id, "2026-spring");
const lesson = { day: 1, date: null, condition: "1-15н", variant: "upper" };
assert.equal(lessonDates(lesson, view)[0], "2027-02-08");
assert.equal(lessonDates(lesson, semesterView(bothSemesters, fef, "2026-10-16"))[0], "2026-09-14");
// Spring table not published yet: no guessed dates.
const onlyAutumn = { ...mergeWeekTables([autumn]), bells: [], source: {} };
assert.equal(semesterFor(onlyAutumn, springSchedule, "2027-01-20"), null);
// Next year's autumn schedule against this year's table: no match either.
assert.equal(semesterFor(onlyAutumn, { ...fef, academicYear: "2027" }, "2027-09-10"), null);
// Unknown period: the semester that contains today, none between semesters.
const plain = { lessons: [{ date: null }], period: "Расписание из Word" };
assert.equal(semesterFor(bothSemesters, plain, "2026-11-02").id, "2026-autumn");
assert.equal(semesterFor(bothSemesters, plain, "2027-01-20"), null);
// A session schedule follows its own dates.
const session = { lessons: [{ date: "2027-02-10" }, { date: "2027-02-11" }], period: "Установочная сессия" };
assert.equal(semesterFor(bothSemesters, session, "2026-12-01").id, "2026-spring");
// A calendar cached before semesters existed still works.
assert.equal(
semesterView({ weeks: autumn, bells: [], source: {} }, fef, "2026-10-16").weeks.length,
18,
);
});
test("Обновления: тот же файл перечитывается, новый документ группы предлагается", () => {
const saved = {
schemaVersion: 5,
source: {
fileId: "a",
modified: "Mon, 12 Oct 2026 11:45:13 GMT",
importedAt: "2026-10-16T10:00:00Z",
faculty: "ФЭФ",
},
};
const file = (id, name, modified, faculty = "ФЭФ") => ({ id, name, modified, faculty });
const base = file("a", "23-ЛГ_23-БА.docx", saved.source.modified);
const options = { schemaVersion: 5 };
assert.equal(scheduleUpdate(saved, { files: [base] }, "23-БА", options), null);
const republished = { ...base, modified: "Tue, 20 Oct 2026 08:00:00 GMT" };
assert.equal(
scheduleUpdate(saved, { files: [republished] }, "23-БА", options).changed,
republished,
);
assert.equal(
scheduleUpdate({ ...saved, schemaVersion: 4 }, { files: [base] }, "23-БА", options).changed,
base,
);
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"),
file("d", "23-БА.docx", "Mon, 25 Jan 2027 09:00:00 GMT", "ФИТ"),
file("e", "23-БА старое.docx", "Mon, 01 Sep 2026 09:00:00 GMT"),
];
const found = scheduleUpdate(saved, { files: [base, spring, ...others] }, "23-БА", options);
assert.equal(found.newer, spring);
assert.equal(found.changed, null);
assert.equal(
scheduleUpdate(saved, { files: [base, spring] }, "23-БА", { ...options, dismissed: ["b"] }),
null,
);
// New academic year: the old path is gone.
assert.deepEqual(scheduleUpdate(saved, { files: [] }, "23-БА", options), {
changed: null,
newer: null,
missing: true,
});
// An offline copy of the catalog proves nothing.
assert.equal(scheduleUpdate(saved, { files: [], stale: true }, "23-БА", options), null);
assert.ok(namesGroup("23-ЛГ_23-БА.docx", "23-БА"));
assert.ok(!namesGroup("123-БА.docx", "23-БА"));
assert.ok(!namesGroup("23-БАз.docx", "23-БА"));
});

123
test/weeks.test.js Normal file
View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx } from "../server/parser.js";
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
import {
parseWeekRanges,
parseTextDates,
lessonWeeks,
lessonDates,
dateInWeek,
weekIndexOf,
todayIso,
} from "../shared/weeks.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
weeks: parseWeeksDocx(fixture("weeks")),
bells: parseBellsDocx(fixture("bells")),
};
const fef = () =>
parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" }).lessons.filter(
(l) => l.groups.includes("23-БА"),
);
test("Таблица недель: 18 недель, нечётные белые, неделя 1 со вторника", () => {
const { weeks } = calendar;
assert.equal(weeks.length, 18);
assert.deepEqual(weeks[0], {
n: 1,
start: "2026-09-01",
end: "2026-09-06",
color: "white",
});
assert.deepEqual(weeks.at(-1), {
n: 18,
start: "2026-12-28",
end: "2027-01-03",
color: "green",
});
assert.ok(weeks.every((w) => (w.n % 2 ? "white" : "green") === w.color));
assert.equal(dateInWeek(weeks[0], 1), null);
assert.equal(dateInWeek(weeks[0], 2), "2026-09-01");
assert.equal(weekIndexOf(calendar, "2026-10-16"), 6);
assert.equal(weekIndexOf(calendar, "2027-02-01"), -1);
assert.equal(todayIso(new Date("2026-10-15T22:30:00Z")), "2026-10-16");
});
test("Звонки: восемь пар с половинами", () => {
assert.equal(calendar.bells.length, 8);
assert.deepEqual(calendar.bells[1], {
pair: 2,
start: "10:05",
end: "11:30",
halves: [
["10:05", "10:45"],
["10:50", "11:30"],
],
});
});
test("Пометки недель: диапазоны, «с N», опечатки остаются для сверки", () => {
assert.deepEqual(parseWeekRanges("1-4,6-11н."), [
[1, 4],
[6, 11],
]);
assert.deepEqual(parseWeekRanges("со 2 нед."), [[2, null]]);
assert.deepEqual(parseWeekRanges("с 4-16н"), [[4, 16]]);
assert.deepEqual(parseWeekRanges("1-8,10-17"), [
[1, 8],
[10, 17],
]);
assert.equal(parseWeekRanges("2,6-12-16н"), null);
assert.equal(parseWeekRanges("26.10"), null);
assert.equal(parseWeekRanges(""), null);
});
test("Цвет строки ограничивает диапазон чётностью недели", () => {
const white = { day: 2, date: null, condition: "13-17н", variant: "upper" };
const green = { day: 4, date: null, condition: "4-12н", variant: "lower" };
const every = { day: 5, date: null, condition: "6-13н", variant: "all" };
const plain = { day: 1, date: null, condition: "", variant: "lower" };
assert.deepEqual(lessonWeeks(white, 18), [13, 15, 17]);
assert.deepEqual(lessonWeeks(green, 18), [4, 6, 8, 10, 12]);
assert.deepEqual(lessonWeeks(every, 18), [6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepEqual(lessonWeeks(plain, 18), [2, 4, 6, 8, 10, 12, 14, 16, 18]);
assert.equal(
lessonWeeks({ ...white, condition: "2,6-12-16н" }, 18),
null,
);
assert.deepEqual(lessonDates(every, calendar).slice(0, 2), [
"2026-10-09",
"2026-10-16",
]);
});
test("Парсер: 23-БА получает номера недель и даты дня информирования", () => {
const lessons = fef();
const friday = lessons.filter((l) => l.day === 5 && l.start === "10:05");
assert.deepEqual(
friday.map((l) => l.weekRanges),
[[[1, 4], [6, 11]], [[12, 15]], [[16, 16]]],
);
const week7 = friday.filter((l) => lessonWeeks(l, 18).includes(7));
assert.deepEqual(
week7.map((l) => l.subject),
["PR-менеджмент"],
);
const info = lessons.find((l) =>
l.subject.startsWith("Единый день информирования"),
);
assert.deepEqual(info.dates, [
"2026-09-10",
"2026-10-15",
"2026-11-19",
"2026-12-17",
]);
assert.deepEqual(parseTextDates("17 декабря и 14 января", 2026), [
"2026-12-17",
"2027-01-14",
]);
// Every coloured-row mark in the real document agrees with the week table.
for (const l of lessons.filter((l) => l.variant !== "all" && l.weekRanges))
for (const [a, b] of l.weekRanges)
for (const n of [a, b ?? a])
assert.equal(n % 2, l.variant === "upper" ? 1 : 0, l.raw);
});

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src", "shared"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
export default defineConfig({
plugins: [react(), svgr()],
server: {
proxy: { "/api": `http://127.0.0.1:${process.env.API_PORT || 3001}` },
},
build: { sourcemap: true },
});