From d04207302a51172dfa70a18b441b66d2ec3795b6 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 14 Sep 2026 22:01:46 +0300 Subject: [PATCH] Phase 7: deployment, data import, UI hardening and security fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker/prod deployment: - multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml, nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow) - Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers Variant distribution (lab page only): - round-robin by student level, shared random queue across selected groups, least-used variants, manual pins always preserved; occupied/dup labels - setup page: single form + one «Сохранить всё», batch level/variant for selected students (checkboxes) Grades, defense, materials: - publication gate + comment lists; defense CSV import (2/3 cols, quotes) - additional task after defense ("начать заново"); enroll/unenroll students from group page; temporary-password lock (no navigation until changed) and persistent "Смена пароля"; forced change once - chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard, compact theme toggle; responsive mobile header - markdown: single newlines are soft breaks; highlight.js with light/dark themes - lab/content imports ZIP-only (lab.yaml+README+variants/assets, chapters tree+assets); duplicate lab numbers rejected; variant ordering and date defaults honored Fixes: - 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page --- .dockerignore | 17 + .env.prod.example | 14 + .gitignore | 5 + Dockerfile | 64 + README.md | 67 +- deploy/DEPLOY.md | 145 ++ deploy/IMPORT.md | 78 ++ deploy/deploy.sh | 59 + deploy/nginx/lms.our-place.ru.conf | 67 + docker-compose.prod.yml | 116 ++ libs/common-http/build.gradle.kts | 2 + .../ru/ysu/lms/markdown/MarkdownRenderer.kt | 1 - .../ysu/lms/academic/lab/LabImportParser.kt | 71 +- .../ru/ysu/lms/academic/lab/LabService.kt | 13 + .../src/main/resources/application.yml | 2 +- .../lms/identity/repo/EnrollmentRepository.kt | 1 + .../lms/identity/roster/GroupController.kt | 22 + .../ysu/lms/identity/roster/GroupService.kt | 69 +- .../ru/ysu/lms/identity/roster/RosterDtos.kt | 8 + .../src/main/resources/application.yml | 5 + .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 15 +- .../lms/progress/GradePublicationService.kt | 31 + .../ru/ysu/lms/progress/ProgressController.kt | 33 +- .../ru/ysu/lms/progress/ProgressService.kt | 178 ++- .../lms/progress/domain/GradePublication.kt | 24 + .../repo/GradePublicationRepository.kt | 12 + .../db/migration/V7__grade_publications.sql | 8 + .../ru/ysu/lms/web/admin/AdminController.kt | 633 +++++++-- .../ru/ysu/lms/web/admin/AdminLabView.kt | 44 +- .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 59 +- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 17 +- .../ru/ysu/lms/web/auth/AuthPageController.kt | 7 +- .../kotlin/ru/ysu/lms/web/config/WebConfig.kt | 7 + .../ru/ysu/lms/web/learn/LearnController.kt | 13 + .../session/MustChangePasswordInterceptor.kt | 27 + .../ysu/lms/web/student/StudentController.kt | 31 +- web/src/main/resources/application.yml | 4 + web/src/main/resources/static/css/app.css | 87 +- .../resources/static/css/hljs-github-dark.css | 10 + .../main/resources/static/css/hljs-github.css | 10 + .../main/resources/static/js/admin-group.js | 116 ++ web/src/main/resources/static/js/admin-lab.js | 36 + .../main/resources/static/js/defense-timer.js | 23 + .../main/resources/static/js/highlight.min.js | 1244 +++++++++++++++++ .../main/resources/templates/admin/group.html | 501 ++++--- .../resources/templates/admin/lab-edit.html | 165 ++- .../main/resources/templates/admin/lab.html | 428 ++---- .../main/resources/templates/admin/labs.html | 28 +- .../resources/templates/change_password.html | 12 +- web/src/main/resources/templates/error.html | 4 +- .../resources/templates/fragments/base.html | 46 +- .../resources/templates/learn/article.html | 8 +- .../resources/templates/learn/chapter.html | 31 + .../main/resources/templates/learn/index.html | 4 +- .../resources/templates/learn/section.html | 11 +- .../templates/student/dashboard.html | 2 +- .../main/resources/templates/student/lab.html | 114 +- .../resources/templates/student/labs.html | 2 +- 58 files changed, 3967 insertions(+), 884 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.prod.example create mode 100644 Dockerfile create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/IMPORT.md create mode 100755 deploy/deploy.sh create mode 100644 deploy/nginx/lms.our-place.ru.conf create mode 100644 docker-compose.prod.yml create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublication.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt create mode 100644 services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/session/MustChangePasswordInterceptor.kt create mode 100644 web/src/main/resources/static/css/hljs-github-dark.css create mode 100644 web/src/main/resources/static/css/hljs-github.css create mode 100644 web/src/main/resources/static/js/admin-group.js create mode 100644 web/src/main/resources/static/js/admin-lab.js create mode 100644 web/src/main/resources/static/js/defense-timer.js create mode 100644 web/src/main/resources/static/js/highlight.min.js create mode 100644 web/src/main/resources/templates/learn/chapter.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fda28d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gradle +.kotlin +.idea +build +**/build +logs +data +oldLMS +.env +.env.prod +node_modules +__pycache__ +*.pyc +*.iml +*.log +.gradle/ diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..c9793b7 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,14 @@ +# Production-настройки LMS. Скопируйте в .env.prod и заполните секреты. +# Этот файл НЕ коммитить (см. .gitignore). + +LMS_DB_NAME=lms +LMS_DB_USER=lms +# Обязательно смените на длинный случайный пароль: +LMS_DB_PASSWORD=change-me-to-a-long-random-string + +# Порт web на loopback (его проксирует nginx). +LMS_WEB_PORT=8090 + +# Домен (используется в deploy-скрипте/nginx). +LMS_DOMAIN=lms.our-place.ru +LETSENCRYPT_EMAIL=admin@our-place.ru diff --git a/.gitignore b/.gitignore index afe2eda..e7fa49c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,13 @@ build/ ### LMS ### .env +.env.prod /data/ node_modules/ +# Каталог с боевыми данными и паролями (не коммитить!) +examples/ +__pycache__/ +*.pyc # Reference implementation, kept as a separate nested repository. oldLMS/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ba4ec5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 +# +# Единый multi-stage Dockerfile для всего LMS. +# Сборка идёт внутри образа (Gradle + JDK 21), поэтому на сервере +# достаточно Docker — устанавливать Java не требуется. +# +# Использование (см. docker-compose.prod.yml): +# docker build --target web -t lms-web . +# docker build --target identity -t lms-identity . + +# ---------- Стадия сборки ---------- +FROM eclipse-temurin:21-jdk AS build +WORKDIR /src + +# Сначала копируем файлы сборки — слой кэшируется отдельно от исходников. +COPY gradlew gradlew +COPY gradle gradle +COPY settings.gradle.kts gradle.properties ./ +COPY buildSrc buildSrc +RUN chmod +x gradlew + +# Исходники модулей. +COPY libs libs +COPY services services +COPY web web + +RUN --mount=type=cache,target=/root/.gradle \ + ./gradlew --no-daemon bootJar -x test + +# ---------- Runtime-стадии (только JRE) ---------- +FROM eclipse-temurin:21-jre AS identity +WORKDIR /app +ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx320m" +COPY --from=build /src/services/identity-service/build/libs/identity-service-*.jar app.jar +EXPOSE 8081 +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] + +FROM eclipse-temurin:21-jre AS academic +WORKDIR /app +ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx384m" +COPY --from=build /src/services/academic-service/build/libs/academic-service-*.jar app.jar +EXPOSE 8082 +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] + +FROM eclipse-temurin:21-jre AS progress +WORKDIR /app +ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx384m" +COPY --from=build /src/services/progress-service/build/libs/progress-service-*.jar app.jar +EXPOSE 8083 +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] + +FROM eclipse-temurin:21-jre AS gateway +WORKDIR /app +ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx256m" +COPY --from=build /src/services/gateway/build/libs/gateway-*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] + +FROM eclipse-temurin:21-jre AS web +WORKDIR /app +ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx320m" +COPY --from=build /src/web/build/libs/web-*.jar app.jar +EXPOSE 8090 +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] diff --git a/README.md b/README.md index 6f5dc94..b35d1b7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,33 @@ Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`, progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. +## Запуск в Docker (для сервера) + +На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage +`Dockerfile`, сборка на JDK 21, запуск на JRE 21). + +```bash +cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD +./deploy/deploy.sh up # сборка + запуск (postgres, 5 сервисов) +./deploy/deploy.sh logs web +./deploy/deploy.sh ps +./deploy/deploy.sh down +``` + +Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx +(конфиг и полная инструкция по деплою на `lms.our-place.ru` в +[`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`). + +## Наполнение данными + +Порядок ввода данных (всё через админку): `/setup` → администратор; импорт групп +(`.xls`, логин = студенческий номер, временный пароль со сменой при первом входе); +импорт лабораторной ZIP'ом (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить» +— случайная раздача по кругам в рамках уровня; оценки/защита — на странице группы; +учебные материалы — статьи или ZIP (`chapters/…`, доступны гостям). + +Подробный пошаговый порядок — в [`deploy/IMPORT.md`](deploy/IMPORT.md). + ## Текущий функционал Открыть `http://localhost:8090`. При первом запуске создаётся аккаунт @@ -46,15 +73,24 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. - **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД), проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах, session fixation-защита; вход студента с обязательной сменой пароля. -- **Группы и студенты**: создание groups, импорт `.xls/.xlsx/.csv`, ручное - добавление студента (генерация пароля), выгрузка логинов XLSX/CSV, сброс пароля; - на странице группы — баллы по каждой лабе и средний балл. -- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` c - front-matter) и старого JSON; создание через UI с произвольным числом уровней; - просмотр/редактирование условия (markdown), вариантов, дат; статусы - draft/published (клик по бейджу меняет); даты выводятся в московском времени. -- **Распределение вариантов**: по уровню студента на лабу (по умолчанию — самый - простой), round-robin с уникальностью в группе; ручное назначение варианта. +- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное + добавление студента (генерация пароля) и «Добавить существующего» (перевод из + другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля. +- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` + + `assets/`); создание через UI с произвольным числом уровней; единая форма + настроек с одной кнопкой «Сохранить всё»; статусы draft/published + (клик по бейджу меняет); даты в московском времени. +- **Распределение вариантов** (только на странице лабы): «Распределить» с + выбором групп; раздача случайная и равномерная — студенты выбранных групп + идут общей очередью, вариант берётся со своего уровня, пока в уровне есть + невыданные — без повторов, затем выдаются наименее занятые; ручные + закрепления сохраняются всегда. На странице лабы — список назначений по + группам (уровень/вариант каждому), массовые операции чекбоксами, индикаторы + «выдано/всего». Матрица на странице группы — просмотр (вариант, сдача) + + оценки/защита, управление раздачей оттуда убрано. +- **Индивидуальные задания (защита)**: на странице группы — назначение/изменение + задания, импорт CSV (2 или 3 колонки, с кавычками и `\n`), выдача кода, + «доп. задание: начать заново» после завершения защиты. - **Материалы** (`/learn`, публичная читалка): главы → разделы → статьи в markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP (`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/ @@ -62,9 +98,16 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. - **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка), карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача файлов (zip/.c, переотправка до дедлайна, история, скачивание последней), - комментарий преподавателя к оценке, защита по одноразовому коду (30 мин). -- **Комментарии к оценкам**: тональность (pos/neu/neg), хранятся в БД, - автоподсказки при вводе ранжируются по частоте использования; видны студенту чипсами. + оценка и комментарии отдельным блоком, защита по одноразовому коду (30 мин). +- **Публикация оценок**: в группе по каждой лабе — кнопка «опубликовать»; студент + видит оценку и комментарии только после публикации (публикуется вся группа). +- **Комментарии к оценкам**: тональность (positive/negative/neutral), на странице + студента — ненумерованными списками в трёх колонках, хранятся в БД, + автоподсказки при вводе ранжируются по частоте использования. +- **Архив сдач группы**: одна кнопка «Скачать все работы» — ZIP со всеми + последними решениями группы, разложенными по лабам и студентам. +- **Материалы** `/learn` доступны без входа; аккордеон глав: свёрнуты все, + открыта последняя. - UI: светлая/тёмная тема (сохраняется в localStorage), модальные окна вместо отдельных страниц, аккордеоны глав/разделов в `/learn`, фавиконка, адаптивная вёрстка без inline-стилей. diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..112f345 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,145 @@ +# Деплой LMS на сервер `lms.our-place.ru` + +Стек целиком запускается в Docker. **Java на сервере не устанавливается** — приложение +собирается и работает внутри контейнеров (multi-stage `Dockerfile`: сборка на JDK 21, +запуск на JRE 21). + +Компоненты: + +| Сервис | Контейнер | Наружу | +|---|---|---| +| web (Thymeleaf UI) | `lms-web` | `127.0.0.1:8090` (проксирует nginx) | +| gateway | `lms-gateway` | нет | +| identity / academic / progress | `lms-*` | нет | +| PostgreSQL 18 | `lms-postgres` | нет | + +Данные лежат в именованных volume: `lms_pgdata` (БД), `lms_submissions` (решения), +`lms_assets` (картинки материалов). + +--- + +## 0. Что нужно + +- Сервер с Ubuntu 22.04/24.04 (или другим Linux с Docker), sudo/root. +- Домен `lms.our-place.ru`, A-запись которого указывает на IP сервера. +- Открытые порты 80 и 443 (443 — если настраиваете HTTPS). + +## 1. Установка Docker + +```bash +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker "$USER" # перелогиньтесь, чтобы работать без sudo +docker compose version # нужна версия v2 +``` + +## 2. Получить код + +```bash +sudo mkdir -p /opt/lms && sudo chown "$USER" /opt/lms +git clone /opt/lms +cd /opt/lms +``` + +## 3. Настроить окружение + +```bash +cp .env.prod.example .env.prod +nano .env.prod +``` + +Обязательно задайте надёжный `LMS_DB_PASSWORD`. Остальные значения по умолчанию подходят. + +## 4. Собрать и запустить + +```bash +./deploy/deploy.sh up +``` + +Скрипт соберёт образы (первый раз ~5–15 минут: скачиваются Gradle и зависимости) +и поднимет контейнеры. Проверка: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +curl -fsS http://127.0.0.1:8090/actuator/health # {"status":"UP",...} +``` + +## 5. Nginx + HTTPS + +```bash +sudo apt-get update && sudo apt-get install -y nginx certbot python3-certbot-nginx +sudo mkdir -p /var/www/certbot + +sudo cp deploy/nginx/lms.our-place.ru.conf /etc/nginx/sites-available/lms.our-place.ru.conf +sudo ln -sf /etc/nginx/sites-available/lms.our-place.ru.conf /etc/nginx/sites-enabled/lms.our-place.ru.conf +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d lms.our-place.ru --redirect -m admin@our-place.ru --agree-tos +``` + +`certbot --nginx` сам добавит TLS-блок и редирект с HTTP на HTTPS. Автопродление +уже настроено пакетом (`systemctl status certbot.timer`). + +## 6. Первый запуск приложения + +Откройте `https://lms.our-place.ru` → откроется `/setup` → создайте администратора. + +## 6a. Наполнение данными + +Дальше всё через интерфейс: импорт групп (`.xls`), импорт лабораторной ZIP'ом, +раздача вариантов, оценки и защита, материалы. Подробный порядок с проверками — +в [`IMPORT.md`](IMPORT.md). Файлы со списками студентов и выгрузками паролей — +персональные данные, держите их вне репозитория. + +## 7. Обновление версии + +```bash +cd /opt/lms +git pull +./deploy/deploy.sh up # пересоберёт изменённые образы и перезапустит +``` + +Миграции БД (Flyway) применяются автоматически при старте сервисов. + +## 8. Резервное копирование + +```bash +# БД +docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T postgres \ + pg_dump -U "$LMS_DB_USER" "$LMS_DB_NAME" | gzip > lms-$(date +%F).sql.gz + +# Файлы (решения и материалы) +docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":/backup alpine \ + tar czf /backup/lms-files-$(date +%F).tar.gz -C / sub assets +``` + +Восстановление БД: `gunzip -c backup.sql.gz | docker compose ... exec -T postgres psql -U lms lms`. + +## 9. Диагностика + +```bash +./deploy/deploy.sh ps +./deploy/deploy.sh logs web +./deploy/deploy.sh logs gateway +./deploy/deploy.sh logs identity +``` + +Типичные проблемы: + +- **web отдаёт 502 через nginx** — контейнер `web` не поднялся: смотрите `logs web`. + Часто причина — занят порт 8090 или не задан `LMS_DB_PASSWORD` (compose не стартует). +- **Страница входа открывается, но запросы падают** — проверьте `logs gateway` и + `logs identity`; gateway должен получить JWKS от identity. +- **401 на страницах после входа (Docker)** — у backend-сервисов не задан адрес + JWKS: должно быть `LMS_JWT_JWK_SET_URI=http://identity:8081/api/v1/auth/jwks.json` + (уже прописано в `docker-compose.prod.yml` для academic/progress/gateway). + Внутри контейнера `localhost:8081` недоступен, поэтому токен не проверяется. +- **Не проходят загрузки больших ZIP** — увеличьте `client_max_body_size` в nginx + (сейчас 120 МБ) и `spring.servlet.multipart.max-request-size`. + +## Замечания по безопасности + +- PostgreSQL наружу не публикуется; связь между контейнерами — по внутренней сети `lms`. +- Публикуется только `127.0.0.1:8090`, доступ снаружи — исключительно через nginx/TLS. +- `.env.prod` содержит пароль БД и не коммитится (в `.gitignore`). +- Пароль администратора задаётся вами на шаге 6; хранение — BCrypt. diff --git a/deploy/IMPORT.md b/deploy/IMPORT.md new file mode 100644 index 0000000..e6c85c8 --- /dev/null +++ b/deploy/IMPORT.md @@ -0,0 +1,78 @@ +# Порядок наполнения LMS данными (через интерфейс) + +Всё делается в админке, порядок: администратор → группы и студенты → лабораторная → +раздача вариантов → защита → материалы. + +## Шаг 1. Деплой + +См. [`DEPLOY.md`](DEPLOY.md): Docker + nginx + HTTPS. После старта сервисов +откройте домен — попадёте на `/setup`, создайте администратора (логин и пароль +храните в секрете, в репозиторий их не писать). + +## Шаг 2. Группы и студенты + +**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы +(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»). +Название группы берётся из файла или указывается при импорте. Логином студента +становится студенческий номер, пароль генерируется (временный, с обязательной +сменой при первом входе). + +Дальше по каждому студенту при необходимости: +* **«Сбросить пароль»** — выдать новый временный пароль; +* **«Добавить существующего»** — перевести студента из другой группы; +* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются). + +Выгрузить логины: **«Логины XLSX» / «CSV»**. + +## Шаг 3. Лабораторная + +**Лабораторные → Импорт** — ZIP со структурой: + +```text +lab.yaml # номер, название, points_max, уровни (code/title), сроки, статус +README.md # общее условие (markdown) +variants/01.md # вариант: front-matter (level, number, title) + текст задания +``` + +Примеры — в личном рабочем каталоге (не в репозитории). После импорта на +странице лабораторной: + +* «Настроить» — срок сдачи, дата защиты, максимум баллов, статус; +* уровни/варианты можно править и добавлять прямо там же. + +## Шаг 4. Раздача вариантов + +На странице лабораторной — **«Распределить»**: выбрать группы, при необходимости +«пересоздать заново». Логика: случайная очередь по всем вариантам выбранного +уровня; без повторов, пока в уровне есть невыданные варианты; закрепленные +вручную не трогаются. Частично/точно — в списке назначений (уровень и вариант по +каждому студенту) или массово чекбоксами. + +## Шаг 5. Оценки и защита + +На странице группы (матрица): + +* **«опубликовать оценки»** — показать/скрыть оценки и комментарии студентам; +* **«поставить оценку»** — балл + комментарии (позитивный/негативный/нейтральный); +* раздел «Индивидуальные задания (защита)»: «Назначить задание» или импорт CSV + (`студент;ЛР;задание` или `студент;задание` при выбранной ЛР), «выдать код», + «изменить», «текст», после готовности — «доп. задание: начать заново». + +## Шаг 6. Учебные материалы + +**Лабораторные → материалы (`/learn`)** доступны гостям. Для наполнения: +создание глав/разделов/статей в админке или **импорт ZIP** со структурой: + +```text +chapters/01-глава/01-раздел/01-статья.md # front-matter: title, slug +assets/ # картинки для md +``` + +Материалы видны всем; для редактирования — «Материалы» с правами администратора. + +## Замечания + +* Данные в `*.xls`/PDF/PDF-пароли содержат персональные сведения — храните их во +временном каталоге и не коммитьте в репозиторий. +* Пароли студентов выдаются автоматически; временный пароль нужно менять при +первом входе (до смены другие страницы недоступны). diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..034b566 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Деплой LMS в Docker на сервере. Java на хосте не требуется. +# +# ./deploy/deploy.sh # сборка + запуск/обновление +# ./deploy/deploy.sh logs web # логи сервиса +# ./deploy/deploy.sh ps +# ./deploy/deploy.sh down # остановить (данные сохраняются в volume) +set -euo pipefail + +cd "$(dirname "$0")/.." + +COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env.prod" + +if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + echo "Ошибка: нужен Docker с плагином compose v2." >&2 + exit 1 +fi + +if [[ ! -f .env.prod ]]; then + if [[ -f .env.prod.example ]]; then + cp .env.prod.example .env.prod + echo "Создан .env.prod из примера. ОБЯЗАТЕЛЬНО смените LMS_DB_PASSWORD и повторите запуск." >&2 + exit 1 + fi + echo "Не найден .env.prod" >&2 + exit 1 +fi + +cmd="${1:-up}" + +case "$cmd" in + up) + echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..." + $COMPOSE build + echo ">> Запуск сервисов..." + $COMPOSE up -d --remove-orphans + echo + $COMPOSE ps + echo + echo "Готово. Проверьте: curl -fsS http://127.0.0.1:${LMS_WEB_PORT:-8090}/actuator/health" + echo "Первый запуск: откройте https://<домен>/setup и создайте администратора." + ;; + logs) + $COMPOSE logs -f --tail=200 "${2:-web}" + ;; + ps) + $COMPOSE ps + ;; + down) + $COMPOSE down + ;; + restart) + $COMPOSE restart "${2:-}" + ;; + *) + echo "usage: $0 [up|ps|logs |restart [service]|down]" >&2 + exit 1 + ;; +esac diff --git a/deploy/nginx/lms.our-place.ru.conf b/deploy/nginx/lms.our-place.ru.conf new file mode 100644 index 0000000..6f904c1 --- /dev/null +++ b/deploy/nginx/lms.our-place.ru.conf @@ -0,0 +1,67 @@ +# Nginx для LMS на сервере lms.our-place.ru. +# +# Схема: +# интернет -> nginx (80/443) -> 127.0.0.1:8090 (контейнер web) +# +# Установка (Debian/Ubuntu): +# sudo cp deploy/nginx/lms.our-place.ru.conf /etc/nginx/sites-available/lms.our-place.ru.conf +# sudo ln -sf /etc/nginx/sites-available/lms.our-place.ru.conf /etc/nginx/sites-enabled/lms.our-place.ru.conf +# sudo nginx -t && sudo systemctl reload nginx +# sudo certbot --nginx -d lms.our-place.ru # добавит TLS автоматически +# +# Ниже — HTTP-версия, достаточная и для первичного выпуска сертификата, +# и для работы без TLS. После `certbot --nginx` блок server 443 появится сам. + +server { + listen 80; + listen [::]:80; + server_name lms.our-place.ru; + + # ACME-челлендж Let's Encrypt. + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Большие загрузки: ZIP лаб, решения, материалы. + client_max_body_size 120m; + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # Загрузка файлов может быть долгой. + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_connect_timeout 30s; + } +} + +# --- Если TLS настраивается вручную (без certbot --nginx) --- +# server { +# listen 443 ssl; +# listen [::]:443 ssl; +# http2 on; +# server_name lms.our-place.ru; +# +# ssl_certificate /etc/letsencrypt/live/lms.our-place.ru/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/lms.our-place.ru/privkey.pem; +# ssl_protocols TLSv1.2 TLSv1.3; +# +# client_max_body_size 120m; +# +# location / { +# proxy_pass http://127.0.0.1:8090; +# proxy_http_version 1.1; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_read_timeout 300s; +# proxy_send_timeout 300s; +# } +# } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..cd71d92 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,116 @@ +# Production-стек LMS: PostgreSQL 18 + 5 сервисов. +# Java на сервере не нужна — всё собирается и запускается в контейнерах. +# +# Запуск: +# cp .env.prod.example .env.prod # и заполнить секреты +# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build +# +# Наружу публикуется только web на 127.0.0.1:8090; его проксирует nginx (deploy/nginx). + +name: lms + +x-db-env: &db-env + LMS_DB_USER: ${LMS_DB_USER:-lms} + LMS_DB_PASSWORD: ${LMS_DB_PASSWORD:?Задайте LMS_DB_PASSWORD в .env.prod} + +# Все backend-сервисы проверяют JWT по JWKS identity. В контейнерах это +# http://identity:8081, а не localhost (иначе 401 на авторизованных запросах). +x-jwt-env: &jwt-env + LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json + +services: + postgres: + image: postgres:18 + restart: unless-stopped + environment: + POSTGRES_DB: ${LMS_DB_NAME:-lms} + POSTGRES_USER: ${LMS_DB_USER:-lms} + POSTGRES_PASSWORD: ${LMS_DB_PASSWORD:?Задайте LMS_DB_PASSWORD в .env.prod} + volumes: + - lms_pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${LMS_DB_USER:-lms} -d ${LMS_DB_NAME:-lms}"] + interval: 5s + timeout: 3s + retries: 20 + networks: [lms] + + identity: + build: { context: ., target: identity } + image: lms-identity:latest + restart: unless-stopped + environment: + <<: *db-env + LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity + depends_on: + postgres: { condition: service_healthy } + networks: [lms] + + academic: + build: { context: ., target: academic } + image: lms-academic:latest + restart: unless-stopped + environment: + <<: [*db-env, *jwt-env] + LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=academic + LMS_CONTENT_ASSETS_DIR: /data/content-assets + volumes: + - lms_assets:/data/content-assets + depends_on: + postgres: { condition: service_healthy } + identity: { condition: service_started } + networks: [lms] + + progress: + build: { context: ., target: progress } + image: lms-progress:latest + restart: unless-stopped + environment: + <<: [*db-env, *jwt-env] + LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress + LMS_SUBMISSIONS_DIR: /data/submissions + LMS_IDENTITY_URI: http://identity:8081 + LMS_ACADEMIC_URI: http://academic:8082 + volumes: + - lms_submissions:/data/submissions + depends_on: + postgres: { condition: service_healthy } + identity: { condition: service_started } + academic: { condition: service_started } + networks: [lms] + + gateway: + build: { context: ., target: gateway } + image: lms-gateway:latest + restart: unless-stopped + environment: + LMS_IDENTITY_URI: http://identity:8081 + LMS_ACADEMIC_URI: http://academic:8082 + LMS_PROGRESS_URI: http://progress:8083 + LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json + depends_on: + identity: { condition: service_started } + academic: { condition: service_started } + progress: { condition: service_started } + networks: [lms] + + web: + build: { context: ., target: web } + image: lms-web:latest + restart: unless-stopped + environment: + LMS_GATEWAY_URL: http://gateway:8080 + SERVER_PORT: 8090 + ports: + - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" + depends_on: + gateway: { condition: service_started } + networks: [lms] + +networks: + lms: + +volumes: + lms_pgdata: + lms_submissions: + lms_assets: diff --git a/libs/common-http/build.gradle.kts b/libs/common-http/build.gradle.kts index b686a0b..10e74be 100644 --- a/libs/common-http/build.gradle.kts +++ b/libs/common-http/build.gradle.kts @@ -7,4 +7,6 @@ dependencies { api(libs.springWeb) api(libs.springBootAutoconfigure) + // Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию. + api(libs.jacksonModuleKotlin) } diff --git a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt index ab0464c..9d5cf2b 100644 --- a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt +++ b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt @@ -41,7 +41,6 @@ class MarkdownRenderer( ), ) set(HtmlRenderer.GENERATE_HEADER_ID, true) - set(HtmlRenderer.SOFT_BREAK, "
\n") set(HtmlRenderer.ESCAPE_HTML, !allowHtml) } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabImportParser.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabImportParser.kt index 7c865f1..9b96d7f 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabImportParser.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabImportParser.kt @@ -1,6 +1,5 @@ package ru.ysu.lms.academic.lab -import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.dataformat.yaml.YAMLFactory @@ -8,43 +7,25 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule import org.springframework.stereotype.Component import ru.ysu.lms.common.BadRequestException import java.io.ByteArrayInputStream -import java.math.BigDecimal import java.util.zip.ZipInputStream +/** + * Импорт лабораторной из ZIP: `lab.yaml` (метаданные и уровни), `README.md` + * (условие), `variants/` (файлы вариантов с front-matter: level, number, title), + * каталог `assets/` (картинки). + */ @Component class LabImportParser { private val yaml = ObjectMapper(YAMLFactory()) .registerModule(KotlinModule.Builder().build()) .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) .configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - private val json = ObjectMapper() fun parse(filename: String, bytes: ByteArray): LabImportBundle { - val lower = filename.lowercase() - if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) { - throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json") + if (!filename.lowercase().endsWith(".zip")) { + throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)") } - return if (lower.endsWith(".json")) { - parseJson(bytes) - } else { - parseZip(bytes) - } - } - - private fun parseJson(bytes: ByteArray): LabImportBundle { - val tree = json.readTree(bytes) - val meta = LabMeta( - number = tree.path("number").asInt(), - title = tree.path("title").asText(), - pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)), - deadlineAt = tree.path("deadline").asText("").ifBlank { null }, - defenseAt = tree.path("defense_at").asText("").ifBlank { null }, - status = tree.path("status").asText("published"), - levels = parseLevels(tree), - ) - val descriptionMd = tree.path("common_requirements").asText("") - val variants = parseVariantsFromTree(tree, meta.levels) - return LabImportBundle(meta, descriptionMd, variants) + return parseZip(bytes) } private fun parseZip(bytes: ByteArray): LabImportBundle { @@ -90,42 +71,6 @@ class LabImportParser { return LabImportBundle(meta, readmeMd, variants, assets) } - private fun parseLevels(tree: JsonNode): List { - val levels = tree.path("levels") - if (levels.isArray && levels.size() > 0) { - return levels.map { n -> - LabLevelMeta( - code = n.path("code").asText(), - title = n.path("title").asText(""), - description = n.path("description").asText(""), - position = n.path("position").asInt(0), - ) - } - } - return listOf( - LabLevelMeta("basic", "Основная группа", position = 1), - LabLevelMeta("advanced", "Повышенная сложность", position = 2), - ) - } - - private fun parseVariantsFromTree(tree: JsonNode, levels: List): List { - val variants = tree.path("variants") - if (!variants.isArray) return emptyList() - val levelMap = levels.associateBy { it.code } - return variants.map { v -> - val levelCode = normalizeLevel(v.path("level").asText("basic")) - if (!levelMap.containsKey(levelCode)) { - throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}") - } - VariantMeta( - level = levelCode, - number = v.path("number").asText(), - title = v.path("title").asText(""), - statement = v.path("statement").asText(""), - ) - } - } - private fun parseVariantFile(path: String, content: String, levels: List): VariantMeta? { val levelMap = levels.associateBy { it.code } val lines = content.lines() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index 8a63766..a4c4de3 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -35,6 +35,12 @@ class LabService( val semester = semesters.findFirstByActiveTrue() ?: throw BadRequestException("Нет активного семестра. Создайте семестр.") + if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) { + throw ConflictException( + "Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.", + ) + } + val lab = labs.save(Lab( number = bundle.meta.number, title = bundle.meta.title, @@ -42,6 +48,8 @@ class LabService( descriptionMd = bundle.descriptionMd, pointsMax = bundle.meta.pointsMax, status = bundle.meta.status, + deadlineAt = parseInstant(bundle.meta.deadlineAt), + defenseAt = parseInstant(bundle.meta.defenseAt), )) val levelMetas = bundle.meta.levels.ifEmpty { @@ -115,8 +123,10 @@ class LabService( defenseAt = lab.defenseAt?.toString(), pointsMax = lab.pointsMax, levels = levels.map { level -> + // Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой). val vs = variantsRepo.findByLabIdOrderByNumber(lab.id) .filter { it.labLevel?.id == level.id } + .sortedWith(compareBy({ naturalNumber(it.number) }, { it.number })) LabLevelDetail( id = level.id.toString(), code = level.code, @@ -287,6 +297,9 @@ class LabService( return semesters.save(Semester(title = t, year = y, active = true)) } + private fun naturalNumber(value: String): Int = + Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE + private fun parseInstant(value: String?): Instant? { val v = value?.takeIf { it.isNotBlank() } ?: return null return runCatching { Instant.parse(v) } diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index ac04148..748e1db 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: - id: progress uri: ${LMS_PROGRESS_URI:http://localhost:8083} predicates: - - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/** + - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/submissions/** management: endpoints: diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt index f09f433..8bda039 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt @@ -14,6 +14,7 @@ interface EnrollmentRepository : JpaRepository { fun findAllByGroupId(@Param("groupId") groupId: UUID): List fun findByStudent_AccountId(accountId: UUID): Enrollment? + fun findFirstByStudent_AccountId(accountId: UUID): Enrollment? fun deleteByStudent_AccountId(accountId: UUID) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt index 8c00d76..f1021ea 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt @@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -16,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile import ru.ysu.lms.common.BadRequestException import java.net.URLEncoder import java.nio.charset.StandardCharsets +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt import java.util.UUID @RestController @@ -46,6 +49,25 @@ class GroupController(private val groupService: GroupService) { return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes) } + /** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */ + @GetMapping("/{id}/candidates") + fun candidates(@PathVariable id: UUID): List = groupService.candidates(id) + + /** Зачислить существующего студента. */ + @PostMapping("/{id}/students/{studentId}/enroll") + fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail = + groupService.enroll(id, studentId) + + /** Отчислить студента из группы. */ + @DeleteMapping("/{id}/students/{studentId}") + fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail = + groupService.unenroll(id, studentId) + + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + fun myGroup(@AuthenticationPrincipal jwt: Jwt): List = + groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList() + @GetMapping("/{id}") fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index 44632ee..9a47d3e 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -49,6 +49,7 @@ class GroupService( tempPassword = password, ), ) + // Student.id — это account_id через @MapsId: ассоциацию задаём до persist. val student = students.save( Student( studentNo = studentNo, @@ -57,9 +58,9 @@ class GroupService( middleName = request.middleName.trim(), basis = request.basis.trim(), note = request.note.trim(), + account = account, ), ) - student.account = account enrollments.save(Enrollment(group = group, student = student)) return Credential( id = account.id.toString(), @@ -78,6 +79,61 @@ class GroupService( return summary(groups.save(GroupEntity(name = name))) } + /** Студенты, которых ещё нет в этой группе (включая состоящих в других). */ + @Transactional(readOnly = true) + fun candidates(groupId: UUID): List { + groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } + val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet() + val currentByStudent = HashMap>() + for (e in enrollments.findAll()) { + val sid = e.student?.accountId ?: continue + val gid = e.group?.id ?: continue + val name = e.group?.name ?: continue + currentByStudent[sid] = gid to name + } + return students.findAll() + .filter { it.accountId !in memberIds } + .map { s -> + val current = s.accountId?.let { currentByStudent[it] } + StudentCandidate( + id = s.accountId!!.toString(), + fullName = s.fullName, + studentNo = s.studentNo, + currentGroupId = current?.first?.toString(), + currentGroupName = current?.second, + ) + } + .sortedWith(compareBy({ it.fullName }, { it.studentNo })) + } + + /** Зачислить существующего студента; если он в другой группе — переводим. */ + @Transactional + fun enroll(groupId: UUID, studentId: UUID): GroupDetail { + val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } + val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") } + val existing = enrollments.findByStudent_AccountId(studentId) + if (existing != null) { + if (existing.group?.id == group.id) { + throw ConflictException("Студент уже в этой группе") + } + // unique-констрейт на student_id: переводим перепривязкой, без delete+insert + existing.group = group + enrollments.save(existing) + } else { + enrollments.save(Enrollment(group = group, student = student)) + } + return detail(groupId) + } + + /** Отчислить из группы (студент и его аккаунт сохраняются). */ + @Transactional + fun unenroll(groupId: UUID, studentId: UUID): GroupDetail { + val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId } + ?: throw NotFoundException("Студент не входит в эту группу") + enrollments.delete(enrollment) + return detail(groupId) + } + @Transactional fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult { val parsed = parser.parse(filename, bytes) @@ -141,6 +197,17 @@ class GroupService( return ImportResult(group.id.toString(), group.name, added, updated) } + @Transactional(readOnly = true) + fun groupOfStudent(accountId: UUID): GroupSummary? { + val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null + return GroupSummary( + id = group.id.toString(), + name = group.name, + studentCount = enrollments.findAllByGroupId(group.id).size.toLong(), + taskDurationMin = group.taskDurationMin, + ) + } + @Transactional(readOnly = true) fun detail(groupId: UUID): GroupDetail { val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt index c682854..f153f63 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/RosterDtos.kt @@ -59,6 +59,14 @@ data class ResetPasswordResponse( val password: String, ) +data class StudentCandidate( + val id: String, + val fullName: String, + val studentNo: String, + val currentGroupId: String?, + val currentGroupName: String?, +) + class ExportFile( val bytes: ByteArray, val filename: String, diff --git a/services/identity-service/src/main/resources/application.yml b/services/identity-service/src/main/resources/application.yml index 58a934b..b3b5243 100644 --- a/services/identity-service/src/main/resources/application.yml +++ b/services/identity-service/src/main/resources/application.yml @@ -35,3 +35,8 @@ management: springdoc: api-docs: path: /v3/api-docs + +lms: + jwt: + # Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml). + access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index ffc8537..234906f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -17,9 +17,13 @@ data class LevelAssignmentResponse( ) data class DistributeRequest( - val labId: String, - /** If true, re-assigns even existing manual assignments */ - val force: Boolean = false, + val labId: String? = null, + /** Можно передать несколько лабораторных за один вызов. */ + val labIds: List? = null, + /** Пересоздать варианты заново, сбросив текущие назначения. */ + val force: Boolean? = null, + /** Ограничить группы; пусто — все группы. */ + val groupIds: List? = null, ) data class VariantAssignmentResponse( @@ -31,10 +35,11 @@ data class VariantAssignmentResponse( ) data class DistributeResult( - val labId: String, - val labTitle: String, + val labs: Int = 1, val assigned: Int, val total: Int, + val labId: String? = null, + val labTitle: String? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt new file mode 100644 index 0000000..7771e73 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradePublicationService.kt @@ -0,0 +1,31 @@ +package ru.ysu.lms.progress + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.progress.domain.GradePublication +import ru.ysu.lms.progress.domain.GradePublicationKey +import ru.ysu.lms.progress.repo.GradePublicationRepository +import java.util.UUID + +@Service +class GradePublicationService(private val repo: GradePublicationRepository) { + + @Transactional + fun setPublished(groupId: UUID, labId: UUID, published: Boolean) { + val existing = repo.findByLabIdAndGroupId(labId, groupId) + if (published && existing == null) repo.save(GradePublication(groupId, labId)) + if (!published && existing != null) repo.delete(existing) + } + + @Transactional(readOnly = true) + fun isPublished(groupId: UUID, labId: UUID): Boolean = + repo.findByLabIdAndGroupId(labId, groupId) != null + + @Transactional(readOnly = true) + fun publishedGroupIdsForLab(labId: UUID): List = + repo.findByLabId(labId).map { it.groupId.toString() } + + @Transactional(readOnly = true) + fun publishedLabIdsForGroup(groupId: UUID): List = + repo.findByGroupId(groupId).map { it.labId.toString() } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index 22b07a1..d71615c 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -17,6 +17,7 @@ import java.util.UUID @RequestMapping("/api/v1") class ProgressController( private val service: ProgressService, + private val publications: GradePublicationService, private val submissions: SubmissionService, private val academic: AcademicClient, ) { @@ -36,8 +37,10 @@ class ProgressController( @PostMapping("/levels") @PreAuthorize("hasAuthority('ROLE_ADMIN')") - fun setLevel(@Valid @RequestBody body: LevelAssignmentRequest): LevelAssignmentResponse = - service.setLevel(body) + fun setLevel( + @Valid @RequestBody body: LevelAssignmentRequest, + @AuthenticationPrincipal jwt: Jwt, + ): LevelAssignmentResponse = service.setLevel(body, jwt.tokenValue) @GetMapping("/levels") @PreAuthorize("hasAuthority('ROLE_ADMIN')") @@ -80,9 +83,31 @@ class ProgressController( @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun listGrades(): List = service.listGrades() + @PostMapping("/grades/publish") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) { + publications.setPublished(groupId, labId, published) + } + + @GetMapping("/grades/published") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean = + publications.isPublished(groupId, labId) + + @GetMapping("/grades/publications") + @PreAuthorize("isAuthenticated()") + fun groupPublications(@RequestParam groupId: java.util.UUID): List = + publications.publishedLabIdsForGroup(groupId) + @GetMapping("/me/grades") - fun myGrades(@AuthenticationPrincipal jwt: Jwt): List = - service.listGrades().filter { it.studentId == jwt.subject } + fun myGrades( + @AuthenticationPrincipal jwt: Jwt, + @RequestParam(required = false) groupId: java.util.UUID?, + ): List { + val all = service.listGrades().filter { it.studentId == jwt.subject } + if (groupId == null) return emptyList() + return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) } + } @GetMapping("/comment-templates") @PreAuthorize("hasAuthority('ROLE_ADMIN')") diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt index dc9ce98..493df19 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -30,19 +30,39 @@ class ProgressService( ) { @Transactional - fun setLevel(request: LevelAssignmentRequest): LevelAssignmentResponse { - val key = StudentLabLevelKey( - UUID.fromString(request.studentId), - UUID.fromString(request.labId), - ) + fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse { + val sid = UUID.fromString(request.studentId) + val lid = UUID.fromString(request.labId) + val key = StudentLabLevelKey(sid, lid) val existing = levels.findById(key) - val entity = existing.orElse(StudentLabLevel( - studentId = UUID.fromString(request.studentId), - labId = UUID.fromString(request.labId), - )) + val entity = existing.orElse(StudentLabLevel(studentId = sid, labId = lid)) entity.labLevelId = UUID.fromString(request.labLevelId) entity.manual = true levels.save(entity) + + // Сразу подбираем вариант из выбранного уровня: админ видит результат, + // а не «пустой» вариант до нажатия «Распределить». + val lab = runCatching { academic.getLab(request.labId, token) }.getOrNull() + val level = lab?.levels?.firstOrNull { it.id == request.labLevelId } + if (level != null && level.variants.isNotEmpty()) { + val assignment = assignments.findByStudentIdAndLabId(sid, lid) + val currentInLevel = assignment?.variantId?.toString() + ?.let { vid -> level.variants.any { it.id == vid } } == true + if (!currentInLevel) { + val used = assignments.findByLabId(lid) + .map { it.variantId.toString() } + .filter { it != assignment?.variantId?.toString() } + .toMutableSet() + val pool = level.variants.map { it.id } + val chosen = pool.filter { it !in used }.shuffled().firstOrNull() + ?: pool.shuffled().first() + val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply { + variantId = UUID.fromString(chosen) + manual = false + } + assignments.save(target) + } + } return LevelAssignmentResponse( studentId = request.studentId, labId = request.labId, @@ -61,73 +81,95 @@ class ProgressService( @Transactional fun distribute(request: DistributeRequest, token: String): DistributeResult { - val labId = UUID.fromString(request.labId) - val lab = academic.getLab(request.labId, token) - val levelMap = lab.levels.associateBy { it.id } + val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная") - if (request.force) { - assignments.deleteByLabId(labId) - } - - val groups = identity.listGroups(token) - val students = mutableListOf>() // (studentId, labLevelId) - for (g in groups) { + val force = request.force == true + val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet() + val studentIds = mutableListOf() + for (g in identity.listGroups(token)) { val gid = g["id"] as String - val group = identity.getGroup(gid, token) - for (s in group.students) { - val sid = UUID.fromString(s.id) - val existingLevel = levels.findByStudentIdAndLabId(sid, labId) - val levelId = if (existingLevel != null) { - existingLevel.labLevelId - } else { - val levelInfo = lab.levels.firstOrNull() ?: continue - UUID.fromString(levelInfo.id) + if (groupFilter.isNotEmpty() && gid !in groupFilter) continue + for (s in identity.getGroup(gid, token).students) { + studentIds.add(UUID.fromString(s.id)) + } + } + val allStudents = studentIds.distinct() + + var assignedTotal = 0 + var totalStudents = 0 + var lastLabId: String? = null + var lastLabTitle: String? = null + + for (labIdStr in labIds) { + val labId = UUID.fromString(labIdStr) + val lab = academic.getLab(labIdStr, token) + + // force сбрасывает авто-назначения только у выбранных студентов; + // закреплённые вручную варианты остаются как есть. + if (force) { + assignments.findByLabId(labId) + .filter { it.studentId in allStudents && !it.manual } + .forEach { assignments.delete(it) } + } + + val kept = mutableMapOf() + assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents } + assignments.findByLabId(labId) + .filter { it.studentId in allStudents } + .forEach { kept[it.studentId] = it } + + val toAssign = allStudents.filterNot { kept.containsKey(it) } + + // Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня. + // При этом очередь одна общая для всех выбранных групп: круг не + // «перезапускается» на каждой группе, а продолжается по наименее + // занятым вариантам уровня (пока хоть один не выдан — повторов нет). + val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } } + val defaultLevel = lab.levels.firstOrNull()?.id + + if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) { + val usage = assignments.findByLabId(labId) + .groupingBy { it.variantId.toString() } + .eachCount() + .toMutableMap() + + for (sid in toAssign.shuffled()) { + val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() + ?: defaultLevel + val pool = levelId?.let { poolsByLevel[it] }.orEmpty() + .ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() } + .ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() } + if (pool.isEmpty()) continue + val minCount = pool.minOf { usage[it] ?: 0 } + val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random() + assignments.save( + VariantAssignment( + studentId = sid, + labId = labId, + variantId = UUID.fromString(chosen), + manual = false, + ), + ) + usage[chosen] = (usage[chosen] ?: 0) + 1 + assignedTotal++ } - students.add(sid to levelId) - } - } - - val assigned = mutableSetOf() - val variantIdsByLevel = lab.levels.associate { lv -> - lv.id to lv.variants.map { it.id } - } - - val result = mutableListOf() - for ((sid, levelId) in students) { - val existing = assignments.findByStudentIdAndLabId(sid, labId) - if (existing != null && existing.manual) { - result.add(existing) - assigned.add(existing.variantId.toString()) - continue - } - if (existing != null && !request.force) { - result.add(existing) - assigned.add(existing.variantId.toString()) - continue } - val pool = variantIdsByLevel[levelId.toString()].orEmpty() - if (pool.isEmpty()) continue - - val chosen = pool.firstOrNull { it !in assigned } - ?: pool.first() - val entity = VariantAssignment( - studentId = sid, - labId = labId, - variantId = UUID.fromString(chosen), - manual = false, - ) - assignments.save(entity) - result.add(entity) - assigned.add(chosen) + totalStudents += allStudents.size + lastLabId = labIdStr + lastLabTitle = lab.title } - val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title return DistributeResult( - labId = request.labId, - labTitle = title, - assigned = result.size, - total = students.size, + labs = labIds.size, + assigned = assignedTotal, + total = totalStudents, + labId = if (labIds.size == 1) lastLabId else null, + labTitle = if (labIds.size == 1) lastLabTitle else null, ) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublication.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublication.kt new file mode 100644 index 0000000..0d5b807 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/GradePublication.kt @@ -0,0 +1,24 @@ +package ru.ysu.lms.progress.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.IdClass +import jakarta.persistence.Table +import java.io.Serializable +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "grade_publications") +@IdClass(GradePublicationKey::class) +class GradePublication( + @Id @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0), + @Id @Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0), + @Column(name = "published_at", nullable = false) var publishedAt: Instant = Instant.now(), +) + +data class GradePublicationKey( + val groupId: UUID = UUID(0, 0), + val labId: UUID = UUID(0, 0), +) : Serializable diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt new file mode 100644 index 0000000..04c49c4 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/GradePublicationRepository.kt @@ -0,0 +1,12 @@ +package ru.ysu.lms.progress.repo + +import org.springframework.data.jpa.repository.JpaRepository +import ru.ysu.lms.progress.domain.GradePublication +import ru.ysu.lms.progress.domain.GradePublicationKey +import java.util.UUID + +interface GradePublicationRepository : JpaRepository { + fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication? + fun findByLabId(labId: UUID): List + fun findByGroupId(groupId: UUID): List +} diff --git a/services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql b/services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql new file mode 100644 index 0000000..25401ef --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V7__grade_publications.sql @@ -0,0 +1,8 @@ +-- Grade publication flags: a teacher publishes grades for a group per lab. + +CREATE TABLE grade_publications ( + group_id uuid NOT NULL, + lab_id uuid NOT NULL, + published_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, lab_id) +); diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt index a436be1..396d4db 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt @@ -117,30 +117,65 @@ class AdminController( ): String { val t = token(session) val group = api.groupDetail(t, id) - val labs = api.listLabs(t) + val labs = api.listLabs(t).filter { it.status == "published" } val grades = api.listGrades(t) val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } - val studentGrades = group.students.map { s -> - val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] } - val nums = scores.mapNotNull { it?.toBigDecimalOrNull() } - AdminStudentGrades( + val publishedLabs = java.util.HashSet(api.groupPublications(t, id)) + val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } } + val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } } + val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } } + + val rows = group.students.map { s -> + val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() } + val cells = labs.map { lab -> + val va = assignmentsBy.getValue(lab)[s.id] + val sub = submissionsBy.getValue(lab)[s.id] + val def = defensesBy.getValue(lab)[s.id] + GroupLabCell( + labId = lab.id, labNumber = lab.number, labTitle = lab.title, + variantNumber = va?.variantNumber, variantManual = va?.manual == true, + score = gradeBy["${s.id}|${lab.id}"], + published = publishedLabs.contains(lab.id), + submissionSeq = sub?.seq, submissionId = sub?.id, + defenseStatus = def?.status, defenseCode = def?.code, + defenseSecondsLeft = def?.secondsLeft, + defenseAssigned = def?.assignmentMd?.isNotBlank() == true, + ) + } + GroupStudentRow( studentId = s.id, fullName = s.fullName, studentNo = s.studentNo, - scores = scores, - average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(), - comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }, - commentsJson = mapper.writeValueAsString(labs.map { lab -> - (commentsBy["${"$"}{s.id}|${"$"}{lab.id}"] ?: emptyList()).map { (t, tone) -> mapOf("text" to t, "tone" to tone) } - }), - + basis = s.basis, + mustChangePassword = s.mustChangePassword, + cells = cells, + variantsSummary = cells.filter { it.variantNumber != null } + .joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" }, + commentsJson = mapper.writeValueAsString(comments.map { cs -> cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } }), ) } + + val defenseRows = labs.flatMap { lab -> + val byStudent = api.labDefenses(t, lab.id).associateBy { it.studentId } + group.students.mapNotNull { s -> + val d = byStudent[s.id] ?: return@mapNotNull null + GroupDefenseRow( + sessionId = d.id, + studentId = s.id, fullName = s.fullName, studentNo = s.studentNo, + labId = lab.id, labNumber = lab.number, + status = d.status, code = d.code?.takeIf { d.status == "waiting" }, + assignmentMd = d.assignmentMd, secondsLeft = d.secondsLeft, + ) + } + } + model.addAttribute("group", group) model.addAttribute("labs", labs) - model.addAttribute("studentGrades", studentGrades) - model.addAttribute("commentOptions", api.suggestComments(t, "")) + model.addAttribute("candidates", runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())) + model.addAttribute("studentGrades", rows) + model.addAttribute("defenseRows", defenseRows) + model.addAttribute("publishedLabIds", publishedLabs) return "admin/group" } @@ -259,56 +294,57 @@ class AdminController( ): String { val t = token(session) val lab = api.labDetail(t, id) - val levelAssignments = api.getLevels(t, id) - val variantAssignments = api.getLabAssignments(t, id) val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } - val defensesByStudent = api.labDefenses(t, id).associateBy { it.studentId } + val assignments = api.getLabAssignments(t, id) + val assignedCount = assignments.size + val levelByStudent = api.getLevels(t, id).associateBy { it.studentId } + val assignmentByStudent = assignments.associateBy { it.studentId } + val variantUsage = java.util.HashMap() + assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } + val allVariants = lab.levels.flatMap { it.variants } + val totalVariants = allVariants.size + val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } + val defaultLevel = lab.levels.firstOrNull() - // Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом - val groupSections = api.listGroups(t).map { group -> - val detail = api.groupDetail(t, group.id) + val assignmentGroups = api.listGroups(t).map { g -> + val detail = api.groupDetail(t, g.id) AdminLabGroup( - groupName = group.name, + groupName = g.name, students = detail.students.map { s -> - val level = levelAssignments.firstOrNull { it.studentId == s.id } - val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty() - val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—" - val va = variantAssignments.firstOrNull { it.studentId == s.id } - val sub = submissionsByStudent[s.id] + val levelState = levelByStudent[s.id] + val level = lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: defaultLevel + val va = assignmentByStudent[s.id] AdminLabStudent( studentId = s.id, fullName = s.fullName, studentNo = s.studentNo, - levelId = levelId, - levelTitle = levelTitle, - levelChanged = level != null, + levelId = level?.id ?: "", + levelTitle = level?.title ?: "—", + levelChanged = levelState?.manual == true, variantId = va?.variantId, variantNumber = va?.variantNumber, - source = when { - va == null -> "не распределён" - va.manual -> "вручную" - else -> "авто" - }, - submissionId = sub?.id, - submissionSeq = sub?.seq, - defenseSessionId = defensesByStudent[s.id]?.id, - defenseStatus = defensesByStudent[s.id]?.status, - defenseCode = defensesByStudent[s.id]?.takeIf { it.status == "waiting" }?.code, - defenseSecondsLeft = defensesByStudent[s.id]?.secondsLeft, - defenseAssigned = defensesByStudent[s.id]?.assignmentMd?.isNotBlank() == true, - adminAssignmentMd = defensesByStudent[s.id]?.assignmentMd ?: "", + source = if (va?.manual == true) "вручную" else "авто", + submissionId = submissionsByStudent[s.id]?.id, + submissionSeq = submissionsByStudent[s.id]?.seq, ) }, ) } + model.addAttribute("lab", lab) - model.addAttribute("groups", groupSections) model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) model.addAttribute("deadlineMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.deadlineAt)) model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt)) model.addAttribute("deadlineLocal", ru.ysu.lms.web.DateUtils.localInput(lab.deadlineAt)) model.addAttribute("defenseLocal", ru.ysu.lms.web.DateUtils.localInput(lab.defenseAt)) - model.addAttribute("defensesByStudent", defensesByStudent) + model.addAttribute("assignedCount", assignedCount) + model.addAttribute("submissionCount", submissionsByStudent.size) + model.addAttribute("assignmentGroups", assignmentGroups) + model.addAttribute("groups", api.listGroups(t)) + model.addAttribute("variantUsage", variantUsage) + model.addAttribute("totalVariants", totalVariants) + model.addAttribute("occupiedVariants", occupiedVariants) + model.addAttribute("freeVariants", totalVariants - occupiedVariants) return "admin/lab" } @@ -317,82 +353,395 @@ class AdminController( @PathVariable labId: String, @RequestParam studentId: String, @RequestParam assignmentMd: String, + @RequestParam(required = false) groupId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.assignDefense(token(session), studentId, labId, assignmentMd) redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") - "redirect:/admin/labs/$labId" + redirectToGroup(groupId, labId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" + redirectToGroup(groupId, labId) + } + + /** Назначение/правка индивидуального задания со страницы группы. */ + @PostMapping("/defenses/save") + fun saveDefense( + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam assignmentMd: String, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.assignDefense(token(session), studentId, labId, assignmentMd) + redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) } @PostMapping("/labs/{labId}/defense/import") fun importDefenses( @PathVariable labId: String, @RequestParam("file") file: MultipartFile, + @RequestParam(required = false) groupId: String?, session: HttpSession, redirect: RedirectAttributes, - ): String { - val t = token(session) - return try { - val map = mutableMapOf() - for (g in api.listGroups(t)) { - api.groupDetail(t, g.id).students.forEach { map[it.studentNo] = it.id } - } - val text = String(file.bytes, Charsets.UTF_8).removePrefix("\uFEFF") - var count = 0 - for (raw in text.lineSequence()) { - val line = raw.trim() - if (line.isBlank()) continue - if (line.contains("студен", ignoreCase = true) && line.contains(";")) continue - val pair = line.split(";", limit = 2) - if (pair.size < 2) continue - val sid = map[pair[0].trim()] ?: continue - val md = pair[1].trim().replace("\\n", "\n") - if (md.isEmpty()) continue - api.assignDefense(t, sid, labId, md) - count++ - } - redirect.addFlashAttribute("notice", "Импортировано заданий: $count") - "redirect:/admin/labs/$labId" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" + ): String = importDefenses(token(session), labId, file, groupId, redirect) + + /** Импорт CSV со страницы группы: лабораторная выбирается в форме или берётся из строки. */ + @PostMapping("/defenses/import") + fun importDefensesGeneric( + @RequestParam(required = false) labId: String?, + @RequestParam("file") file: MultipartFile, + @RequestParam(required = false) groupId: String?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = importDefenses(token(session), labId, file, groupId, redirect) + + private fun importDefenses( + t: String, + labId: String?, + file: MultipartFile, + groupId: String?, + redirect: RedirectAttributes, + ): String = try { + val studentByNo = mutableMapOf() + for (g in api.listGroups(t)) { + api.groupDetail(t, g.id).students.forEach { studentByNo[it.studentNo] = it.id } } + val labByNumber = api.listLabs(t).associateBy { it.number.toString() } + val rows = parseCsv(file.bytes) + var count = 0 + var skipped = 0 + for ((idx, rawCols) in rows.withIndex()) { + val cells = rawCols.map { it.trim() } + if (cells.all { it.isBlank() }) continue + if (idx == 0 && looksLikeHeader(cells)) continue + var sid: String? = null + var lid: String? = labId?.takeIf { it.isNotBlank() } + var md: String? = null + when { + cells.size >= 3 -> { + sid = studentByNo[cells[0]] + lid = labByNumber[cells[1]]?.id ?: lid + md = cells.drop(2).joinToString("; ") + } + cells.size == 2 -> { + sid = studentByNo[cells[0]] + md = cells[1] + } + } + if (sid == null || lid.isNullOrBlank() || md.isNullOrBlank()) { + skipped++ + continue + } + api.assignDefense(t, sid, lid, md.replace("\\n", "\n")) + count++ + } + val tail = if (skipped > 0) ", пропущено: $skipped" else "" + redirect.addFlashAttribute("notice", "Импортировано заданий: $count$tail") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + private fun looksLikeHeader(cells: List): Boolean { + val joined = cells.joinToString(" ").lowercase() + return joined.contains("студент") || joined.contains("student") || + joined.contains("фио") || joined.contains("задан") || joined.contains("assignment") + } + + /** Простой CSV: разделитель ; , или таб; поддержка кавычек и экранирования "" */ + private fun parseCsv(bytes: ByteArray): List> { + val text = String(bytes, Charsets.UTF_8).removePrefix("\uFEFF") + val lines = text.lines().filter { it.isNotBlank() } + val sample = lines.firstOrNull() ?: return emptyList() + val delimiter = when { + sample.contains('\t') -> '\t' + sample.contains(';') -> ';' + else -> ',' + } + return lines.map { splitCsvLine(it, delimiter) } + } + + private fun splitCsvLine(line: String, delimiter: Char): List { + val out = mutableListOf() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val c = line[i] + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length && line[i + 1] == '"') { + sb.append('"') + i++ + } else { + inQuotes = false + } + } else { + sb.append(c) + } + } else { + when (c) { + '"' -> inQuotes = true + delimiter -> { + out.add(sb.toString()) + sb.setLength(0) + } + else -> sb.append(c) + } + } + i++ + } + out.add(sb.toString()) + return out } @PostMapping("/defenses/{id}/issue-code") fun issueDefenseCode( @PathVariable id: String, @RequestParam labId: String, + @RequestParam(required = false) groupId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { val code = api.issueDefenseCode(token(session), id) redirect.addFlashAttribute("notice", "Код для входа на защиту: $code") - "redirect:/admin/labs/$labId" + redirectToGroup(groupId, labId) } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$labId" + redirectToGroup(groupId, labId) } @PostMapping("/defenses/{id}/reset") fun resetDefenseSession( @PathVariable id: String, @RequestParam labId: String, + @RequestParam(required = false) groupId: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.resetDefense(token(session), id) redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода") + redirectToGroup(groupId, labId) + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + redirectToGroup(groupId, labId) + } + + private fun redirectToGroup(groupId: String?, labId: String?): String = when { + !groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId" + !labId.isNullOrBlank() -> "redirect:/admin/labs/$labId" + else -> "redirect:/admin/groups" + } + + @PostMapping("/groups/{gid}/set-level") + fun setStudentLevel( + @PathVariable gid: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId)) + redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + @PostMapping("/groups/{gid}/pin-variant") + fun pinStudentVariant( + @PathVariable gid: String, + @RequestParam studentId: String, + @RequestParam labId: String, + @RequestParam variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId)) + redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + @PostMapping("/groups/{gid}/distribute") + fun distributeForGroup( + @PathVariable gid: String, + @RequestParam labId: String, + @RequestParam(defaultValue = "false") force: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val result = api.distribute( + token(session), + DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)), + ) + redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + /** Раздать варианты по всем опубликованным ЛР для одной группы. */ + @PostMapping("/groups/{gid}/distribute-all") + fun distributeAllForGroup( + @PathVariable gid: String, + @RequestParam(defaultValue = "false") force: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val t = token(session) + val labs = api.listLabs(t).filter { it.status == "published" } + if (labs.isEmpty()) { + redirect.addFlashAttribute("error", "Нет опубликованных лабораторных") + return "redirect:/admin/groups/$gid" + } + val result = api.distribute( + t, + DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)), + ) + redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}") + "redirect:/admin/groups/$gid" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$gid" + } + + /** Массовая установка уровня выделенным студентам. */ + @PostMapping("/labs/{labId}/batch-level") + fun batchLevel( + @PathVariable labId: String, + @RequestParam("studentIds") studentIds: List, + @RequestParam labLevelId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val t = token(session) + studentIds.filter { it.isNotBlank() }.forEach { sid -> + api.setLevel(t, LevelAssignPayload(sid, labId, labLevelId)) + } + redirect.addFlashAttribute("notice", "Уровень обновлён у ${studentIds.size} студент(ов)") "redirect:/admin/labs/$labId" } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/labs/$labId" } + /** Массовое закрепление конкретного варианта выделенным студентам. */ + @PostMapping("/labs/{labId}/batch-variant") + fun batchVariant( + @PathVariable labId: String, + @RequestParam("studentIds") studentIds: List, + @RequestParam variantId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val t = token(session) + studentIds.filter { it.isNotBlank() }.forEach { sid -> + api.assignVariant(t, AssignVariantPayload(sid, labId, variantId)) + } + redirect.addFlashAttribute("notice", "Вариант закреплён у ${studentIds.size} студент(ов)") + "redirect:/admin/labs/$labId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$labId" + } + + @PostMapping("/labs/{id}/distribute") + fun distribute( + @PathVariable id: String, + @RequestParam(defaultValue = "false") force: Boolean, + @RequestParam(required = false) groupIds: List?, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + val result = api.distribute( + token(session), + DistributeRequestPayload( + labId = id, + force = force, + groupIds = groupIds.orEmpty().filter { it.isNotBlank() }, + ), + ) + redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") + "redirect:/admin/labs/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id" + } + + @GetMapping("/groups/{id}/submissions.zip") + fun downloadGroupSubmissions( + @PathVariable id: String, + @RequestParam(required = false) labId: String?, + session: HttpSession, + ): ResponseEntity { + val t = token(session) + val group = api.groupDetail(t, id) + val studentById = group.students.associateBy { it.id } + val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } + val out = java.io.ByteArrayOutputStream() + java.util.zip.ZipOutputStream(out).use { zos -> + java.util.zip.ZipEntry("ОПИСАНИЕ.txt").also { it.time = 0; zos.putNextEntry(it) } + zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8)) + zos.closeEntry() + for (lab in labs) { + val subs = api.labSubmissions(t, lab.id) + for (sub in subs) { + val student = studentById[sub.studentId] ?: continue + val bytes = api.downloadSubmissionAsAdmin(t, sub.id).bytes + java.io.ByteArrayInputStream(bytes).let { src -> + java.util.zip.ZipInputStream(src).use { zin -> + var e = zin.nextEntry + while (e != null) { + if (!e.isDirectory) { + java.util.zip.ZipEntry("ЛР${lab.number}/${student.studentNo}_${student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_")}/${e.name}").let { ne -> ne.time = 0; zos.putNextEntry(ne) } + zin.copyTo(zos) + zos.closeEntry() + } + e = zin.nextEntry + } + } + } + } + } + } + val name = "solutions-${group.name}.zip" + val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8) + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''${encoded}") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(out.toByteArray()) + } + + @PostMapping("/groups/{id}/publish") + fun publishGrades( + @PathVariable id: String, + @RequestParam labId: String, + @RequestParam published: Boolean, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.publishGrades(token(session), labId, id, published) + redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы") + "redirect:/admin/groups/$id" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$id" + } + @GetMapping("/submissions/{id}/download") fun downloadSubmissionAsAdmin( @PathVariable id: String, @@ -532,6 +881,36 @@ class AdminController( "redirect:/admin/groups/$groupId" } + @PostMapping("/groups/{groupId}/students/enroll") + fun enrollStudent( + @PathVariable groupId: String, + @RequestParam studentId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.enrollStudent(token(session), groupId, studentId) + redirect.addFlashAttribute("notice", "Студент зачислен в группу") + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } + + @PostMapping("/groups/{groupId}/students/{studentId}/unenroll") + fun unenrollStudent( + @PathVariable groupId: String, + @PathVariable studentId: String, + session: HttpSession, + redirect: RedirectAttributes, + ): String = try { + api.unenrollStudent(token(session), groupId, studentId) + redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)") + "redirect:/admin/groups/$groupId" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/groups/$groupId" + } + @GetMapping("/labs/{id}/edit") fun editLabForm( @PathVariable id: String, @@ -546,6 +925,7 @@ class AdminController( return "admin/lab-edit" } + /** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */ @PostMapping("/labs/{id}/edit") fun editLab( @PathVariable id: String, @@ -555,18 +935,88 @@ class AdminController( @RequestParam pointsMax: String, @RequestParam status: String, @RequestParam descriptionMd: String, + @RequestParam(defaultValue = "save") action: String, + @RequestParam(required = false) levelIds: List?, + @RequestParam(required = false) levelTitles: List?, + @RequestParam(required = false) variantIds: List?, + @RequestParam(required = false) variantLevelIds: List?, + @RequestParam(required = false) variantNumbers: List?, + @RequestParam(required = false) variantTitles: List?, + @RequestParam(required = false) variantStatements: List?, + @RequestParam(required = false) newLevelCode: String?, + @RequestParam(required = false) newLevelTitle: String?, + @RequestParam(required = false) addLevelId: String?, + @RequestParam(required = false) addNumber: String?, + @RequestParam(required = false) addTitle: String?, + @RequestParam(required = false) addStatementMd: String?, session: HttpSession, redirect: RedirectAttributes, - ): String = try { - api.updateLab( - token(session), id, - LabUpdatePayload(title, ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt), ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt), pointsMax, status, descriptionMd), - ) - redirect.addFlashAttribute("notice", "Лабораторная сохранена") - "redirect:/admin/labs/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$id/edit" + ): String { + val t = token(session) + return try { + when { + action.startsWith("delete-level-") -> { + api.deleteLevel(t, id, action.removePrefix("delete-level-")) + redirect.addFlashAttribute("notice", "Уровень удалён") + return "redirect:/admin/labs/$id/edit" + } + action.startsWith("delete-variant-") -> { + api.deleteVariant(t, id, action.removePrefix("delete-variant-")) + redirect.addFlashAttribute("notice", "Вариант удалён") + return "redirect:/admin/labs/$id/edit" + } + } + + api.updateLab( + t, id, + LabUpdatePayload( + title, + ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt), + ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt), + pointsMax, status, descriptionMd, + ), + ) + levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) -> + if (levelId.isNotBlank() && newTitle.isNotBlank()) { + api.renameLevel(t, id, levelId, newTitle.trim()) + } + } + variantIds.orEmpty().forEachIndexed { idx, variantId -> + val number = variantNumbers.orEmpty().getOrNull(idx).orEmpty().trim() + if (variantId.isNotBlank() && number.isNotBlank()) { + api.saveVariant( + t, id, variantId, + VariantSavePayload( + variantLevelIds.orEmpty().getOrNull(idx).orEmpty(), + number, + variantTitles.orEmpty().getOrNull(idx).orEmpty().trim(), + variantStatements.orEmpty().getOrNull(idx).orEmpty(), + ), + ) + } + } + when { + action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> { + api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim())) + redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен") + } + action == "add-variant" && !addLevelId.isNullOrBlank() && !addNumber.isNullOrBlank() -> { + api.saveVariant( + t, id, null, + VariantSavePayload( + addLevelId, addNumber.trim(), + addTitle.orEmpty().trim(), addStatementMd.orEmpty(), + ), + ) + redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен") + } + else -> redirect.addFlashAttribute("notice", "Все изменения сохранены") + } + "redirect:/admin/labs/$id/edit" + } catch (ex: ApiException) { + redirect.addFlashAttribute("error", ex.message) + "redirect:/admin/labs/$id/edit" + } } @PostMapping("/labs/{labId}/variants/save") @@ -603,21 +1053,6 @@ class AdminController( "redirect:/admin/labs/$labId/edit" } - @PostMapping("/labs/{id}/distribute") - fun distribute( - @PathVariable id: String, - @RequestParam(defaultValue = "false") force: Boolean, - session: HttpSession, - redirect: RedirectAttributes, - ): String = try { - val result = api.distribute(token(session), DistributeRequestPayload(labId = id, force = force)) - redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}") - "redirect:/admin/labs/$id" - } catch (ex: ApiException) { - redirect.addFlashAttribute("error", ex.message) - "redirect:/admin/labs/$id" - } - @PostMapping("/labs/{labId}/set-level") fun setLevel( @PathVariable labId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 1e553e1..4a57fcd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -33,4 +33,46 @@ data class AdminStudentGrades( val average: Double?, val comments: List>>, val commentsJson: String, -) \ No newline at end of file +) +/** Ячейка матрицы «студент × лабораторная» на странице группы. */ +data class GroupLabCell( + val labId: String = "", + val labNumber: Int = 0, + val labTitle: String = "", + val variantNumber: String? = null, + val variantManual: Boolean = false, + val score: String? = null, + val published: Boolean = false, + val submissionSeq: Int? = null, + val submissionId: String? = null, + val defenseStatus: String? = null, + val defenseCode: String? = null, + val defenseSecondsLeft: Long? = null, + val defenseAssigned: Boolean = false, +) + +/** Строка студента в матрице + комментарии для диалога. */ +data class GroupStudentRow( + val studentId: String, + val fullName: String, + val studentNo: String, + val basis: String, + val mustChangePassword: Boolean, + val cells: List, + val variantsSummary: String, + val commentsJson: String, +) + +/** Ряд блока защит (одна сессия на строку). */ +data class GroupDefenseRow( + val sessionId: String?, + val studentId: String, + val fullName: String, + val studentNo: String, + val labId: String, + val labNumber: Int, + val status: String, + val code: String?, + val assignmentMd: String, + val secondsLeft: Long?, +) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 8225f78..dd33a7d 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -98,6 +98,31 @@ class ApiClient( .retrieve().body(GroupDetailDto::class.java) } + fun groupCandidates(token: String, groupId: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/groups/{id}/candidates", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun enrollStudent(token: String, groupId: String, studentId: String) { + call { + client.post().uri("/api/v1/groups/{id}/students/{sid}/enroll", groupId, studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun unenrollStudent(token: String, groupId: String, studentId: String) { + call { + client.delete().uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + fun resetPassword(token: String, studentId: String): ResetPasswordDto = call { client.post().uri("/api/v1/students/{id}/reset-password", studentId) @@ -315,10 +340,40 @@ class ApiClient( } ?: emptyList() } - fun myGrades(token: String): List { + fun myGrades(token: String): List = myGrades(token, null) + + fun myGrades(token: String, groupId: String?): List { val type = object : ParameterizedTypeReference>() {} return call { - client.get().uri("/api/v1/me/grades") + val uri = if (groupId.isNullOrBlank()) "/api/v1/me/grades" + else "/api/v1/me/grades?groupId={gid}" + val spec = client.get() + .uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId)) + spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type) + } ?: emptyList() + } + + fun groupPublications(token: String, groupId: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/grades/publications?groupId={gid}", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().body(type) + } ?: emptyList() + } + + fun publishGrades(token: String, labId: String, groupId: String, published: Boolean) { + call { + client.post().uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve().toBodilessEntity() + } + } + + fun myGroup(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client.get().uri("/api/v1/groups/me") .header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve().body(type) } ?: emptyList() diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index bb4099e..5bb2988 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -68,6 +68,14 @@ data class GroupDetailDto( val students: List = emptyList(), ) +data class StudentCandidateDto( + val id: String = "", + val fullName: String = "", + val studentNo: String = "", + val currentGroupId: String? = null, + val currentGroupName: String? = null, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class ResetPasswordDto(val password: String = "") @@ -95,15 +103,18 @@ data class LabImportResultDto( @JsonIgnoreProperties(ignoreUnknown = true) data class DistributeResultDto( - val labId: String = "", - val labTitle: String = "", + val labs: Int = 1, + val labId: String? = null, + val labTitle: String? = null, val assigned: Int = 0, val total: Int = 0, ) data class DistributeRequestPayload( - val labId: String, + val labId: String? = null, + val labIds: List = emptyList(), val force: Boolean = false, + val groupIds: List = emptyList(), ) data class LevelAssignPayload( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index e9294eb..e0dc7c3 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -17,10 +17,7 @@ import ru.ysu.lms.web.session.SessionUser class AuthPageController(private val api: ApiClient) { @GetMapping("/") - fun root(session: HttpSession): String { - val user = currentUser(session) ?: return "redirect:/login" - return if (user.isAdmin) "redirect:/admin" else "redirect:/student" - } + fun root(): String = "redirect:/learn" @GetMapping("/login") fun loginPage(session: HttpSession, model: Model): String { @@ -93,7 +90,7 @@ class AuthPageController(private val api: ApiClient) { return try { api.changePassword(token, old, new, new2) session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false)) - "redirect:/" + "redirect:" + if (user.isAdmin) "/admin" else "/student" } catch (ex: ApiException) { model.addAttribute("error", ex.message) "change_password" diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt index fec260b..1d58274 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt @@ -8,6 +8,7 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import ru.ysu.lms.web.session.AdminInterceptor import ru.ysu.lms.web.session.AuthInterceptor +import ru.ysu.lms.web.session.MustChangePasswordInterceptor import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser @@ -16,10 +17,16 @@ class WebConfig( private val authInterceptor: AuthInterceptor, private val adminInterceptor: AdminInterceptor, private val csrfInterceptor: CsrfInterceptor, + private val mustChangePasswordInterceptor: MustChangePasswordInterceptor, ) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(csrfInterceptor).addPathPatterns("/**") + registry.addInterceptor(mustChangePasswordInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/change-password", "/logout", "/login", "/css/**", "/js/**", "/favicon.svg", "/error", + ) registry.addInterceptor(authInterceptor) .addPathPatterns("/admin/**", "/student/**", "/change-password") registry.addInterceptor(adminInterceptor) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt index c053997..0fddbb9 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -43,6 +43,19 @@ class LearnController( return "learn/index" } + @GetMapping("/learn/{chapter}") + fun chapter( + @PathVariable chapter: String, + session: HttpSession, + model: Model, + ): String { + val tree: ContentTreeNodeDto = api.contentTree(token(session)) + val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" + model.addAttribute("tree", tree) + model.addAttribute("chapter", ch) + return "learn/chapter" + } + @GetMapping("/learn/{chapter}/{section}") fun section( @PathVariable chapter: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/MustChangePasswordInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/MustChangePasswordInterceptor.kt new file mode 100644 index 0000000..04024ef --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/MustChangePasswordInterceptor.kt @@ -0,0 +1,27 @@ +package ru.ysu.lms.web.session + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +/** + * Пока временный пароль не заменён, пользователю доступны только + * страница смены пароля, выход и статика — все остальные запросы + * принудительно возвращают на /change-password. + */ +@Component +class MustChangePasswordInterceptor : HandlerInterceptor { + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ): Boolean { + val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser + ?: return true + if (!user.mustChangePassword) return true + response.sendRedirect("/change-password") + return false + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index 2c1386f..bad9ebf 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -36,6 +36,9 @@ class StudentController( private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String + private fun studentGroupId(t: String): String? = + api.myGroup(t).firstOrNull()?.id + data class LabCard( val id: String, val number: Int, @@ -83,21 +86,29 @@ class StudentController( model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) model.addAttribute("assignment", assignment) model.addAttribute("statementHtml", assignment?.statementHtml) - model.addAttribute("submissions", api.mySubmissions(t, id)) - val myGrade = api.myGrades(t).firstOrNull { it.labId == id } - model.addAttribute("grade", myGrade?.score) - model.addAttribute("gradeComments", myGrade?.comments ?: emptyList()) + val subs = api.mySubmissions(t, id) + model.addAttribute("submissions", subs.map { it.copyMsk() }) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) - model.addAttribute("canSubmit", canSubmit(lab.deadlineAt)) + val gid = studentGroupId(t) + val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null + model.addAttribute("canSeeGrade", myGrade != null) + model.addAttribute("grade", myGrade?.score) + model.addAttribute("commentsPositive", myGrade?.comments?.filter { it.tone == "positive" } ?: emptyList()) + model.addAttribute("commentsNeutral", myGrade?.comments?.filter { it.tone == "neutral" } ?: emptyList()) + model.addAttribute("commentsNegative", myGrade?.comments?.filter { it.tone == "negative" } ?: emptyList()) val defense = api.myDefense(t, id) - defense?.let { it.assignmentHtml = markdown.render(it.assignmentMd) } + defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" } model.addAttribute("defense", defense) + val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank()) + model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending) "student/lab" } catch (ex: ApiException) { "redirect:/student/labs" } } + + @PostMapping("/labs/{id}/submit") fun submit( @PathVariable id: String, @@ -169,7 +180,8 @@ class StudentController( private fun buildCards(token: String): List { val labs = api.listLabs(token).filter { it.status == "published" } val assignments = api.meAssignments(token).associateBy { it.labId } - val grades = api.myGrades(token).associate { it.labId to it } + val gid = studentGroupId(token) + val grades = if (gid != null) api.myGrades(token, gid).associate { it.labId to it } else emptyMap() return labs.map { lab -> val subs = api.mySubmissions(token, lab.id) val assignment = assignments[lab.id] @@ -187,6 +199,11 @@ class StudentController( } } + private fun SubmissionDto.copyMsk(): SubmissionDto = SubmissionDto( + id = id, seq = seq, studentId = studentId, labId = labId, + createdAt = ru.ysu.lms.web.DateUtils.formatMsk(createdAt) ?: createdAt, files = files, + ) + private fun canSubmit(deadlineAt: String?): Boolean { val d = deadlineAt ?: return true val instant = runCatching { Instant.parse(d) }.getOrNull() ?: return true diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index 4c9e879..bc14777 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -1,10 +1,14 @@ server: port: ${SERVER_PORT:8090} + # За nginx: доверяем X-Forwarded-Proto/Host, чтобы редиректы были https и с верным хостом. + forward-headers-strategy: framework servlet: session: + timeout: 30d cookie: http-only: true same-site: lax + max-age: 30d tracking-modes: cookie spring: diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index ca034d1..a006e92 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -47,7 +47,13 @@ a:hover { color: var(--c-text-link-hover); } /* Layout */ .page { display: flex; flex-direction: column; min-height: 100vh; } .page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); } -.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; } +.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; } +@media (max-width: 640px) { + .page-header-inner { padding: 0.5rem 0.75rem; } + .nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; } + .nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; } + .nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +} .page-main { flex: 1; width: 100%; max-width: 1152px; margin: 0 auto; padding: 2rem 1.5rem; } .page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); } @@ -57,6 +63,12 @@ a:hover { color: var(--c-text-link-hover); } .nav-link:hover { color: var(--c-text-link-hover); } .nav-sep { color: var(--c-text-muted); } .logo { font-size: 1.125rem; font-weight: 600; color: var(--c-text); text-decoration: none; } +.nav-user { font-weight: 500; } +.theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; } +.theme-icon { width: 1.05rem; height: 1.05rem; display: block; } +.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } +.breadcrumb .nav-sep { user-select: none; } +.check-list { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; max-height: 12rem; overflow-y: auto; } /* Buttons */ .btn { @@ -244,3 +256,76 @@ details.sub-acc > summary::-webkit-details-marker { display: none; } .sub-arrow { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; margin-right: 6px; vertical-align: 2px;} details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); } .sub-actions { margin: 0 0 2px 18px; } + +/* ---- Матрица группы и карточки защиты ---- */ +.m-0 { margin: 0; } +.mb-8 { margin-bottom: 2rem; } +.mb-0 { margin-bottom: 0; } +.text-strong { font-weight: 600; color: var(--c-text); } +.text-right { text-align: right; } +.font-medium { font-weight: 500; } +.scroll-x { overflow-x: auto; } +.matrix { border-collapse: separate; border-spacing: 0; } +.matrix th, .matrix td { vertical-align: top; } +.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } +.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } +.matrix td.student-cell { min-width: 12rem; } +.lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; } +.cell-line { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; text-align: left; } +.cell-label { color: var(--c-text-muted); min-width: 3.5rem; } +.cell-link { font-size: 0.8125rem; } +.cell-edit { padding: 0 0.4rem; } +.cell-grade-line { font-weight: 500; } +.defense-cell-actions { display: flex; gap: 0.35rem; justify-content: flex-end; flex-wrap: wrap; } +.th-w-sm { width: 4rem; } +.th-w-md { width: 12rem; } +.span-2 { grid-column: 1 / -1; } +.preview { max-width: 34rem; } +.md-preview { max-width: 22rem; } + +/* ---- Комментарии к оценке (ЛК студента) ---- */ +.review-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; } +.review-col h3 { margin-bottom: 0.35rem; } +.review-list { margin: 0; padding-left: 1.15rem; } +.review-list li { margin: 0.15rem 0; } +.tone-positive-text { color: #166534; } +.tone-negative-text { color: #991b1b; } +[data-theme="dark"] .tone-positive-text { color: #6ee7b7; } +[data-theme="dark"] .tone-negative-text { color: #fca5a5; } +.tone-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; } +.pre-wrap { white-space: pre-wrap; word-break: break-word; } + +/* Редактируемая матрица: закреплённый столбец и компактные селекты */ +.matrix-sticky { position: sticky; left: 0; z-index: 2; background: var(--c-surface); } +.matrix thead .matrix-sticky { z-index: 3; } +.level-form { margin: 0; } +.level-select { + width: auto; min-width: 6rem; max-width: 9.5rem; + padding: 0.15rem 1.5rem 0.15rem 0.4rem; font-size: 0.75rem; +} +.cell-level { justify-content: center; } +.pin-hint { font-size: 0.75rem; } + +/* Компактные ячейки матрицы группы */ +.btn-xs { padding: 0.05rem 0.4rem; font-size: 0.7rem; line-height: 1.5; } +.badge-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; } +.chip-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; } +.cell-top { display: flex; align-items: center; justify-content: center; gap: 0.25rem; } +.cell-chips { display: flex; flex-wrap: wrap; gap: 0.18rem; justify-content: center; margin-top: 0.25rem; } +.cell-chip { + display: inline-flex; align-items: center; gap: 0.15rem; + font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem; + border: 1px solid var(--c-border); border-radius: 99px; + background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer; + text-decoration: none; font-family: inherit; +} +.cell-chip:hover { border-color: var(--c-border-strong, var(--c-border)); filter: brightness(0.97); } +.chip-dim { color: var(--c-text-muted); } +.chip-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; } +.chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; } +.chip-link:hover { color: var(--c-text-link); } +[data-theme="dark"] .chip-warn { color: #fcd34d; background: rgba(146, 64, 14, 0.25); border-color: rgba(146, 64, 14, 0.5); } +[data-theme="dark"] .chip-ok { color: #86efac; background: rgba(22, 101, 52, 0.25); border-color: rgba(22, 101, 52, 0.5); } +.lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; } +.actions-cell { white-space: nowrap; } +.actions-cell .inline-form { display: inline-block; } diff --git a/web/src/main/resources/static/css/hljs-github-dark.css b/web/src/main/resources/static/css/hljs-github-dark.css new file mode 100644 index 0000000..03b6da8 --- /dev/null +++ b/web/src/main/resources/static/css/hljs-github-dark.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c} \ No newline at end of file diff --git a/web/src/main/resources/static/css/hljs-github.css b/web/src/main/resources/static/css/hljs-github.css new file mode 100644 index 0000000..275239a --- /dev/null +++ b/web/src/main/resources/static/css/hljs-github.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js new file mode 100644 index 0000000..48064bd --- /dev/null +++ b/web/src/main/resources/static/js/admin-group.js @@ -0,0 +1,116 @@ +function $id(id) { return document.getElementById(id); } + +/* ---------- Оценка + комментарии ---------- */ + +function openGradeEditor(btn) { + var all = []; + try { all = JSON.parse(btn.getAttribute('data-comments') || '[]'); } catch (e) { all = []; } + var idx = parseInt(btn.getAttribute('data-ci') || '0', 10); + var list = Array.isArray(all[idx]) ? all[idx] : []; + + $id('grade-student').value = btn.getAttribute('data-student') || ''; + $id('grade-lab').value = btn.getAttribute('data-lab') || ''; + $id('grade-who').value = btn.getAttribute('data-student-name') || ''; + var title = $id('grade-title'); + if (title) title.textContent = (btn.getAttribute('data-lab-name') || '') + ' — ' + (btn.getAttribute('data-student-name') || ''); + $id('grade-score').value = btn.getAttribute('data-score') || ''; + + var box = $id('cs-rows'); + box.innerHTML = ''; + list.forEach(function (c) { csRow(c.text, c.tone); }); + if (!list.length) csRow('', 'neutral'); + + var d = $id('modal-grade'); + if (d && d.showModal) d.showModal(); +} + +function csRow(text, tone) { + var box = $id('cs-rows'); + var row = document.createElement('div'); + row.className = 'cs-row'; + + var inp = document.createElement('input'); + inp.className = 'input cs-text'; + inp.placeholder = 'Комментарий'; + inp.value = text || ''; + + var sel = document.createElement('select'); + sel.className = 'input cs-tone'; + [['positive', '+ позитивный'], ['negative', '— негативный'], ['neutral', '○ нейтральный']].forEach(function (o) { + var op = document.createElement('option'); + op.value = o[0]; + op.textContent = o[1]; + sel.appendChild(op); + }); + sel.value = tone || 'neutral'; + + var rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'btn btn-secondary btn-sm'; + rm.textContent = 'удалить'; + rm.onclick = function () { row.remove(); }; + + row.appendChild(inp); + row.appendChild(sel); + row.appendChild(rm); + box.appendChild(row); +} + +function csSerialize() { + var out = []; + document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) { + var t = r.querySelector('.cs-text').value.trim(); + if (t) out.push({ text: t, tone: r.querySelector('.cs-tone').value }); + }); + $id('grade-comments').value = JSON.stringify(out); +} + +/* ---------- Индивидуальные задания (защита) ---------- */ + +function openDefenseForm(btn) { + var form = $id('defense-form'); + if (!form) return; + form.reset(); + var student = btn.getAttribute('data-student') || ''; + var lab = btn.getAttribute('data-lab') || ''; + if (student) $id('defense-student-select').value = student; + if (lab) $id('defense-lab-select').value = lab; + $id('defense-md').value = btn.getAttribute('data-md') || ''; + var d = $id('modal-defense'); + if (d && d.showModal) d.showModal(); +} + +function showDefenseText(btn) { + var el = $id('defense-text-body'); + if (el) el.textContent = btn.getAttribute('data-md') || '(пусто)'; + var title = $id('defense-text-title'); + if (title) title.textContent = (btn.getAttribute('data-student-name') || '') + ' — ' + (btn.getAttribute('data-lab-name') || ''); + var d = $id('modal-defense-text'); + if (d && d.showModal) d.showModal(); +} + +function filterDefense() { + var s = $id('defense-lab-filter'); + if (!s) return; + var labId = s.value; + document.querySelectorAll('#defense-table tr[data-lab]').forEach(function (tr) { + tr.style.display = (!labId || tr.getAttribute('data-lab') === labId) ? '' : 'none'; + }); +} + +/* ---------- Матрица: фокус на лабораторной и закрепление варианта ---------- */ + +function focusLab() { + var s = $id('lab-focus'); + if (!s) return; + var labId = s.value; + document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab]').forEach(function (el) { + el.style.display = (!labId || el.getAttribute('data-lab') === labId) ? '' : 'none'; + }); +} + +document.addEventListener('submit', function (ev) { + if (ev.target && ev.target.id === 'grade-form') csSerialize(); +}, true); + +window.addEventListener('DOMContentLoaded', function () { filterDefense(); focusLab(); }); diff --git a/web/src/main/resources/static/js/admin-lab.js b/web/src/main/resources/static/js/admin-lab.js new file mode 100644 index 0000000..9194263 --- /dev/null +++ b/web/src/main/resources/static/js/admin-lab.js @@ -0,0 +1,36 @@ +function toggleAllStudents(cb) { + document.querySelectorAll('.pick-student').forEach(function (c) { c.checked = cb.checked; }); +} + +function collectPicked(form) { + var picked = Array.prototype.slice.call(document.querySelectorAll('.pick-student:checked')); + if (!picked.length) { + alert('Отметьте хотя бы одного студента.'); + return false; + } + picked.forEach(function (c) { + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'studentIds'; + input.value = c.value; + form.appendChild(input); + }); + return true; +} + +function confirmOccupied(sel) { + var opt = sel.options[sel.selectedIndex]; + var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0; + if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) { + if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) { + if (sel.dataset.prev !== undefined) sel.value = sel.dataset.prev; + return; + } + } + sel.dataset.prev = sel.value; + if (sel.form) sel.form.submit(); +} + +window.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.pick-variant').forEach(function (s) { s.dataset.prev = s.value; }); +}); diff --git a/web/src/main/resources/static/js/defense-timer.js b/web/src/main/resources/static/js/defense-timer.js new file mode 100644 index 0000000..1201d84 --- /dev/null +++ b/web/src/main/resources/static/js/defense-timer.js @@ -0,0 +1,23 @@ +document.querySelectorAll('.defense-timer').forEach(function (el) { + var left = parseInt(el.getAttribute('data-seconds') || '0', 10); + function render(v) { + var m = Math.floor(v / 60), s = v % 60; + el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s; + } + render(left); + var tick = setInterval(function () { + left -= 1; + if (left <= 0) { clearInterval(tick); window.location.reload(); return; } + render(left); + }, 1000); +}); +document.querySelectorAll('.ru-datetime').forEach(function (el) { + try { + var iso = (el.textContent || '').trim(); + if (iso && !el.getAttribute('data-done')) { + var d = new Date(iso); + el.textContent = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short', timeZone: 'Europe/Moscow' }).format(d); + el.setAttribute('data-done', '1'); + } + } catch (e) {} +}); diff --git a/web/src/main/resources/static/js/highlight.min.js b/web/src/main/resources/static/js/highlight.min.js new file mode 100644 index 0000000..6e1a09e --- /dev/null +++ b/web/src/main/resources/static/js/highlight.min.js @@ -0,0 +1,1244 @@ +/*! + Highlight.js v11.11.1 (git: 08cb242e7d) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",w="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",v="\\b(0b[01]+)",N={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[N]},x={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[N]},O=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=O("//","$"),A=O("/\\*","\\*/"),S=O("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:N,BINARY_NUMBER_MODE:{ +scope:"number",begin:v,relevance:0},BINARY_NUMBER_RE:v,COMMENT:O, +C_BLOCK_COMMENT_MODE:A,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:w,relevance:0},C_NUMBER_RE:w,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:S,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:x,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[N,{begin:/\[/,end:/\]/,relevance:0,contains:[N]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},F=["of","and","for","in","not","or","if","then","parent","list","value"] +;function z(e,n,t="keyword"){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,z(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>F.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ +console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) +},H=Error();function G(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw P("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +H +;if("object"!=typeof e.beginScope||null===e.beginScope)throw P("beginScope must be object"), +H;G(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw P("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +H +;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), +H;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,Z,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ +return!!e&&(e.endsWithParent||Q(e.starts))}class X extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const V=t,J=a,Y=Symbol("nomatch"),ee=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};O("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,O("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!O.keywords)return void A.addText(S) +;let e=0;O.keywordPatternRe.lastIndex=0;let n=O.keywordPatternRe.exec(S),t="" +;for(;n;){t+=S.substring(e,n.index) +;const i=v.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,O.keywords[a]);if(r){ +const[e,a]=r +;if(A.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=v.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=O.keywordPatternRe.lastIndex,n=O.keywordPatternRe.exec(S)}var a +;t+=S.substring(e),A.addText(t)}function d(){null!=O.subLanguage?(()=>{ +if(""===S)return;let e=null;if("string"==typeof O.subLanguage){ +if(!a[O.subLanguage])return void A.addText(S) +;e=f(O.subLanguage,S,!0,M[O.subLanguage]),M[O.subLanguage]=e._top +}else e=E(S,O.subLanguage.length?O.subLanguage:null) +;O.relevance>0&&(C+=e.relevance),A.__addSublanguage(e._emitter,e.language) +})():c(),S=""}function g(e,n){ +""!==e&&(A.startScope(n),A.addText(e),A.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=v.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(S=i,c(),S=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&A.openNode(v.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(S,v.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +S=""):e.beginScope._multi&&(u(e.beginScope,n),S="")),O=Object.create(e,{parent:{ +value:O}}),O}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===O.matcher.regexIndex?(S+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(O,e,a);if(!i)return Y;const r=O +;O.endScope&&O.endScope._wrap?(d(), +g(n,O.endScope._wrap)):O.endScope&&O.endScope._multi?(d(), +u(O.endScope,e)):r.skip?S+=n:(r.returnEnd||r.excludeEnd||(S+=n), +d(),r.excludeEnd&&(S=n));do{ +O.scope&&A.closeNode(),O.skip||O.subLanguage||(C+=O.relevance),O=O.parent +}while(O!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function w(a,r){const o=r&&r[0];if(S+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(S+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?S+=t:(a.excludeBegin&&(S+=t), +d(),a.returnBegin||a.excludeBegin||(S=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(O.scope||"")+'"') +;throw e.mode=O,e}if("end"===r.type){const e=h(r);if(e!==Y)return e} +if("illegal"===r.type&&""===o)return S+="\n",1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return S+=o,o.length}const v=N(e) +;if(!v)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=W(v);let x="",O=r||k;const M={},A=new p.__emitter(p);(()=>{const e=[] +;for(let n=O;n!==v;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>A.openNode(e)))})();let S="",C=0,T=0,R=0,D=!1;try{ +if(v.__emitTokens)v.__emitTokens(t,A);else{for(O.matcher.considerAll();;){ +R++,D?D=!1:O.matcher.considerAll(),O.matcher.lastIndex=T +;const e=O.matcher.exec(t);if(!e)break;const n=w(t.substring(T,e.index),e) +;T=e.index+n}w(t.substring(T))}return A.finalize(),x=A.toHTML(),{language:e, +value:x,relevance:C,illegal:!1,_emitter:A,_top:O}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:V(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:x},_emitter:A};if(s)return{ +language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:A,_top:O} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:V(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(N(e.language).supersetOf===n.language)return 1 +;if(N(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=N(t[1]) +;return n||(K(o.replace("{}",t[1])), +K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||N(e)))})(e);if(_(t))return +;if(O("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new X("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),O("after:highlightElement",{el:e,result:r,text:a})}let w=!1;function v(){ +if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",(()=>{ +v()}),!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} +function N(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function x(e){const n=N(e) +;return n&&!n.disableAutodetect}function O(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))}Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:v, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=J(p,e)}, +initHighlighting:()=>{ +v(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +v(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(P("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;P(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&k(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:N,registerAliases:k, +autoDetection:x,inherit:J,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.11.1",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},ne=ee({});ne.newInstance=()=>ee({});const te=e=>({IMPORTANT:{scope:"meta", +begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),ae=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],ie=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),re=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),se=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),oe=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),le=re.concat(se).sort().reverse() +;var ce="[0-9](_*[0-9])*",de=`\\.(${ce})`,ge="[0-9a-fA-F](_*[0-9a-fA-F])*",ue={ +className:"number",variants:[{ +begin:`(\\b(${ce})((${de})|\\.)?|(${de}))[eE][+-]?(${ce})[fFdD]?\\b`},{ +begin:`\\b(${ce})((${de})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${de})[fFdD]?\\b`},{begin:`\\b(${ce})[fFdD]\\b`},{ +begin:`\\b0[xX]((${ge})\\.?|(${ge})?\\.(${ge}))[pP][+-]?(${ce})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ge})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function be(e,n,t){return-1===t?"":e.replace(n,(a=>be(e,n,t-1)))} +const me="[A-Za-z$_][0-9A-Za-z$_]*",pe=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],_e=["true","false","null","undefined","NaN","Infinity"],he=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],fe=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ee=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],ye=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],we=[].concat(Ee,he,fe) +;function ve(e){const n=e.regex,t=me,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="e+"\\s*\\(")), +n.concat("(?!",N.join("|"),")")),t,n.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var N;const k={ +begin:n.concat(/\./,n.lookahead(n.concat(t,/(?![0-9A-Za-z$_(])/))),end:t, +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},x={ +match:[/get|set/,/\s+/,t,/(?=\()/],className:{1:"keyword",3:"title.function"}, +contains:[{begin:/\(\)/},f] +},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +scope:"attr",match:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},w,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},k,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},v,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,x,{match:/\$[(.]/}]}} +const Ne=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),ke=["Protocol","Type"].map(Ne),xe=["init","self"].map(Ne),Oe=["Any","Self"],Me=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ae=["false","nil","true"],Se=["assignment","associativity","higherThan","left","lowerThan","none","right"],Ce=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],Te=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Re=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),De=m(Re,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Ie=b(Re,De,"*"),Le=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Be=m(Le,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),$e=b(Le,Be,"*"),Fe=b(/[A-Z]/,Be,"*"),ze=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,$e,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],je=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ue=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},r=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),s={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const l={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},c=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),d={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[c,e.SHEBANG(),d,l,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}},grmr_c:e=>{ +const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{match:/\b(0b[01']+)/},{ +match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/ +},{ +match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/ +},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0 +},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{ +begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" +},{ +begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","args","async","await","by","descending","dynamic","equals","file","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","record","remove","required","scoped","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[{className:"string", +begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 +},c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u={begin:"<",end:">", +contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=te(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+re.join("|")+")"},{begin:":(:)?("+se.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:ie.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+ae.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex +;return{name:"GraphQL",aliases:["gql"],case_insensitive:!0,disableAutodetect:!1, +keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+be("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ue,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ue,r]}},grmr_javascript:ve, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",aliases:["jsonc"],keywords:{ +literal:n},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/, +relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=ue,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=te(e),t=le,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:ie.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+ae.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+re.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+se.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",aliases:["pluto"],keywords:{ +$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}, +grmr_objectivec:e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot class close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl field fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map method mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={scope:"attr",match:/\s+:\s*\w+(\s*\(.*?\))?/},o={scope:"variable", +variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](?!")(\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},l={ +className:"number",variants:[{match:/0?\.[0-9][0-9_]+\b/},{ +match:/\bv?(0|[1-9][0-9_]*(\.[0-9_]+)?|[1-9][0-9_]*)\b/},{ +match:/\b0[0-7][0-7_]*\b/},{match:/\b0x[0-9a-fA-F][0-9a-fA-F_]*\b/},{ +match:/\b0b[0-1][0-1_]*\b/}],relevance:0 +},c=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},u=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),b=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:c,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},l,{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:g("s|tr|y",n.either(...d,{capture:!0}))},{begin:g("s|tr|y","\\(","\\)")},{ +begin:g("s|tr|y","\\[","\\]")},{begin:g("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:u("(?:m|qr)?",/\//,/\//)},{begin:u("m|qr",n.either(...d,{capture:!0 +}),/\1/)},{begin:u("m|qr",/\(/,/\)/)},{begin:u("m|qr",/\[/,/\]/)},{ +begin:u("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub method", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE,s] +},{className:"class",beginKeywords:"class",end:"[;{]",excludeEnd:!0,relevance:5, +contains:[e.TITLE_MODE,s,l]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", +end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$", +className:"comment"}]}];return i.contains=b,r.contains=b,{name:"Perl", +aliases:["pl","pm"],keywords:a,contains:b}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r=n.concat(/[A-Z]+/,t),s={ +scope:"variable",match:"\\$+"+a},o={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},l=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),c="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),l,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(o),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},g={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},u=["false","null","true"],b=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],m=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],p={ +keyword:b,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(u),built_in:m},_=e=>e.map((e=>e.replace(/\|\d+$/,""))),h={variants:[{ +match:[/new/,n.concat(c,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},f=n.concat(a,"\\b(?!\\()"),E={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},y={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},w={relevance:0, +begin:/\(/,end:/\)/,keywords:p,contains:[y,s,E,e.C_BLOCK_COMMENT_MODE,d,g,h] +},v={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(c,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[w]};w.contains.push(v) +;const N=[y,E,e.C_BLOCK_COMMENT_MODE,d,g,h],k={ +begin:n.concat(/#\[\s*\\?/,n.either(i,r)),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:u,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:u,keyword:["new","array"]}, +contains:["self",...N]},...N,{scope:"meta",variants:[{match:i},{match:r}]}]} +;return{case_insensitive:!1,keywords:p, +contains:[k,e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{ +contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},s,v,E,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},h,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:p, +contains:["self",k,s,E,e.C_BLOCK_COMMENT_MODE,d,g]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},d,g]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{scope:"variable.language",match:/\bself\b/ +},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" +},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", +3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{ +const n=e.regex,t=/(r#)?/,a=n.concat(t,e.UNDERSCORE_IDENT_RE),i=n.concat(t,e.IDENT_RE),r={ +className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,n.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(32|64))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],l=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:l, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:o},illegal:""},r]}}, +grmr_scss:e=>{const n=te(e),t=se,a=re,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+ae.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+oe.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:ie.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),s={ +match:n.concat(/\b/,n.either(...i),/\s*\(/),relevance:0,keywords:{built_in:i}} +;function o(e){ +return n.concat(/\b/,n.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)} +const l={scope:"keyword", +match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]), +relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(r,{when:e=>e.length<3}),literal:["true","false","unknown"], +type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"], +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{scope:"type", +match:o(["double precision","large object","with timezone","without timezone"]) +},l,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ +begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{ +match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator", +match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, +grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ +contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...ke,...xe)], +className:{2:"keyword"}},r={match:b(/\./,m(...Me)),relevance:0 +},s=Me.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Me.filter((e=>"string"!=typeof e)).concat(Oe).map(Ne),...xe)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Ce),literal:Ae},c=[i,r,o],g=[{ +match:b(/\./,m(...Te)),relevance:0},{className:"built_in", +match:b(/\b/,m(...Te),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Ie},{match:`\\.(\\.|${De})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),w=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),v=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),w(e)] +}),N=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),w(e)]}),k={ +className:"string", +variants:[v(),v("#"),v("##"),v("###"),N(),N("#"),N("##"),N("###")] +},x=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],O={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:x},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...x,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},A={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,$e,/`/) +},C=[S,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${Be}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:je,contains:[...p,f,k]}]}},{ +scope:"keyword",match:b(/@/,m(...ze),d(m(/\(/,/\s+/)))},{scope:"meta", +match:b(/@/,$e)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Be,"+") +},{className:"type",match:Fe,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Fe)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b($e,/\s*:/),keywords:"_|0",relevance:0 +},...a,A,...c,...g,...p,f,k,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b($e,/\s*:/)),d(b($e,/\s+/,$e,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:$e}]},...a,...c,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(S.match,$e,Ie)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},F={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Ie],className:{ +1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,Fe],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Se,...Ae],end:/}/},U={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,$e,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:l,contains:[L,...c,{begin:/:/, +end:/\{/,keywords:l,contains:[{scope:"title.class.inherited",match:Fe},...c], +relevance:0}]};for(const e of k.variants){ +const n=e.contains.find((e=>"interpol"===e.label));n.keywords=l +;const t=[...c,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:l,contains:[...a,$,F,{ +match:[/class\b/,/\s+/,/func\b/,/\s+/,/\b[A-Za-z_][A-Za-z0-9_]*\b/],scope:{ +1:"keyword",3:"keyword",5:"title.function"}},{match:[/class\b/,/\s+/,/var\b/], +scope:{1:"keyword",3:"keyword"}},U,z,j,{beginKeywords:"import",end:/$/, +contains:[...a],relevance:0},A,...c,...g,...p,f,k,...C,...T,R,I]}}, +grmr_typescript:e=>{ +const n=e.regex,t=ve(e),a=me,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ +begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} +},s={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ +keyword:"interface extends",built_in:i},contains:[t.exports.CLASS_REFERENCE] +},o={$pattern:me, +keyword:pe.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:_e,built_in:we.concat(i),"variable.language":ye},l={className:"meta", +begin:"@"+a},c=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(l) +;const d=t.contains.find((e=>"attr"===e.scope)),g=Object.assign({},d,{ +match:n.concat(a,n.lookahead(/\s*\?:/))}) +;return t.exports.PARAMS_CONTAINS.push([t.exports.CLASS_REFERENCE,d,g]), +t.contains=t.contains.concat([l,r,s,g]), +c(t,"shebang",e.SHEBANG()),c(t,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),t.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(t,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),t},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}], +contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{ +begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{variants:[{ +begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{ +begin:/[^\s,{}[\]]+/}]}),r={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n, +relevance:0},s={begin:/\{/,end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={ +begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},l=[{ +className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{ +begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{ +begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta", +begin:"^---\\s*$",relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,{ +className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/, +scope:"char.escape",relevance:0}]},a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const Pe=ne;for(const e of Object.keys(Ue)){ +const n=e.replace("grmr_","").replace("_","-");Pe.registerLanguage(n,Ue[e])} +return Pe}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 047e8fd..f2be773 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -4,14 +4,18 @@
← Все группы -
+
-

Группа

+

Группа

Студентов: 0

-
+
- Выгрузить XLSX + + Скачать все работы + Логины XLSX CSV
@@ -19,73 +23,141 @@
- Новый пароль: password + Новый пароль: password
-
-
-

Импорт: группа

-

- Новых студентов: 0, - обновлено: 0. -

-
-

Сохраните пароли — они показываются для выдачи студентам.

- - - - - - - - - -
ФИОЛогинПароль
———
-
+
+

Результаты по лабораторным

+
+ + +
+
+

+ Значки в ячейке: «№» — вариант, «🛡» — защита, «↓» — сдача (скачать), «✎» — оценка. + Наведите на значок — появится пояснение; клик по «↓» скачивает последнее решение, + клик по «✎» открывает оценку и комментарии. Раздача вариантов — на странице лабораторной. +

+
Опубликованных лабораторных пока нет.
+
+
+ + + + + + + + + + + + + + +
Студент +
+
+ ЛР +
+ + + + +
+
+
+
+
—
+
—
+
+
+ + №12 + + 🛡— + 🛡⌛ + 🛡🔑 + 🛡⏳ + 🛡✓ + ↓1 + ↓— + +
+
В группе нет студентов.
-

Баллы за лабораторные

-

Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.

-
- - - - - - - - +
+

Индивидуальные задания (защита)

+
+ + + + +
+
+
+
Студент - ЛР - Средний
+ - - - + - + + + - - - +
СтудентЛРЗаданиеСтатусДействия
— -
-
-
- - - -
-
-
- коммент -
-
+
+
—
+
—
- — - — + —— + ждёт кода + код выдан + пишет ( c) + готов + +
+ + + + +
+ + +
+ + + + +
В группе нет студентов.
Заданий для защиты пока нет.
@@ -93,27 +165,182 @@

Студенты

- + - + - - - - - + + + + + - +
№ФИОЛогинОсноваПароль
№ФИОЛогинОсноваВариантыПарольДействия
1———временный - изменён -
- - +
——— + — + не распределены + временный + изменён + + + + +
+ +
В группе пока нет студентов.
В группе пока нет студентов.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Студент добавлен

@@ -121,128 +348,10 @@

Логин: —

Пароль: —

- - -
- - - - - - - - - - - - -
+ + +
- \ No newline at end of file + diff --git a/web/src/main/resources/templates/admin/lab-edit.html b/web/src/main/resources/templates/admin/lab-edit.html index f2d16ed..ab75661 100644 --- a/web/src/main/resources/templates/admin/lab-edit.html +++ b/web/src/main/resources/templates/admin/lab-edit.html @@ -1,85 +1,116 @@ -Правка лабы +Настройка лабы
← К лабораторной -

Правка лабораторной

+

Настройка лабораторной

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
+
+ -

Варианты

-
-

Уровень

-
- - - +

Параметры

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +

Уровни

+
+ + + + + + + + + + +
КодНазвание
basic + + + + +
Уровней нет — добавьте ниже.
+
+
+ + Новый уровень +
+
+
+
+
+
+
+ +

Варианты

+
Сначала создайте уровень — затем варианты.
+ +
+

Уровень

+
+ +
-
-
+
+
+
+
-
-
- -
- - -
- - -
+
+ +
-
-

Добавить вариант

-
-
-
-
-
-
+
+ + Добавить вариант +
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
-
-
-
-
- -
- \ No newline at end of file + diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 14ed4a9..4ecac4a 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -8,309 +8,189 @@

ЛР 1

- Статус:

+ Статус: + + th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" + th:text="${lab.status}">draft
· Срок: · Защита: · Максимум: б. + Вариантов назначено: + Занято вариантов: из + Свободно: + Сдано:

- -
- - -
-
- - -
+ Редактировать +
-

Условие

+

Условие лабораторной

-
-

Уровни и варианты

- +
+

Назначения студентов

+

+ Смена уровня сразу подбирает вариант; конкретный вариант можно закрепить вручную. +

+
+
Нет студентов для назначения.
+ +
+
+ +
+ + + +
+
+ + + +
+
+
+
+

Группа

+
+ + + + + + + + + + + + + + + +
ВыборСтудентУровеньВариантИсточникСдача
+
—
+
—
+
+
+ + + + +
+
+
+ + + + +
+
+ авто + + + попытка 1 ↓ + — +
+
+
+
+ +

Уровни и варианты

+
Уровней пока нет — добавьте на экране настройки.
-
-

- basic — - Уровень - (0 вар.) -

-
- -
- - -
- -
+
+

Уровень

+ 0 +
+
+ + + + + + + + + + +
№НазваниеУсловие
1——
Вариантов нет.
- - - - - - - - - - - -
№НазваниеУсловие
——— - -
- - -
-
Вариантов пока нет.
-

Студенты и варианты

-
-

Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.

- -
-
-

Группа

-
- - - - - - - - - - - - - - - -
СтудентЛогинУровеньВариантИсточникСдачаЗащита
—— -
- - - - -
-
-
- - - - -
-
——↓ - не сдано - не назначено - код - ждёт задания - на защите (с) - готов к защите -
- - - - -
-
В группе нет студентов.
-
-
- - -

Индивидуальные задания для защиты

-
- -
- - - -
-
-

CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.

- - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ + + +
+ - +
- \ No newline at end of file + diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html index e4105b8..1f8a49b 100644 --- a/web/src/main/resources/templates/admin/labs.html +++ b/web/src/main/resources/templates/admin/labs.html @@ -5,7 +5,10 @@

Лабораторные

- +
+ + +

Статус draft — черновик (редактирование, студентам не видна). published — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.

@@ -36,6 +39,29 @@
+ + + + + + +
+ diff --git a/web/src/main/resources/templates/student/labs.html b/web/src/main/resources/templates/student/labs.html index 60e5fc7..8396b01 100644 --- a/web/src/main/resources/templates/student/labs.html +++ b/web/src/main/resources/templates/student/labs.html @@ -14,7 +14,7 @@ Вариант сдано (попытка ) не сдано - оценка + оценка:

—