376 lines
93 KiB
Markdown
376 lines
93 KiB
Markdown
# Расписание ПГУ
|
||
|
||
Первый рабочий PWA-клиент для расписаний Полоцкого государственного университета. При первом запуске предлагает найти свою группу; исходный сценарий проверки — **ФЭФ, 23-БА** из общего документа `23-ЛГ_23-БА.docx` за 2026–2027 учебный год.
|
||
|
||
## Запуск
|
||
|
||
Нужны Node.js 22.12+ (проверено на 24.14.1) и npm.
|
||
|
||
```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 мин») по расписанию звонков.
|
||
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами этого дня — в виде «День» и «Неделя» — стоит карточка «Занятия в Полоцке · пометка в Word на этот день» (с подгруппой, если заметка только её). В карточке пары город стоит рядом с аудиторией («Ауд. 249Ж · Полоцк»), в деталях — строка «Город». Сохранённые раньше расписания перечитываются по версии парсера и получают города сами.
|
||
- «Что изменилось»: когда университет перепубликует документ, приложение сравнивает новую версию с сохранённой и показывает для вашей группы изменённые поля (аудитория, время, преподаватель, недели), добавленные и удалённые занятия. Последний список изменений открывается из меню.
|
||
- Моя подгруппа и скрытые занятия: в «Группах» можно выбрать подгруппу — занятия другой подгруппы пропадут; любое занятие можно скрыть из его карточки и вернуть кнопкой «Скрыто · показать». Настройки хранятся только в этом браузере.
|
||
- **Поиск по университету** (вкладка «Поиск»): преподаватель или аудитория — занятия на выбранный день по всем расписаниям каталога; свободные аудитории на дату и пару. Сервер разбирает все документы каталога в фоне (первый раз около 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) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а дашборд `/status` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
|
||
|
||
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
|
||
|
||
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
|
||
|
||
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки.
|
||
|
||
**Вежливость к университету.** Каждый запрос к Nextcloud представляется: `User-Agent: PSU-Schedule/0.1.0 (+<PSU_PUBLIC_URL>; <PSU_CONTACT>)`. Например, `PSU_CONTACT=mailto:you@example.com` — тогда администраторы университета видят, чей это сервис и кому написать, а не безымянного робота. Когда университет перестаёт отвечать (нет соединения, `502`–`504`, `429`, долго не отвечает листинг папки), сервер делает паузу: до её конца к университету не уходит ни один запрос — ни от наблюдателя, ни от «Обновить» в клиентах, ни при открытии документа. Студенты получают сохранённую копию. Пауза удваивается после каждой неудачной попытки: 5, 10, 20, 40 минут, дальше раз в час (`PSU_SOURCE_BACKOFF_MS`, `PSU_SOURCE_BACKOFF_MAX_MS`), и учитывает `Retry-After`. Первый же ответ её снимает. Скачанные документы сервер хранит у себя (`.data/documents/`), поэтому новая версия парсера перечитывает их локально, не обращаясь к университету. Ошибка одного документа (`404`, `500`) и медленная загрузка файла паузу не включают. На дашборде у плитки «Университет» видно время следующей попытки, в `/status/data.json` — `catalog.backoff`.
|
||
|
||
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
|
||
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
|
||
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;
|
||
- **лента событий** — новые, перезалитые и нечитаемые документы, смена учебного года, сбои университета; последние 100 событий хранятся в `.data/events.json`;
|
||
- **подозрительные ячейки** — по документам, с исходным текстом ячейки и ссылкой на оригинал, с фильтрами по виду и поиском по группе, документу или тексту.
|
||
|
||
Страница — обычные HTML, CSS и JS из `server/status/` без сборки, под той же строгой CSP. Она обновляется раз в 30 секунд, пока вкладка открыта. Данные берёт из `/status/data.json` за тем же паролем. Публичный `/api/health` после появления дашборда отвечает только «жив, готов, версии, учебный год»: остальное называет документы, клиентов и настройки владельца.
|
||
|
||
**Журнал запросов и метрики.** На каждый запрос к `/api` в журнал (`docker logs`) пишется одна строка JSON: время, метод, путь с параметрами, статус, время ответа в мс, код ошибки, размер ответа и адрес клиента без последней части (`203.0.113.0`, у IPv6 — /48). Точные адреса не сохраняются. Проверки здоровья (`/api/health`, `/api/ready`) не пишутся и не считаются. Журнал выключается переменной `PSU_REQUEST_LOG=off`.
|
||
|
||
Поле `traffic` на дашборде (`/status/data.json`) — сводка за последний час (`lastHour`) и с запуска (`total`):
|
||
- сколько было запросов и какая доля ушла пустым `304`;
|
||
- ошибки клиента и сервера;
|
||
- срабатывания лимита (`limited`) и таймаута (`timeouts`);
|
||
- ответы `502`/`504` из‑за университета (`sourceErrors`) и упавшие запросы к нему (`sourceFailures`);
|
||
- медленные ответы, дольше секунды;
|
||
- медиана и 95‑й процентиль времени ответа — как граница корзины: 25, 50, 100, 250… мс;
|
||
- десять самых частых адресов API со средним временем.
|
||
|
||
За последний час есть ещё число разных клиентов и три самых активных. Так видно, если кто‑то перебирает API.
|
||
|
||
**Оповещения в Telegram.** Если заданы `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`, сервер пишет владельцу после проверок каталога:
|
||
- появился новый документ — с его группами и подозрительными ячейками;
|
||
- документ не читается;
|
||
- документ перезалит, и в нём появились подозрительные ячейки, которых раньше не было;
|
||
- каталог перешёл на новый учебный год, или папка нового года задерживается;
|
||
- университет не отвечает дольше часа, а потом снова отвечает.
|
||
|
||
Первая проверка только сообщает, что оповещения включены, и называет число документов и подозрительных ячеек. Если сообщение не ушло, оно повторится при следующей проверке. С `PSU_PUBLIC_URL` в сообщениях есть ссылка на `/api/diagnostics` документа.
|
||
|
||
Настройка:
|
||
1. Создайте бота у @BotFather и получите токен.
|
||
2. Напишите боту любое сообщение.
|
||
3. Возьмите `chat.id` из ответа `https://api.telegram.org/bot<токен>/getUpdates`.
|
||
4. Положите значения в `.env` рядом с `docker-compose.yml` (файл в `.gitignore`):
|
||
|
||
```sh
|
||
TELEGRAM_BOT_TOKEN=123456:ABC...
|
||
TELEGRAM_CHAT_ID=123456789
|
||
PSU_PUBLIC_URL=https://psu.rumblur.by
|
||
```
|
||
|
||
5. Перезапустите контейнер: `docker compose up -d`.
|
||
|
||
Состояние оповещений — плитка Telegram на дашборде и поле `telegram` в `/status/data.json`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает.
|
||
|
||
Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
|
||
|
||
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
|
||
|
||
Ошибки приходят как `{ "error": "текст для показа", "code": "…" }`. Клиенты опираются на `code`, а не на текст: `bad_request` (400), `unknown_endpoint`, `document_not_found`, `group_not_found` (404), `file_too_large` (413), `unreadable_file` (422), `too_many_requests` и `busy` (429), `source_unavailable` и `source_invalid` (502, каталог университета не ответил или не распознан), `source_timeout` и `timeout` (504, университет или сам сервер не ответили вовремя), `internal` (500). Полный список — в `server/errors.js`. Android и iOS при `source_unavailable`, `source_timeout`, `timeout`, `busy` и `too_many_requests` оставляют на экране сохранённую копию и показывают «Повторить».
|
||
|
||
К каталогу университета сервер обращается встроенным в Node `fetch`: соединения переиспользуются, сертификаты проверяются, перенаправления не принимаются, ответ ограничен 10 МБ и 25 секундами. Обход каталога (около сотни папок) занимает несколько секунд.
|
||
|
||
**Защита открытого сервера.**
|
||
- **Лимит запросов.** К `/api` — не больше 300 запросов в минуту с одного адреса, сверх лимита ответ `429 too_many_requests` с `Retry-After`. Лимит щедрый, потому что студенты в общежитии или в сети университета выходят в интернет с одного адреса: он ловит только ботов и зациклившиеся клиенты. Проверки здоровья (`/api/health`, `/api/ready`) не считаются. Импорт Word ограничен отдельно: 10 файлов за 10 минут.
|
||
- **Время ответа.** Запрос, на который сервер не ответил за 30 секунд (импорт — за 90), получает `504 timeout`. Клиент должен прислать заголовки за 15 секунд, а весь запрос — за две минуты.
|
||
- **Заголовки безопасности.** Все ответы несут строгую `Content-Security-Policy`: только свои скрипты, стили, шрифты и запросы, а единственный inline-скрипт (тема до первой отрисовки) разрешён по хэшу, который сервер считает из собранного `dist/index.html`. Кроме неё: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`. За HTTPS-прокси, который передаёт `X-Forwarded-Proto`, добавляется `Strict-Transport-Security`.
|
||
- **Адрес клиента за прокси.** Лимит считается по настоящему адресу клиента. Сервер верит `X-Forwarded-For` только от прокси из локальной сети (`127.0.0.1`, `10.*`, `172.16–31.*`, `192.168.*`). Поле `client` в `/api/health` показывает, кем сервер тебя видит: если там адрес прокси, а не твой, все посетители делят один лимит, и прокси нужно настроить на передачу `X-Forwarded-For`.
|
||
|
||
`GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив: поле `ready` — то же, что `/api/ready`, плюс версии и учебный год. Возраст каталога, найденные файлы недель и звонков, семестры и предупреждения (папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется) — на дашборде `/status`.
|
||
|
||
```sh
|
||
npm run sync
|
||
```
|
||
|
||
Серверный кэш находится в `.data/` (исключён из Git). Загрузки пользователей обрабатываются в памяти; временные файлы конвертации `.doc` удаляются. Данные из университетского каталога могут сохраняться в серверном кэше. Для офлайн-просмотра расписания хранятся в `localStorage`; очистка данных браузера удалит сохранённые документы. Аналитики и учётных записей нет.
|
||
|
||
## Границы парсера
|
||
|
||
Основной парсер читает XML и сетку таблиц `.docx`, включая горизонтальные и вертикальные объединения ячеек. Поддерживаются проверенные дневные шаблоны ФЭФ, ФИТ, ФКНЭ, ИСФ и ГФ, заочные установочные сессии с отдельными колонками предмета, аудитории и преподавателя, а также заочные лабораторно-экзаменационные сессии.
|
||
|
||
**Лабораторно-экзаменационные сессии заочников** (проверено на зимней сессии 23-ИТзс 2024 года и летней 2026 года, `test/fixtures/itzs-*.docx`) устроены иначе:
|
||
- **Группа.** Указана только в заголовке («группа 23-ИТзс»).
|
||
- **Недели.** В таблице две–три недели стоят рядом блоками столбцов «Д.н. — Время — Дисциплина», у каждого дня своя дата. Сессия поэтому не зависит от таблицы недель и показывается по своим датам даже в прошлом учебном году. API отдаёт её границы в поле `semester` с названием «Лабораторно-экзаменационная сессия».
|
||
- **Подгруппы.** Если под одним заголовком дисциплины столбец разделён, левая часть — подгруппа 1, правая — подгруппа 2, даже при неровном делении.
|
||
- **Типы и предмет.** Распознаются установочные лекции и занятия (`уст.л.`, `уст.пз.`), экзамены, зачёты, консультации и защита курсовой работы. Предмет берётся из кавычек («…по учебной дисциплине «Базы данных»»).
|
||
- **Время.** Если у экзамена указано только начало («8.30»), конец берётся по расписанию звонков.
|
||
|
||
**Расписания зачётов** (папка «Расписание зачетов», проверено на 23-, 24- и 26-АРХ ИСФ, `test/fixtures/isf-credits-26-arh.docx`) — таблица «Дата — Время — группа»:
|
||
- **Дата и время.** В ячейке дня дата стоит перед днём недели («27.10.2026 вторник»), пустая ячейка продолжает день выше. Указано одно время — начало, конец берётся по расписанию звонков. Такое одиночное время теперь принимается и в других таблицах: прежде в документах «Введение в высшее образование» и в установочных сессиях пропадали строки вроде «Торжественное собрание 10.30».
|
||
- **Тип записи.** Строка без пометки считается зачётом, «Консультация по учебной дисциплине…» — консультацией. Все они попадают в обратный отсчёт сессии.
|
||
|
||
Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку.
|
||
|
||
**Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы», а части пометки через `;` или `,` (`1-4н.;6-12н.`, `2-5 н., 10-17 н.`) складываются. Текстовые даты вроде «15 октября» и списки дат числами («10.09;15.10;19.11;17.12», не меньше двух дат, чтобы время пары не путалось с датой) привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет.
|
||
|
||
Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, `9.00`, `18.35.`, интервал `11:10-11:30` или несколько времён (`17.00 и 18.35`, `18.35+20.10` — тогда это отдельные занятия), оно имеет приоритет; окончание не выдумывается. Дата в начале ячейки (`13.10`, `3.12`, `24.11.`, `12.09.26г.`) делает запись разовой. Запись вида `20.10` может быть и датой, и началом восьмой пары: если такая дата приходится на другой день недели, чем строка, или после неё идёт пометка недель, это время.
|
||
|
||
**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`):
|
||
- **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`.
|
||
- **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям.
|
||
- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д» и с цветом после аудитории — «ауд.533н-б.н.,420н-з.н.» (25-ДО, 26-ДО, 23-ДО, АРХ, ПГС, ТТиИ и др., парсер 15). Номер аудитории перед пометкой цвета («32н-з.н.») не принимается за 32-ю неделю.
|
||
- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели; PWA — тоже (общий `planFor` из `shared/lessons.js`), а в режиме «Все строки Word» показывает аудитории обоих цветов: «533Н (бел. н.), 420Н (зел. н.)». Весь план виден в деталях занятия. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель.
|
||
|
||
**Ещё форматы ячеек:**
|
||
- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом.
|
||
- **Разный вид по неделям.** «1‑11н. ‑ л., 12‑14 – п.з. Защита населения…» — лекция на одни недели, практика на другие.
|
||
- **Дисциплины по выбору и специализации.**
|
||
- Заголовок «Дисциплины по выбору:» или «Дисциплина специализации: 4‑14 н. …» — не занятие, и он не скрывает недели.
|
||
- Строка «Социология 1‑10 н. (л.) … 11‑16 н. (п.з.) …» с неделями после названия становится занятиями по видам.
|
||
- Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней.
|
||
- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика».
|
||
- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы.
|
||
- **Вид занятия после преподавателя** (парсер 18): «Русский язык как иностранный Козыренко А.Н.(п.з.) ауд.100» — название кончается там, где начинается преподаватель или вид, смотря что раньше. Раньше фамилия попадала в название (25‑ЭПи, 25‑ЭМ).
|
||
- **Ячейка из одной точки** — не занятие.
|
||
- **Время в начале ячейки:**
|
||
- интервал с дефисом «17:00‑17:20‑информационный час»;
|
||
- заголовок со временем «18.35+20.10 Новополоцк:» передаёт время занятиям под ним.
|
||
- **Длинная запись под заголовком заглавными** («ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …») называется этим заголовком.
|
||
|
||
**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу.
|
||
|
||
**Занятия в другом городе** (`server/places.js`, парсер 14). Большинство пар — в Новополоцке, но часть факультетов учится в Полоцке, и Word отмечает это по‑разному:
|
||
- **«Занятия в Полоцке» над первой парой дня** (24‑ИЯ (РА)и‑1…6, 25‑ИЯ (РА)и‑3, 24‑РГФи‑1, 22‑ЛОМК, 25‑ТДи) — не занятие, а заметка дня для столбца: в этот день все пары группы или подгруппы идут в том городе (и аудитории у них полоцкие — Г, Д, Е, Ж). Заметка лежит в разобранном документе в `places` (`{day, date, groups, subgroups, place, text}`), занятиям этого дня проставляется `place`. Если в той же ячейке под заметкой есть пара, это обычное занятие без приставки в названии. Занятие всей группы получает город, когда заметка стоит во всех её подгруппах.
|
||
- **Город строкой в ячейке занятия** — «Новополоцк», «г.Новополоцк» — или **после аудитории** — «246(Новополоцк)», «121(г.Новополоцк)» — город этого занятия; из названия и аудитории он убирается.
|
||
- Пишется и «Занятия в г. Новополоцк», «Занятия проходят в …», «Занятия в Междуречье». Город приводится к именительному падежу («Полоцке» → «Полоцк»). Название предмета вроде «История Полоцка» городом не считается.
|
||
- В документах «Введение в высшее образование» строка «г. Полоцк, Дом офицеров» даёт занятию город, а место проведения остаётся в тексте.
|
||
- Какие заметки показать над днём группы, решает общий модуль `shared/places.js`: им пользуются и `/api/timetable`, и PWA. Одинаковая заметка во всех подгруппах сводится к одной для всей группы, а заметка своей подгруппы показывается как заметка всего дня.
|
||
|
||
**Занятия на выбор** (парсер 16). У 23‑, 24‑ и 25‑РГФ‑1 одна ячейка на всю группу содержит две пары сразу: «Немецкий язык (п.з) – Найверт Ю.О. 208А, / Французский язык (п.з.) – Глобенко М.В. 336Е». Это не подгруппы (ячейка не делится пополам): часть группы учит немецкий, часть французский. Так же записаны «Белорусский язык» и «Стилистика русского языка» у 26‑РГФ и две дисциплины по выбору у 23‑ИТ.
|
||
- Ячейка, где каждая строка — целая пара (предмет, вид в скобках, преподаватель), делится на отдельные занятия со своими преподавателем и аудиторией (`parallel` в `server/parser.js`). Строка без предмета («321Д») остаётся при паре над ней. До версии 16 такая ячейка была одной парой «Немецкий язык» с двумя преподавателями и аудиторией французского.
|
||
- Каждое такое занятие получает `option` — свой предмет, а документ — `choices` (`{groups, options}`). Тот же предмет в других ячейках этих групп («Немецкий язык» в четверг без французского) — тот же вариант и тоже получает `option`.
|
||
- `/api/timetable` (API 15) отдаёт у занятия `option`, а у группы — `choices`: `[["Немецкий язык", "Французский язык"]]`. Клиент, который о них не знает, просто показывает обе пары.
|
||
|
||
**Пары для иностранных студентов и для остальных** (парсер 17). «Русский язык как иностранный» и пары с пометкой «(для иностранных студентов)», «(для иностр.студ.)» — только для иностранцев, пары «(кроме иностр.студ.)» — только для остальных. Занятие получает `audience`: `"foreign"` или `"local"`.
|
||
- Пометка — не часть названия и аудитории: раньше «ауд.36н (для иностранных студентов)» давало аудиторию «36н(дляиностранныхстудентов)».
|
||
- В одной ячейке часто две пары сразу: иностранцы на РКИ, остальные на физкультуре или другом занятии. Такая ячейка делится на две, у каждой свой `audience`:
|
||
- «Ф и з и ч е с к а я к у л ь т у р а / Русский язык как иностранный (для иностранных студентов) …» (25‑ГЕО, 25‑КБ, 25‑ЭТ, 24‑СК, 24‑ЭБ);
|
||
- «Русский язык как иностранный (п.з.) Немирская В.А. 331Е ФИЗИЧЕСКАЯ КУЛЬТУРА» (25‑ и 26‑РГФ): раньше физкультура пропадала, а у РКИ не было аудитории;
|
||
- «3‑14н. Великая Отечественная война … 220Д / Русский язык как иностранный … 201Г»: раньше одна пара с аудиторией РКИ.
|
||
- «24‑КФ: Русский язык как иностранный …» в ячейке на 24‑КБ и 24‑КФ — занятие только 24‑КФ.
|
||
- Город или время строкой выше («17.00 Новополоцк») и преподаватель строкой ниже — не отдельная пара.
|
||
- Две пары, обе с пометкой («Теория языка (кроме иностр.студ.)» и «История языка (для иностр.студ.)» у 24‑РГФ‑1), — не выбор предмета, а пары для разных студентов.
|
||
- В группах иностранцев («25‑ДОи», «24‑ТНГи‑2») РКИ — пара для всех, `audience` у неё нет.
|
||
- `/api/timetable` отдаёт у занятия `audience`, а у группы — `audience: true`, если в ней есть такие пары.
|
||
|
||
Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice.
|
||
|
||
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
|
||
|
||
**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=<id>`, `?code=weeks_partly`. Их число и список с фильтрами — на дашборде `/status`. Коды:
|
||
- `weeks_partly` — пометка недель прочитана не целиком (`1-4н.;6-12н.`);
|
||
- `weeks_unreadable` — пометка недель не прочитана (`2,6-12-16н`);
|
||
- `weeks_like_date` — «недели» похожи на дату с опечаткой (`19,11, 03.12`);
|
||
- `weeks_out_of_range` — номер недели больше, чем недель в семестре;
|
||
- `date_in_subject`, `time_in_subject` — дата или время в начале ячейки попали в название (`24.11.`, `9.00`, `11:10-11:30`);
|
||
- `dates_unread` — в ячейке есть даты, но ни одна не распознана;
|
||
- `date_weekday` — дата приходится на другой день недели, чем строка;
|
||
- `date_out_of_year` — дата вне учебного года;
|
||
- `subject_missing` — название предмета не выделено;
|
||
- `time_invalid` — время пары неправдоподобно;
|
||
- `group_empty` — у группы из заголовка нет ни одного занятия.
|
||
|
||
Такие ячейки — повод сверить документ с Word и, если это новый формат, научить парсер.
|
||
|
||
## Проверки
|
||
|
||
```sh
|
||
npm test
|
||
npm run build
|
||
```
|
||
|
||
Тесты используют реальные Word-документы источника в `test/fixtures/` с URL и метаданными рядом. Результат разбора каждого из них вместе с найденными диагностикой ячейками сравнивается построчно со снимком в `test/snapshots/`. Если разбор изменился намеренно, снимки обновляются командой `UPDATE_SNAPSHOTS=1 npm test`; разницу в `test/snapshots/` стоит прочитать перед коммитом. HTTP-тесты (`test/api.test.js`) запускают настоящий сервер против локальной подмены Nextcloud университета, которая отдаёт тестовые документы как маленький каталог, с одним документом, отвечающим ошибкой, и одним слишком медленным. Проверяются готовность, форма ответа `/api/timetable`, `304` по ETag, коды ошибок (`400`, `404`, `422`, `429`, `502`, `504`), диагностика, импорт с лимитом и переиспользование соединений. Для этого сервер читает переменные `PSU_SOURCE_ORIGIN`, `PSU_DATA_DIR` и `PSU_SOURCE_TIMEOUT_MS`; в работе их задавать не нужно. Также проверяются изоляция 23-БА/23-ЛГ, общие события, объединения ячеек, варианты недель, отдельное время кураторского часа, даты сессии и отклонение повреждённых файлов, а также таблица недель и звонков, разбор пометок с учётом цвета строки и согласие всех пометок 23-БА с таблицей.
|
||
|
||
Перед изменением парсера стоит снять снимок всего каталога и после правки сравнить с ним:
|
||
|
||
```sh
|
||
npm run snapshot -- fetch # скачать все документы каталога в .data/originals/
|
||
npm run snapshot -- save # разобрать их текущим парсером и сохранить эталон
|
||
npm run snapshot # после правки: что изменилось по каждому документу
|
||
```
|
||
|
||
Проверка выводит по каждому документу пропавшие и появившиеся занятия (с `--all` — все, иначе первые десять) и как изменилось число подозрительных ячеек по кодам. Если что-то изменилось, команда завершается с кодом 1. Документы и эталон лежат в `.data/` и в git не попадают.
|
||
|
||
## Docker
|
||
|
||
```sh
|
||
docker compose up -d --build
|
||
```
|
||
|
||
Приложение откроется на http://localhost:3001. Кэш каталога, таблицы недель и распознанных документов хранится в томе `psu-data` и переживает пересборку. Контейнер считается здоровым, когда сервер готов (`/api/ready`): с кэшем в томе — через несколько секунд после старта, при первом запуске — после того как скачаны и разобраны все документы каталога; на это даётся две минуты. Порт и адрес меняются переменными `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`. Контейнер включает LibreOffice для `.doc`. Файл `docker-compose.yml` проверен `docker compose config`; сборка образа в этой проверке не запускалась.
|
||
|
||
## Android-приложение
|
||
|
||
В папке `android/` — нативный клиент на Kotlin и Jetpack Compose с компонентами Material 3 Expressive (`MaterialExpressiveTheme`, `ModalWideNavigationRail` и `WideNavigationRail`, соединённые `ToggleButton`, `LoadingIndicator`). Базовая тема синяя (тональная схема M3); с «Цветами обоев» — динамическая, а цвета предметов и зелёной недели гармонизируются с ней. Разделы открываются из бокового меню: на телефоне оно выезжает, на планшете в портрете и на телефоне в альбомной ориентации — узкая боковая панель, на планшете в альбомной ориентации меню открыто постоянно. На широком экране разделы делятся на две панели, как в School Planner: «Обзор» — карточки и недельная сетка, «Планы» и «Календарь» — список и выбранный день, «Поиск» — результаты и день преподавателя или аудитории; «Предметы» — сеткой карточек, «Расписание» открывается неделей; карточки лежат на подложке с оттенком, системные панели следуют теме приложения.
|
||
|
||
**Складные телефоны.** Приложение следит за сгибом экрана через Jetpack WindowManager (`FoldingFeature`, `ui/Fold.kt`).
|
||
- **Фолды** (Galaxy Z Fold, Pixel Fold) в разложенном виде — окно через сгиб с короткой стороной от 600dp — получают планшетный макет:
|
||
- «Обзор», «Планы», «Календарь» и «Поиск» — две панели, как две страницы, по обе стороны вертикального сгиба, и в плоском, и в полураскрытом виде;
|
||
- «Расписание» открывается неделей на карточке;
|
||
- меню остаётся узкой полосой, а раскрытое ложится поверх экрана с затемнением и не сжимает левую панель.
|
||
- **Флипы** (Galaxy Z Flip, Razr) — окно размером с телефон — сохраняют телефонный макет.
|
||
- **«Ноутбук»** — телефон приоткрыт на столе, сгиб горизонтальный. «Расписание» по дням становится настольной подставкой:
|
||
- над сгибом — день и одна пара крупно: время, обратный отсчёт или полоса прогресса идущей пары, аудитория и преподаватель; на широком экране рядом — «Дальше»;
|
||
- под сгибом — панель, лента дней и карточки пар дня: касание показывает пару наверху, повторное — открывает подробности;
|
||
- неделя показывает сетку над сгибом.
|
||
|
||
Проверено на эмуляторе Pixel 10 Pro Fold (`OPENED`, `HALF_OPENED` в портрете и в альбомной ориентации) и на AVD «6.7in Foldable» (флип: телефонный макет). Универсальный AVD флипа сообщает приложению сгиб всегда в состоянии `FLAT`, поэтому «ноутбук» на флипе проверен только на фолде.
|
||
|
||
- **Запуск.** Заставка (`core-splashscreen`, одинаково на Android 8–16): на фоне приложения рисуется иконка — календарь вырастает, кольца опускаются, зелёная галочка дорисовывается (около 750 мс, на Android 12+ анимированно). Заставка держится, пока не прочитаны сохранённые настройки, поэтому пустого кадра нет. Потом иконка раздувается и растворяется, а карточки «Обзора» поднимаются одна за другой (один раз за запуск).
|
||
- **Отмена вместо подтверждений.** Удалённое задание или заметка и скрытая пара сразу пропадают, а снизу 10 секунд висит «Задание удалено · Вернуть» или «Скрыто: «…» · Вернуть». Фото удалённого задания стираются, только когда вернуть уже нельзя. В подробностях пары и в листе предмета snackbar показывается прямо в них.
|
||
- **Сегодня — на текущей паре.** Страница сегодняшнего дня открывается на идущей паре (или следующей), а не на первой утренней.
|
||
- **Виброотклик:** тик при выборе дня в ленте и при листании дней пальцем, при выборе в кнопках-переключателях. Подтверждение при выполнении задания и отметке посещения, щелчок у переключателей настроек.
|
||
- **Скелетоны вместо спиннера:** пока грузится первое расписание группы, «Обзор», «Расписание» и «Предметы» показывают серые заготовки в форме своих карточек с мягким бликом. Так же выглядят занятия преподавателя или аудитории в поиске и каталог групп. Для TalkBack это одно «Загрузка». При системной настройке «Убрать анимацию» блик, появление карточек и прочие украшения отключаются.
|
||
- **Карточка вырастает в подробности** (container transform). Пара открывается из своей карточки: из «Обзора», расписания по дням, недельной сетки, календаря «Планов» и подставки «ноутбука». Рамка плавно растягивается до полного экрана (на планшете — до большой панели по центру), картинка карточки растворяется, подробности проявляются. «Назад», предиктивный жест и касание за панелью сворачивают её обратно в карточку. Так же «+» в «Планах» и календаре раскрывается в лист нового задания; лист можно стянуть вниз за ручку.
|
||
- **Живая текущая пара.** Вокруг идущей пары по часовой стрелке от левого верхнего угла рисуется рамка: какая часть пары прошла. Она подрастает плавно, без скачков. Цифры обратного отсчёта («ещё 44 мин», «Через 15 мин») прокручиваются, как на табло. Линия «сейчас» в недельной сетке ползёт вниз вместе со временем.
|
||
- **Лента дней.** Выбранный день — один круг под всей лентой: при выборе другого дня он перетекает туда на пружинах, передний край спешит, задний догоняет, и по дороге круг вытягивается в каплю. В «Расписании» точка в круге дня значит «есть пары». Когда пролистываешь на другую неделю, её точки появляются по очереди, с понедельника.
|
||
- **Выполненное задание.** При отметке текст зачёркивается слева направо, строка за строкой, и задание чуть задерживается на месте, прежде чем уйти к выполненным. Когда закрыто последнее открытое, у «Всё сделано» выскакивает галочка.
|
||
- **Обновление жестом.** Потянуть вниз в «Обзоре» и в расписании: экспрессивный индикатор M3 с меняющейся формой и щелчок отклика, когда тянуть уже достаточно.
|
||
- **Свежие изменения на виду.** Если университет перезалил документ и пары изменились или добавились, их карточки в расписании дважды вспыхивают цветом (один раз, при первом показе).
|
||
- Первый запуск — короткий онбординг: приветствие с тремя возможностями приложения, затем шаги с волнистым индикатором прогресса: группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на шаге напоминаний, его можно отложить; «Назад» (с предиктивным жестом) возвращает на предыдущий шаг. После онбординга открывается «Обзор» выбранной группы.
|
||
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
||
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
|
||
- **Занятия на выбор** (немецкий или французский у РГФ). После выбора группы (и подгруппы) приложение спрашивает «Что вы изучаете?», в онбординге это отдельный шаг. Пары другого варианта скрываются из расписания, «Обзора», «Планов», виджетов, напоминаний и уведомлений. Изменить выбор можно в «Группах», в карточке «Сейчас открыта». Пока вариант не выбран, видны обе пары с меткой «на выбор».
|
||
- **Иностранные студенты.** В группах, где есть пары только для иностранцев («Русский язык как иностранный») или только для остальных, приложение спрашивает «Вы иностранный студент?» (и в онбординге). Ответ «Да» скрывает пары «кроме иностранных студентов», «Нет» — РКИ и другие пары для иностранцев. Изменить — в «Группах», строка «Иностранный студент». На карточке пары — метка «для иностранных студентов» или «кроме иностранных студентов».
|
||
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
|
||
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
|
||
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
|
||
- **Занятия в другом городе.** Если в Word над днём написано «Занятия в Полоцке», над парами дня появляется карточка «Занятия в Полоцке · пометка в Word на этот день» — на странице дня, в «Календаре», в «Обзоре» (сегодня и ближайший день) и на подставке «ноутбука», а в недельной сетке под датой — метка «Полоцк». Если заметка относится к одной подгруппе, а выбраны все, в карточке указана подгруппа. В карточках пар город стоит рядом с аудиторией («249Ж · Полоцк»), в подробностях — строка «Город»; так же — в виджетах, плитке и напоминаниях. Раньше такая заметка показывалась отдельной парой «Занятия в Полоцке».
|
||
- **Группа, которой больше нет.** Если сервер отвечает `group_not_found` или `document_not_found` — группу разделили («24‑ИЯ(РА)» стала «24‑ИЯ (РА)и‑1»…), или документ ушёл из каталога, — плашка с текстом сервера предлагает «Выбрать группу». Сохранённая копия остаётся на экране, а устаревшая группа убирается из недавних.
|
||
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
|
||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||
- **Фото к заданиям:** в редакторе задания или заметки — «Снять» (приложение камеры) и «Галерея» (системный выбор фото, без доступа к памяти), до 6 фото. Фото уменьшается до 2048 px по длинной стороне, поворачивается по EXIF и хранится в `files/photos/` рядом с заметками. Удаление фото, задания или отмена правки убирает файлы, а при запуске удаляются фото, на которые не ссылается ни одна заметка. В списках у задания миниатюра (с «+N»), на карточке пары — значок фото. Просмотр — на весь экран, с листанием, масштабом щипком и двойным касанием. Задание может быть и одним фото, без текста.
|
||
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
|
||
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
|
||
- Второй виджет «Следующая пара» (4×1) и плитка в шторке быстрых настроек с текущей или следующей парой и аудиторией.
|
||
- **Виджет «Неделя»** (4×2 и больше): номер и цвет недели, по плитке на день (Пн–Сб, воскресенье — только если в нём есть пары) с числом пар. Сегодня выделено. Значок города — день в другом городе, значок стрелок — перенос аудитории, внизу строка вроде «Полоцк: вт, пт · перенос: пн, чт». Если растянуть виджет в высоту, в плитках появляются сокращения пар, как в недельной сетке. В воскресенье виджет показывает следующую неделю. Нажатие на день открывает этот день, на заголовок — неделю.
|
||
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
|
||
- Группы (кнопка группы в верхней панели «Обзора»; если у группы есть подгруппы, после выбора приложение предложит свою): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
|
||
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
|
||
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
|
||
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
|
||
- **Уведомления о дне** (настройки → «Уведомления о дне», тоже локальные, из сохранённого расписания):
|
||
- «Занятия в другом городе» — вечером накануне такого дня (18:00–21:00, по умолчанию 20:00) приходит «Завтра занятия в Полоцке» с первой парой, аудиторией и числом пар. Напоминание о первой паре этого дня, если напоминания включены, приходит раньше обычного на 0–60 минут (по умолчанию 30).
|
||
- «Переносы аудиторий» — утром в день переноса (6:30–8:00, по умолчанию 7:00, но не позже чем за час до пары) приходит «Сегодня перенос аудитории: 15:20 Возрастная физиология… — ауд. 100, обычно 402Н».
|
||
- Оба включены по умолчанию. Нажатие открывает этот день в «Расписании».
|
||
- **Текущая пара в шторке** (выключена по умолчанию): пока идёт пара, в шторке висит уведомление — предмет, «до 11:30 · ауд. 205Г · Полоцк», следующая пара и прогресс по половинам пары из расписания звонков. На Android 16 это Live Update: в строке состояния плашка с обратным отсчётом до конца пары, на экране блокировки — само уведомление. Оно появляется по будильнику в начале пары, обновляется раз в несколько минут и само исчезает в конце.
|
||
- **Ярлыки на иконке** (долгое нажатие): «Пары на сегодня», «Неделя», «Найти аудиторию», «Новое задание» (открывает «Планы» с выбором пары).
|
||
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
|
||
|
||
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
|
||
- **Как выделены.** Янтарный цвет (красный читался бы как ошибка), значок вида события и рамка вокруг карточки.
|
||
- **В «Планах»** они видны всегда, даже с выключенными «Парами дня».
|
||
- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой.
|
||
|
||
Даты считает сервер: `GET /api/timetable?doc=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. С API 14 у занятия есть `place` — город, если документ его называет, а у дня с парами — `places`: заметки «Занятия в Полоцке» (`{place, text, subgroup}`, `subgroup: null` — для всей группы; одинаковая заметка во всех подгруппах сводится к одной). Поэтому правила белых и зелёных недель в Kotlin не дублируются.
|
||
|
||
Сборка (JDK 17, Android SDK 37):
|
||
|
||
```sh
|
||
cd android
|
||
./gradlew assembleDebug
|
||
```
|
||
|
||
Отладочная сборка ходит на `http://10.0.2.2:3010/` — это сервер из `docker compose` на компьютере, видимый из эмулятора (`APP_PORT=3010`). Другой адрес: `-PPSU_API_URL=…`. Релизная сборка принимает только HTTPS-адрес. Идентификатор приложения `ritm.schedule` — рабочий, перед публикацией его стоит заменить своим.
|
||
|
||
## iOS-приложение
|
||
|
||
В папке `ios/` — нативный клиент на SwiftUI для iOS 26 и новее с Liquid Glass. Функции те же, что у Android-приложения, данные — из того же API. Проект `ios/PSUSchedule.xcodeproj` открывается в Xcode 27; папки подключены синхронизированными группами, поэтому новые файлы попадают в сборку сами.
|
||
|
||
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
|
||
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
|
||
- **Занятия в другом городе:** как в Android и PWA. Над парами дня, в «Календаре» и в «Обзоре» (сегодня и ближайший день) стоит карточка «Занятия в Полоцке», в недельной сетке под датой — метка «Полоцк». В карточках пар город стоит рядом с аудиторией, в подробностях — строка «Город»; так же — в виджетах и напоминаниях.
|
||
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
|
||
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
|
||
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
|
||
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
|
||
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
|
||
- **Предметы:** нагрузка недели на диаграмме Swift Charts; у каждого предмета — следующее занятие, прогресс недели, посещаемость и задания.
|
||
- **Поиск «Кто где»:** занятия преподавателя или аудитории на день по всем документам каталога.
|
||
- **Виджеты WidgetKit:** «Сегодня» (малый, средний, большой) и «Следующая пара» (малый и на экране блокировки). Таймлайн обновляется в начале и конце каждой пары; нажатие открывает день в приложении (`psuschedule://day/ГГГГ-ММ-ДД`).
|
||
- **Офлайн:** расписание, экзамены, задания, посещаемость и настройки хранятся в общей папке App Group `group.org.xtimms.psu.schedule`, её читают и виджеты.
|
||
- Напоминания — локальные уведомления на неделю вперёд (до 48).
|
||
- Раз в несколько часов `BGAppRefreshTask` обновляет расписание.
|
||
- Ответы сервера кэшируются `URLCache`: неизменённый семестр приходит пустым `304`.
|
||
|
||
Адрес сервера задаётся ключом `PSUAPIURL` в `ios/Config/App-Info.plist` (по умолчанию `https://psu.rumblur.by/`). В Debug-сборке его можно заменить переменной окружения `PSU_API_URL`, например для локального сервера на этом Mac:
|
||
|
||
```sh
|
||
SIMCTL_CHILD_PSU_API_URL=http://localhost:3010/ xcrun simctl launch booted org.xtimms.psu.schedule
|
||
```
|
||
|
||
Для установки на устройство в Xcode нужно выбрать команду разработчика и зарегистрировать App Group.
|
||
|
||
## Структура
|
||
|
||
- `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.
|
||
- `server/limits.js` — ограничения импорта: число одновременных разборов и запросов с одного адреса.
|
||
- `server/errors.js` — коды ошибок API и их HTTP-статусы.
|
||
- `ios/Shared/` — модели, даты в часовом поясе Минска, расчёт пар и общее хранилище; общие для приложения и виджетов.
|
||
- `ios/PSUSchedule/` — приложение iOS: `AppModel.swift` (состояние и загрузка), `API.swift`, `Reminders.swift`, экраны в `Views/`.
|
||
- `ios/Widgets/` — виджеты WidgetKit.
|
||
- `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 доступны в меню.
|