initial commit
17
.claude/launch.json
Normal 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
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.data
|
||||||
|
.git
|
||||||
|
test
|
||||||
|
*.tsbuildinfo
|
||||||
|
.impeccable
|
||||||
|
.claude
|
||||||
|
artifacts
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.data/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
34
.impeccable/config.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.impeccable/config.local.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"hook": {
|
||||||
|
"consent": "accepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.impeccable/hook.cache.json
Normal 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}}}
|
||||||
6
.impeccable/live/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": ["index.html"],
|
||||||
|
"insertBefore": "</body>",
|
||||||
|
"commentSyntax": "html",
|
||||||
|
"cspChecked": true
|
||||||
|
}
|
||||||
BIN
.impeccable/review/about.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
.impeccable/review/desktop.jpg
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
.impeccable/review/details.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
12
.impeccable/review/detector.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
.impeccable/review/groups.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
.impeccable/review/import.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
.impeccable/review/install.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
42
.impeccable/review/measurements.json
Normal 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
|
After Width: | Height: | Size: 22 KiB |
BIN
.impeccable/review/mobile-first.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
.impeccable/review/mobile.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
.impeccable/review/narrow.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
.impeccable/review/user-487.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
30
.impeccable/surfaces/src-app-tsx.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 93 KiB |
BIN
artifacts/mobile-390.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
artifacts/offline-390.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
66
docker-compose.yml
Normal 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
@@ -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
35
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
93
public/fonts/OFL-RobotoFlex.txt
Normal 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
@@ -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.
|
||||||
BIN
public/fonts/RobotoFlex-Variable.ttf
Normal file
BIN
public/fonts/manrope-400.ttf
Normal file
BIN
public/fonts/manrope-500.ttf
Normal file
BIN
public/fonts/manrope-600.ttf
Normal file
BIN
public/fonts/manrope-700.ttf
Normal file
BIN
public/fonts/manrope-800.ttf
Normal file
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
public/icon.svg
Normal 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 |
26
public/manifest.webmanifest
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
384
src/Groups.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
24
src/theme.ts
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-svgr/client" />
|
||||||
168
test/directory.test.js
Normal 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
6
test/fixtures/bells.docx.json
vendored
Normal 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
7
test/fixtures/fef-23-ba.docx.json
vendored
Normal 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
7
test/fixtures/fit-daily.docx.json
vendored
Normal 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
7
test/fixtures/fit.docx.json
vendored
Normal 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
7
test/fixtures/fkne-daily.docx.json
vendored
Normal 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
7
test/fixtures/fkne.docx.json
vendored
Normal 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
7
test/fixtures/gf-daily.docx.json
vendored
Normal 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
7
test/fixtures/isf-daily.docx.json
vendored
Normal 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
6
test/fixtures/weeks.docx.json
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||||
|
});
|
||||||