commit 19ee41411b8e9ad0be5531c96e8631272ddc2135 Author: Zakhar Timoshenko Date: Fri Oct 16 22:08:45 2026 +0300 initial commit diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..43df41e --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1126138 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.data +.git +test +*.tsbuildinfo +.impeccable +.claude +artifacts +.env +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a1a7c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.data/ +*.tsbuildinfo +.DS_Store diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..c4a4022 --- /dev/null +++ b/.impeccable/config.json @@ -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" + } + ] + } +} diff --git a/.impeccable/config.local.json b/.impeccable/config.local.json new file mode 100644 index 0000000..55ac947 --- /dev/null +++ b/.impeccable/config.local.json @@ -0,0 +1,5 @@ +{ + "hook": { + "consent": "accepted" + } +} diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json new file mode 100644 index 0000000..dd72fb1 --- /dev/null +++ b/.impeccable/hook.cache.json @@ -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}}} \ No newline at end of file diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 0000000..dba4f77 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["index.html"], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} diff --git a/.impeccable/review/about.jpg b/.impeccable/review/about.jpg new file mode 100644 index 0000000..6ee8c15 Binary files /dev/null and b/.impeccable/review/about.jpg differ diff --git a/.impeccable/review/desktop.jpg b/.impeccable/review/desktop.jpg new file mode 100644 index 0000000..3caadd7 Binary files /dev/null and b/.impeccable/review/desktop.jpg differ diff --git a/.impeccable/review/details.jpg b/.impeccable/review/details.jpg new file mode 100644 index 0000000..835ba1d Binary files /dev/null and b/.impeccable/review/details.jpg differ diff --git a/.impeccable/review/detector.json b/.impeccable/review/detector.json new file mode 100644 index 0000000..5ec724e --- /dev/null +++ b/.impeccable/review/detector.json @@ -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" + } +] diff --git a/.impeccable/review/groups.jpg b/.impeccable/review/groups.jpg new file mode 100644 index 0000000..e11467d Binary files /dev/null and b/.impeccable/review/groups.jpg differ diff --git a/.impeccable/review/import.jpg b/.impeccable/review/import.jpg new file mode 100644 index 0000000..d7e9b10 Binary files /dev/null and b/.impeccable/review/import.jpg differ diff --git a/.impeccable/review/install.jpg b/.impeccable/review/install.jpg new file mode 100644 index 0000000..0aa8568 Binary files /dev/null and b/.impeccable/review/install.jpg differ diff --git a/.impeccable/review/measurements.json b/.impeccable/review/measurements.json new file mode 100644 index 0000000..6007be5 --- /dev/null +++ b/.impeccable/review/measurements.json @@ -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 + } +] \ No newline at end of file diff --git a/.impeccable/review/menu.jpg b/.impeccable/review/menu.jpg new file mode 100644 index 0000000..efc0059 Binary files /dev/null and b/.impeccable/review/menu.jpg differ diff --git a/.impeccable/review/mobile-first.jpg b/.impeccable/review/mobile-first.jpg new file mode 100644 index 0000000..4dc2fa3 Binary files /dev/null and b/.impeccable/review/mobile-first.jpg differ diff --git a/.impeccable/review/mobile.jpg b/.impeccable/review/mobile.jpg new file mode 100644 index 0000000..97c2e30 Binary files /dev/null and b/.impeccable/review/mobile.jpg differ diff --git a/.impeccable/review/narrow.jpg b/.impeccable/review/narrow.jpg new file mode 100644 index 0000000..62dcc92 Binary files /dev/null and b/.impeccable/review/narrow.jpg differ diff --git a/.impeccable/review/user-487.jpg b/.impeccable/review/user-487.jpg new file mode 100644 index 0000000..d424cdd Binary files /dev/null and b/.impeccable/review/user-487.jpg differ diff --git a/.impeccable/surfaces/src-app-tsx.md b/.impeccable/surfaces/src-app-tsx.md new file mode 100644 index 0000000..8855126 --- /dev/null +++ b/.impeccable/surfaces/src-app-tsx.md @@ -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-иконка приложения остаётся функциональной иконкой. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74af041 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..b4b0408 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,100 @@ +# Product + + + +## 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 до правки парсера. +- Хостинг, публичный адрес и порядок регулярной публикации не выбраны. Локальная сборка не означает опубликованный сервис. +- Другие языки, роли кроме студентов, уведомления и интеграции с календарями не утверждены как обязательные функции. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc00d62 --- /dev/null +++ b/README.md @@ -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=&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 доступны в меню. diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..1bdfd3f --- /dev/null +++ b/VALIDATION.md @@ -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. diff --git a/artifacts/desktop-1440.jpg b/artifacts/desktop-1440.jpg new file mode 100644 index 0000000..9257993 Binary files /dev/null and b/artifacts/desktop-1440.jpg differ diff --git a/artifacts/mobile-390.jpg b/artifacts/mobile-390.jpg new file mode 100644 index 0000000..55b3024 Binary files /dev/null and b/artifacts/mobile-390.jpg differ diff --git a/artifacts/offline-390.jpg b/artifacts/offline-390.jpg new file mode 100644 index 0000000..83779ce Binary files /dev/null and b/artifacts/offline-390.jpg differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..609340d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/index.html b/index.html new file mode 100644 index 0000000..3fb0c48 --- /dev/null +++ b/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + Расписание ПГУ + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..71c8981 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4707 @@ +{ + "name": "psu-schedule", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "psu-schedule", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@material-symbols/svg-400": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@material-symbols/svg-400/-/svg-400-0.48.0.tgz", + "integrity": "sha512-W20MjbiDbLJQ5yi4CPz2y6lCZ9bk3pJ1d5Ra+uErvtj/guVrCNfcYyMZn3KqHpDyyKVfBGkuGHVcdi5h+5+EqA==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.6.tgz", + "integrity": "sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.6.tgz", + "integrity": "sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.6.tgz", + "integrity": "sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.6.tgz", + "integrity": "sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.6.tgz", + "integrity": "sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.6.tgz", + "integrity": "sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.6.tgz", + "integrity": "sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.6.tgz", + "integrity": "sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.6.tgz", + "integrity": "sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.6.tgz", + "integrity": "sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.6.tgz", + "integrity": "sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.6.tgz", + "integrity": "sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.6.tgz", + "integrity": "sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.6.tgz", + "integrity": "sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.6.tgz", + "integrity": "sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.6.tgz", + "integrity": "sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.6.tgz", + "integrity": "sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.6.tgz", + "integrity": "sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.6.tgz", + "integrity": "sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.6.tgz", + "integrity": "sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.6.tgz", + "integrity": "sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.6.tgz", + "integrity": "sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.6.tgz", + "integrity": "sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.6.tgz", + "integrity": "sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.6.tgz", + "integrity": "sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.5.tgz", + "integrity": "sha512-YMZlvWrTPQEhjKp60XSmMGt60+pffbv6DyRnOMvVO98+J7aF8w8Ui9FhdcOwvSixc/kyR6SoVvmejcn/2lZBqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.456", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz", + "integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.6.tgz", + "integrity": "sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.6", + "@rollup/rollup-android-arm64": "4.64.6", + "@rollup/rollup-darwin-arm64": "4.64.6", + "@rollup/rollup-darwin-x64": "4.64.6", + "@rollup/rollup-freebsd-arm64": "4.64.6", + "@rollup/rollup-freebsd-x64": "4.64.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.6", + "@rollup/rollup-linux-arm-musleabihf": "4.64.6", + "@rollup/rollup-linux-arm64-gnu": "4.64.6", + "@rollup/rollup-linux-arm64-musl": "4.64.6", + "@rollup/rollup-linux-loong64-gnu": "4.64.6", + "@rollup/rollup-linux-loong64-musl": "4.64.6", + "@rollup/rollup-linux-ppc64-gnu": "4.64.6", + "@rollup/rollup-linux-ppc64-musl": "4.64.6", + "@rollup/rollup-linux-riscv64-gnu": "4.64.6", + "@rollup/rollup-linux-riscv64-musl": "4.64.6", + "@rollup/rollup-linux-s390x-gnu": "4.64.6", + "@rollup/rollup-linux-x64-gnu": "4.64.6", + "@rollup/rollup-linux-x64-musl": "4.64.6", + "@rollup/rollup-openbsd-x64": "4.64.6", + "@rollup/rollup-openharmony-arm64": "4.64.6", + "@rollup/rollup-win32-arm64-msvc": "4.64.6", + "@rollup/rollup-win32-ia32-msvc": "4.64.6", + "@rollup/rollup-win32-x64-gnu": "4.64.6", + "@rollup/rollup-win32-x64-msvc": "4.64.6", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-svgr": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", + "integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.3.0", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": ">=3.0.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3879408 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/fonts/OFL-RobotoFlex.txt b/public/fonts/OFL-RobotoFlex.txt new file mode 100644 index 0000000..f6ebd5a --- /dev/null +++ b/public/fonts/OFL-RobotoFlex.txt @@ -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. diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt new file mode 100644 index 0000000..e271172 --- /dev/null +++ b/public/fonts/OFL.txt @@ -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. diff --git a/public/fonts/RobotoFlex-Variable.ttf b/public/fonts/RobotoFlex-Variable.ttf new file mode 100644 index 0000000..2a11e4c Binary files /dev/null and b/public/fonts/RobotoFlex-Variable.ttf differ diff --git a/public/fonts/manrope-400.ttf b/public/fonts/manrope-400.ttf new file mode 100644 index 0000000..8594569 Binary files /dev/null and b/public/fonts/manrope-400.ttf differ diff --git a/public/fonts/manrope-500.ttf b/public/fonts/manrope-500.ttf new file mode 100644 index 0000000..2a169e6 Binary files /dev/null and b/public/fonts/manrope-500.ttf differ diff --git a/public/fonts/manrope-600.ttf b/public/fonts/manrope-600.ttf new file mode 100644 index 0000000..595e7dc Binary files /dev/null and b/public/fonts/manrope-600.ttf differ diff --git a/public/fonts/manrope-700.ttf b/public/fonts/manrope-700.ttf new file mode 100644 index 0000000..746764d Binary files /dev/null and b/public/fonts/manrope-700.ttf differ diff --git a/public/fonts/manrope-800.ttf b/public/fonts/manrope-800.ttf new file mode 100644 index 0000000..d324b62 Binary files /dev/null and b/public/fonts/manrope-800.ttf differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..eb1cd20 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..cb89709 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d0db48f --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..5c98a99 --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..c888d79 --- /dev/null +++ b/public/sw.js @@ -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()), + ), + ); +}); diff --git a/scripts/icons.js b/scripts/icons.js new file mode 100644 index 0000000..388ebe1 --- /dev/null +++ b/scripts/icons.js @@ -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 созданы."); diff --git a/scripts/serviceworker.js b/scripts/serviceworker.js new file mode 100644 index 0000000..6f2203d --- /dev/null +++ b/scripts/serviceworker.js @@ -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}).`); diff --git a/scripts/sync.js b/scripts/sync.js new file mode 100644 index 0000000..ac046ce --- /dev/null +++ b/scripts/sync.js @@ -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}`, + ); diff --git a/server/calendar.js b/server/calendar.js new file mode 100644 index 0000000..fcc6aba --- /dev/null +++ b/server/calendar.js @@ -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); +} diff --git a/server/directory.js b/server/directory.js new file mode 100644 index 0000000..ced2d4d --- /dev/null +++ b/server/directory.js @@ -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 || "").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, + }; +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..78560b0 --- /dev/null +++ b/server/index.js @@ -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}`)); +}); diff --git a/server/parser.js b/server/parser.js new file mode 100644 index 0000000..4accc11 --- /dev/null +++ b/server/parser.js @@ -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(/(? 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 }); + } +} diff --git a/server/schedules.js b/server/schedules.js new file mode 100644 index 0000000..83512e4 --- /dev/null +++ b/server/schedules.js @@ -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; +} diff --git a/server/source.js b/server/source.js new file mode 100644 index 0000000..b907c13 --- /dev/null +++ b/server/source.js @@ -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, + }; +} diff --git a/shared/lessons.d.ts b/shared/lessons.d.ts new file mode 100644 index 0000000..f4b39b5 --- /dev/null +++ b/shared/lessons.d.ts @@ -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 { + 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( + before: { lessons: L[] } | null | undefined, + after: { lessons: L[] } | null | undefined, + group: string, +): ScheduleDiff; +export function changeCount(diff: ScheduleDiff | 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[]; diff --git a/shared/lessons.js b/shared/lessons.js new file mode 100644 index 0000000..63834da --- /dev/null +++ b/shared/lessons.js @@ -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 })); +} diff --git a/shared/updates.d.ts b/shared/updates.d.ts new file mode 100644 index 0000000..df351e0 --- /dev/null +++ b/shared/updates.d.ts @@ -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( + 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; diff --git a/shared/updates.js b/shared/updates.js new file mode 100644 index 0000000..0de43b4 --- /dev/null +++ b/shared/updates.js @@ -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 }; +} diff --git a/shared/weeks.d.ts b/shared/weeks.d.ts new file mode 100644 index 0000000..7ed4d7a --- /dev/null +++ b/shared/weeks.d.ts @@ -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; diff --git a/shared/weeks.js b/shared/weeks.js new file mode 100644 index 0000000..840f1e9 --- /dev/null +++ b/shared/weeks.js @@ -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, + }; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5ae819b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,2425 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Icon } from "./icons"; +import type { Catalog, Schedule, Lesson, SourceFile } from "./types"; +import { + readSaved, + saveSchedule, + readPreference, + savePreference, + readCalendar, + saveCalendar, + readDismissed, + dismissUpdate, + readPersonal, + savePersonal, + documentKey, + readChanges, + saveChanges, + type ChangeRecord, + readExams, + saveExams, + type Exam, + readRecentGroups, + rememberGroup, + readTheme, + saveTheme, + type Theme, +} from "./storage"; +import { switchTheme } from "./theme"; +import { + type AcademicCalendar, + dateInWeek, + lessonDates, + semesterView, + todayIso, + weekIndexOf, + weekday, +} from "../shared/weeks"; +import { + bellFor, + hasWeekLimit, + lessonStatus, + longDate, + minutes, + pairLabel, + shortDate, + dayNumber, + weekSpan, + clockMinutes, + countdown, + daysUntil, +} from "./calendar"; +import Search from "./Search"; +import Groups, { type RecentGroup } from "./Groups"; +import { facultyShort } from "./labels"; +import { scheduleUpdate } from "../shared/updates"; +import { + changeCount, + diffSchedules, + lessonKey, + personalFilter, + subgroupsOf, +} from "../shared/lessons"; +import qrcode from "qrcode-generator"; +/** Matches PARSER_VERSION on the server: older saved schedules are re-read once. */ +const SCHEMA_VERSION = 7; +const SOURCE = + "https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027"; +const DAYS = [ + "Понедельник", + "Вторник", + "Среда", + "Четверг", + "Пятница", + "Суббота", + "Воскресенье", +]; +const SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; +const dateLabel = (s: string) => + new Date(s).toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + timeZone: "Europe/Minsk", + }); +const countLabel = (n: number, forms: [string, string, string]) => { + const tail = n % 100; + return `${n} ${tail >= 11 && tail <= 14 ? forms[2] : n % 10 === 1 ? forms[0] : n % 10 >= 2 && n % 10 <= 4 ? forms[1] : forms[2]}`; +}; +type Mode = "calendar" | "all" | "upper" | "lower"; +/** + * Where the week view opens: today's week (on Sunday, the next Monday); for a + * session schedule, the week of its next date. `usable` is false once the + * semester is over, so an outdated schedule falls back to the Word rows. + */ +function startingPoint( + calendar: AcademicCalendar | null, + schedule?: Schedule | null, +) { + const lessons = schedule?.lessons || []; + if (calendar?.weeks.length && lessons.length && lessons.every((l) => l.date)) { + const dates = lessons.map((l) => l.date!).sort(); + const target = dates.find((d) => d >= todayIso()) || dates[0]; + const index = weekIndexOf(calendar, target); + if (index >= 0) + return { day: weekday(target), index, inside: true, usable: true }; + } + const iso = todayIso(); + let day = weekday(iso), + index = weekIndexOf(calendar, iso); + const inside = index >= 0; + const usable = Boolean( + calendar?.weeks.length && iso <= calendar.weeks.at(-1)!.end, + ); + if (calendar && inside && day === 7 && index + 1 < calendar.weeks.length) { + index++; + day = 1; + } + if (calendar && !inside && calendar.weeks.length) + index = iso < calendar.weeks[0].start ? 0 : calendar.weeks.length - 1; + if (calendar && index === 0 && !dateInWeek(calendar.weeks[0], day)) + day = weekday(calendar.weeks[0].start); + return { day, index, inside, usable }; +} +async function api(url: string, options?: RequestInit): Promise { + const r = await fetch(url, options).catch(() => { + throw new Error( + "Сервер недоступен. Проверьте подключение и попробуйте ещё раз.", + ); + }); + const body = await r.json(); + if (!r.ok) throw new Error(body.error || "Не удалось загрузить данные."); + return body; +} +const lessonTheme = (type: string) => + type === "Лекция" + ? "lecture" + : type === "Практика" + ? "practice" + : type === "Лабораторная" + ? "lab" + : "other"; +interface ListContext { + group: string; + onOpen: (lesson: Lesson) => void; + /** Weeks of the schedule's semester; null when dates cannot be calculated. */ + calendar: AcademicCalendar | null; + /** The whole table: bells apply whatever the semester. */ + bells: AcademicCalendar | null; + datesOf: (lesson: Lesson) => string[] | null; + today: string; + /** Minutes since midnight when the list shows today; drives the live pair chip. */ + clock: number | null; + isHidden: (lesson: Lesson) => boolean; +} +const duration = (m: number) => + m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч${m % 60 ? ` ${m % 60} мин` : ""}`; +function LessonCard({ + lesson, + ctx, + muted = false, +}: { + lesson: Lesson; + ctx: ListContext; + muted?: boolean; +}) { + const theme = lessonTheme(lesson.type); + const subgroup = lesson.subgroups.find((item) => item.group === ctx.group); + const dates = ctx.calendar && hasWeekLimit(lesson) ? ctx.datesOf(lesson) : undefined; + const hidden = ctx.isHidden(lesson); + return ( +