diff --git a/README.md b/README.md
index 2acf704..b83dbf3 100644
--- a/README.md
+++ b/README.md
@@ -1,167 +1,102 @@
# Parser Lab
-Учебный сайт для сбора данных: преподаватель импортирует CSV, JSON или JSONL, выбирает оформление и публикует две версии страниц — обычный HTML с пагинацией и HTML с динамической дозагрузкой. Приложение работает на FastAPI, Jinja2, HTML, CSS и небольших JavaScript-модулях без UI-фреймворков. Один аккаунт преподавателя; студентам вход не нужен.
+Parser Lab — сайт для учебных заданий по веб-парсингу. Преподаватель загружает CSV, JSON или JSONL, настраивает отображение данных и публикует страницы для студентов. Каждая коллекция доступна в двух вариантах: обычный HTML с пагинацией и страница с динамической дозагрузкой.
-## Быстрый запуск через Docker
+Есть четыре представления: таблица, карточки, сообщения и анкеты. Поля в списке и в раскрытой записи можно настраивать отдельно. Для преподавателя предусмотрен один аккаунт; публичные страницы открываются без входа.
-Нужны Docker Engine с Compose либо Docker Desktop. Запускайте команды из папки проекта.
+Приложение написано на Python 3.12, FastAPI и Jinja2. Интерфейс — HTML, CSS и JavaScript, данные хранятся в SQLite. Отдельный сервер базы данных не нужен.
-1. Скопируйте `.env.example` в `.env` и соберите образ:
+## Запуск через Docker Compose
- ```sh
- docker compose build
- ```
+Нужен Docker с командой `docker compose`: Docker Engine с Compose plugin или Docker Desktop. Python и uv на хосте не требуются. Все команды ниже выполняются из корня репозитория. Примеры рассчитаны на Bash; в Windows можно использовать WSL2.
-2. Укажите логин и обычный пароль (не менее 10 символов) в `.env`:
+### 1. Подготовьте конфигурацию и соберите образ
- ```dotenv
- LAB_ADMIN_USER=teacher
- LAB_ADMIN_PASSWORD='ваш-пароль-для-входа'
- ```
+При первом запуске создайте `.env` из шаблона:
- Одинарные кавычки сохраняют символы `$`, пробелы и `#`. Если пароль содержит кавычки или обратные слеши, используйте `uv run --locked python -m parser_lab.cli init-env` для создания корректного `.env` вместо ручного копирования шаблона.
+```sh
+cp .env.example .env
+docker compose build
+```
-3. Создайте случайный секрет:
+Если `.env` уже настроен, повторно копировать шаблон не нужно.
- ```sh
- docker compose run --rm --no-deps app uv run --no-sync python -c "import secrets; print(secrets.token_urlsafe(48))"
- ```
+### 2. Задайте пароль и секрет
- Поместите значение в `LAB_SECRET`. Не используйте пароль как секрет.
+Сгенерируйте секрет для сессий:
-4. Для локальной проверки оставьте `LAB_ORIGINS=http://localhost:8080` и `LAB_SECURE_COOKIE=false`. Запустите:
+```sh
+docker run --rm parser-lab:1.0 python -c 'import secrets; print(secrets.token_urlsafe(48))'
+```
- ```sh
- docker compose up -d
- docker compose ps
- ```
-
-Откройте [кабинет](http://localhost:8080/admin). Проверка сервиса: [healthz](http://localhost:8080/healthz).
-
-Если меняете порт, например на 8087, измените одновременно `LAB_PORT=8087` и `LAB_ORIGINS=http://localhost:8087`. Имя хоста тоже имеет значение: localhost и 127.0.0.1 — разные origin. Через запятую можно указать несколько разрешённых адресов.
-
-## Размещение в интернете
-
-Разместите приложение в корне отдельного домена, например `https://lab.example.org`. Установите:
+Откройте `.env`. Замените значения `LAB_ADMIN_PASSWORD` и `LAB_SECRET` своими:
```dotenv
-LAB_ORIGINS=https://lab.example.org
-LAB_SECURE_COOKIE=true
+LAB_ADMIN_USER=teacher
+LAB_ADMIN_PASSWORD='ЗАМЕНИТЕ_НА_СВОЙ_ПАРОЛЬ'
+LAB_SECRET=ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_СЕКРЕТ
+LAB_ORIGINS=http://localhost:8080
+LAB_SECURE_COOKIE=false
+LAB_PORT=8080
```
-Передайте домен на reverse proxy с HTTPS. Пример nginx, если приложение доступно прокси на порту 8080:
+Пароль должен содержать не менее 10 символов, секрет — не менее 32. Это разные значения. В `.env` пароль хранится обычным текстом; файл исключён из Git и сборки образа.
-```nginx
-location / {
- proxy_pass http://127.0.0.1:8080;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- client_max_body_size 26m;
-}
-```
+Одинарные кавычки сохраняют `$`, `#` и пробелы в пароле. Правила экранирования описаны в [документации Compose](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#env-file-syntax). При локальной установке файл можно создать интерактивной командой `init-env`, приведённой ниже.
-TLS-сертификат и DNS настраиваются на вашем сервере. Ограничьте прямой доступ к порту приложения средствами сервера или привяжите порт в Compose к 127.0.0.1, если прокси находится на той же машине. Подкаталоги вида `/my-lab/` и несколько реплик приложения не поддерживаются первой версией.
-
-## Как опубликовать данные
-
-1. Войдите в кабинет и нажмите «Загрузить данные».
-2. Загрузите файл. При необходимости выберите кодировку и разделитель CSV либо массив записей внутри JSON.
-3. Нажмите «Проверить файл»: появятся количество записей, предупреждения и первые 20 строк. Сохраните импорт.
-4. Выберите представление и включите его. Отметьте поля, задайте подписи, порядок, поиск и категориальный фильтр. Для карточек назначьте заголовок; для сообщений — текст, при желании автора и дату.
-5. Проверьте живой предпросмотр, сохраните черновик и откройте полный предпросмотр.
-6. Опубликуйте версию. В разделе публикаций появятся ссылки для студентов.
-
-Один набор может иметь все четыре представления. Изменения черновика не затрагивают опубликованные данные. Повторная публикация создаёт новые ссылки с номером версии; прежние продолжают работать. Снятие версии с публикации делает её недоступной. Уже скачанные студентом данные, разумеется, не исчезают.
-
-При замене исходного файла настройки представлений сбрасываются: их нужно проверить заново. Изменение названия после публикации разрешено, изменение адреса набора — нет. Если набор отредактирован в другой вкладке, сохранение сообщит о конфликте, а не затрёт изменения.
-
-## Форматы файлов
-
-| Формат | Поддержка |
-|---|---|
-| CSV | Первая строка — уникальные непустые заголовки; UTF-8 или Windows-1251; запятая, точка с запятой или табуляция |
-| JSON | Массив объектов, одиночный объект или выбранный вложенный массив объектов |
-| JSONL | По одному объекту JSON в каждой непустой строке; UTF-8 |
-
-Лимиты по умолчанию: 25 МиБ, 50 000 записей, глубина JSON 20. Размер файла и количество записей можно изменить через `LAB_MAX_BYTES` и `LAB_MAX_RECORDS`. Большие наборы требуют больше памяти: текущая версия хранит JSON-снимки в SQLite и загружает снимок для выборки. Проверяйте скорость на своих данных до занятия.
-
-Дубликаты и исходный порядок сохраняются. Значения CSV остаются строками, чтобы не потерять ведущие нули и исходное представление чисел. В JSON различаются отсутствие поля, null, пустая строка, false и 0. Ошибки формата блокируют импорт; повреждённые строки не пропускаются молча. Пустой набор не публикуется.
-
-Вложенные поля показаны путями: `/profile/age` означает поле age в объекте profile. Символ `/` в самом имени поля кодируется как `~1`, а `~` — как `~0`. Массивы сохраняются; специальные анкеты могут отображать их как пары вопросов и ответов.
-
-### Заполненные анкеты
-
-Для таблицы с колонками `participant, q1, q2` выберите «Колонки с ответами» и замените подписи q1/q2 текстами вопросов.
-
-Для `examples/data/questionnaires.json` достаточно выбрать файл, нажать «Проверить файл» и «Сохранить и настроить». Приложение распознаёт участников и пары `question`/`answer`, выбирает `/participants` и создаёт готовое представление «Анкеты». Предпросмотр сразу показывает двух участников и их ответы. Распознавание зависит от структуры данных, а не имени файла.
-
-Если структура неоднозначна, выберите уровень записей вручную. Для ранее загруженного целиком документа в редакторе доступна кнопка «Настроить анкеты автоматически»: она исправляет черновик без повторной загрузки исходного файла. Опубликованные версии сохраняются.
-
-Сайт отображает готовые данные; он не собирает ответы и не интерпретирует психологические шкалы. Дополнительные примеры синтетические. Хоккейный CSV перенесён из предыдущего проекта.
-
-## Парсинг
-
-Адреса имеют вид:
-
-```text
-/html/study-abc/v/1/table
-/interactive/study-abc/v/1/table
-```
-
-Для HTML данные находятся в исходном ответе сервера. Пагинация — `?page=2`, следующая ссылка — `a[rel=next]`. Стабильные селекторы:
-
-| Селектор | Значение |
-|---|---|
-| `.record[data-record-id]` | Запись со стабильным в пределах версии id |
-| `.field[data-field]` | Значение выбранного поля |
-| `data-value-state` | `missing`, `null` или `value` |
-| `data-value-type` | Исходный тип: string, number, boolean, object, array, null или missing |
-| `data-role` | Роль значения: title, text, author или date |
-| `data-label` | Подпись поля или текст вопроса |
-| `.detail-link` | Ссылка на отдельную запись |
-
-Фильтры HTML: `q` — регистронезависимый поиск в назначенном поле, `category` — JSON-представление скалярного значения. Формы на странице формируют правильные параметры автоматически. Поиск не выполняет морфологический анализ. При неправильном номере страницы возвращается 400, при отсутствии страницы или версии — 404.
-
-В динамическом варианте первые записи также находятся в HTML-ответе. Кнопка «Загрузить ещё», поиск и фильтры обновляют данные обычными HTTP-запросами через JavaScript. Для практики браузерного парсинга используйте Playwright. Оба варианта используют один шаблон, стили и favicon.
-
-Примеры решений находятся в `examples/parsers/` и не доступны как файлы через публичный сайт:
+### 3. Запустите приложение
```sh
-uv sync --locked
-uv run --locked playwright install chromium
-uv run --locked python examples/parsers/scrape_html.py "http://localhost:8080/html/SLUG/v/1/table" --output html.jsonl
-uv run --locked python examples/parsers/scrape_interactive.py "http://localhost:8080/interactive/SLUG/v/1/table" --output interactive.jsonl
-```
-
-Вместо загруженного Chromium можно использовать установленный Chrome: добавьте `--channel chrome` второму парсеру.
-
-## Обновление и резервная копия
-
-Данные находятся в named volume `lab-data`, смонтированном в `/data`. `docker compose down` сохраняет том. **Не используйте `down -v`, если данные нужны.**
-
-Обновление:
-
-```sh
-docker compose down
-docker compose build
docker compose up -d
+docker compose ps
```
-Перед обновлением остановите контейнер и скопируйте весь каталог данных:
+Кабинет преподавателя: [http://localhost:8080/admin](http://localhost:8080/admin). Войдите с логином и паролем из `.env`.
+
+## Запуск через Docker без Compose
+
+Соберите тот же образ:
```sh
-docker compose stop app
-docker compose cp app:/data ./backup-data
-docker compose start app
+docker build -t parser-lab:1.0 .
```
-Храните копию вместе с защищённой копией `.env`. Для восстановления подготовьте отдельный Compose-проект с новым томом, создайте его контейнер (`docker compose create`), скопируйте содержимое backup-data в `/data`, восстановите владельца UID 10001 при необходимости и запустите контейнер. Проверяйте восстановление отдельно от рабочего сайта.
+Создайте и заполните `.env`, как описано выше. Команда генерации секрета также работает без Compose.
-Импортированные файлы и старые снимки не удаляются автоматически. Следите за размером тома. Безвозвратное удаление истории не входит в интерфейс первой версии.
+Создайте том и запустите контейнер:
-## Локальная разработка
+```sh
+docker volume create parser-lab-data
-Нужен [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Python 3.12 и окружение `.venv` управляются через uv. Запускайте команды из корня проекта:
+docker run -d \
+ --name parser-lab \
+ --restart unless-stopped \
+ --stop-timeout 30 \
+ -p 8080:8080 \
+ --mount type=volume,source=parser-lab-data,target=/data \
+ --mount "type=bind,source=$(pwd)/.env,target=/app/.env,readonly" \
+ parser-lab:1.0 \
+ uv run --no-sync --env-file /app/.env python -m parser_lab
+```
+
+Здесь `.env` подключается [как файл только для чтения](https://docs.docker.com/engine/storage/bind-mounts/) и загружается через uv. Так сохраняется обработка кавычек в пароле. Файл должен существовать и быть доступен на чтение пользователю контейнера с UID 10001.
+
+Адрес кабинета тот же: [http://localhost:8080/admin](http://localhost:8080/admin). Управление контейнером:
+
+```sh
+docker stop parser-lab
+docker start parser-lab
+```
+
+При таком запуске `LAB_PORT` не используется: внешний порт задаётся параметром `-p`. Для порта 8087 укажите `-p 8087:8080` и `LAB_ORIGINS=http://localhost:8087` в `.env`.
+
+Том `parser-lab-data` из этого примера и том Compose — разные хранилища. Данные между ними автоматически не переносятся.
+
+## Локальный запуск без Docker
+
+Установите [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Проект использует Python 3.12; uv может установить его и создать виртуальное окружение.
+
+Из корня репозитория выполните:
```sh
uv sync --locked
@@ -169,21 +104,190 @@ uv run --locked python -m parser_lab.cli init-env
uv run --locked --env-file .env python -m parser_lab
```
-`init-env` один раз создаёт `.env` и запрашивает параметры преподавателя. При последующих запусках достаточно последней команды. Активация окружения не требуется.
+`init-env` спросит логин и пароль, сгенерирует секрет и запишет `.env`. Если файл уже существует, команда откажется его перезаписывать — в этом случае пропустите её и проверьте текущие настройки.
-Зависимости приложения и группа `dev` описаны в `pyproject.toml`, точные версии зафиксированы в `uv.lock`. Для изменения зависимостей используйте `uv add ИМЯ` или `uv add --dev ИМЯ`; сохраняйте оба файла в Git. Устаревшие `requirements*.lock` больше не используются. Docker собирает production-окружение командой `uv sync --locked --no-dev` и запускает готовое окружение через `uv run --no-sync`.
+Кабинет доступен по адресу [http://localhost:8080/admin](http://localhost:8080/admin). Для остановки нажмите `Ctrl+C`. При следующих запусках достаточно последней команды; активировать `.venv` вручную не нужно.
-Параметры окружения имеют приоритет над локальной `.env`. Пароль LAB_ADMIN_PASSWORD и секрет LAB_SECRET обязательны, приложение откажется запускаться без них. После изменения пароля или секрета перезапустите приложение (для Docker: `docker compose up -d --force-recreate`); старые сессии станут недействительны.
+По умолчанию данные сохраняются в `./data`. Каталог создаётся автоматически. [Параметры окружения имеют приоритет над значениями из `.env`](https://docs.astral.sh/uv/concepts/configuration-files/#env).
-Тесты:
+Для разработки с перезапуском при изменении кода:
```sh
-uv run --locked pytest -q
-# Дополнительные браузерные тесты: LAB_BROWSER_TESTS=1, при необходимости PLAYWRIGHT_CHANNEL=chrome.
+uv run --locked --env-file .env uvicorn parser_lab.app:create_app \
+ --factory --host 127.0.0.1 --port 8080 --reload
```
-Старые документы проектирования в `docs/` сохранены как история. Актуальная архитектура — FastAPI + Jinja2 + HTML/CSS/JavaScript; зависимости Rio полностью удалены. Существующие SQLite-данные и опубликованные ссылки совместимы с новой версией. Фактические результаты проверки и ограничения среды — в `docs/verification.md`.
+При обычном запуске через `python -m parser_lab` адрес сервера — `0.0.0.0:8080`. Переменная `LAB_PORT` его не меняет. Если нужен другой порт, используйте команду uvicorn с нужным `--port` и измените `LAB_ORIGINS`.
-Справка: [uv в Docker](https://docs.astral.sh/uv/guides/integration/docker/), [запуск команд](https://docs.astral.sh/uv/concepts/projects/run/).
+## Настройки
-Пароль хранится в `.env` обычным текстом. Внутренний Argon2-хеш для проверки входа приложение создаёт в памяти при запуске. Если `.env` остался от предыдущей версии, замените строку `LAB_ADMIN_HASH=...` на `LAB_ADMIN_PASSWORD='ваш-пароль'`; прежний хеш больше не используется. `.env` исключён из Git и Docker-образа.
+| Переменная | Назначение |
+| --- | --- |
+| `LAB_ADMIN_USER` | Логин преподавателя. Обязателен. |
+| `LAB_ADMIN_PASSWORD` | Пароль, не менее 10 символов. Обязателен. |
+| `LAB_SECRET` | Случайный секрет сессий, не менее 32 символов. Обязателен. |
+| `LAB_ORIGINS` | Разрешённые адреса сайта через запятую, включая протокол и порт. По умолчанию `http://localhost:8080`. |
+| `LAB_SECURE_COOKIE` | `false` для локального HTTP, `true` для HTTPS. Если не задана, используется `true`. |
+| `LAB_PORT` | Внешний порт в Compose. По умолчанию 8080. |
+| `LAB_DATA_DIR` | Каталог данных. По умолчанию `./data`, в Docker — `/data`. Compose задаёт `/data` явно. |
+| `LAB_MAX_BYTES` | Максимальный размер загружаемого файла. По умолчанию 26214400 байт, то есть 25 МиБ. |
+| `LAB_MAX_RECORDS` | Максимальное число записей в импорте. По умолчанию 50000. |
+
+`localhost` и `127.0.0.1` считаются разными адресами. Если используете оба, добавьте оба в `LAB_ORIGINS`:
+
+```dotenv
+LAB_ORIGINS=http://localhost:8080,http://127.0.0.1:8080
+```
+
+После изменения `.env` перезапустите локальный процесс или пересоздайте контейнер Compose:
+
+```sh
+docker compose up -d --force-recreate
+```
+
+Для Docker без Compose остановите и удалите контейнер, затем повторите команду `docker run` с тем же томом. Это также заново подключит изменённый `.env`. После смены логина, пароля или секрета прежние сессии перестанут действовать.
+
+## Развёртывание на сервере
+
+Приложение рассчитано на один экземпляр и размещение в корне отдельного домена, например `https://lab.example.org`. Запуск в подкаталоге вроде `/parser/` не поддерживается.
+
+1. Настройте DNS домена на сервер и HTTPS в обратном прокси.
+2. Запустите приложение одним из способов выше.
+3. Укажите публичный адрес в `.env`:
+
+ ```dotenv
+ LAB_ORIGINS=https://lab.example.org
+ LAB_SECURE_COOKIE=true
+ ```
+
+4. Если nginx работает на том же хосте, ограничьте публикацию порта адресом `127.0.0.1`. В `compose.yaml` замените строку в `ports` на:
+
+ ```yaml
+ - "127.0.0.1:${LAB_PORT:-8080}:8080"
+ ```
+
+ Для обычного Docker используйте `-p 127.0.0.1:8080:8080`. Для локального процесса — uvicorn с `--host 127.0.0.1`.
+
+5. Добавьте в HTTPS-блок `server` nginx:
+
+ ```nginx
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ client_max_body_size 26m;
+ }
+ ```
+
+6. Примените конфигурацию nginx и перезапустите приложение. Проверьте `/healthz`, вход в кабинет и загрузку небольшого файла через домен.
+
+Этот фрагмент предполагает уже настроенный TLS-сертификат. Если прокси работает в другом контейнере, подключите оба сервиса к общей сети и укажите адрес приложения в ней: `127.0.0.1` внутри контейнера прокси указывает на сам прокси.
+
+## Работа с коллекциями
+
+1. Войдите в кабинет и нажмите «Загрузить данные».
+2. Выберите файл. Для CSV при необходимости укажите кодировку и разделитель; для вложенного JSON — список, из которого нужно брать записи.
+3. Нажмите «Проверить файл», проверьте первые строки и сохраните импорт.
+4. Включите нужное представление, выберите поля, подписи и порядок. Для карточек назначьте заголовок, для сообщений — текст.
+5. Чтобы раскрытая запись содержала больше информации, выберите «Настройка полей → В полной записи» и включите «Отдельные поля полной записи».
+6. Проверьте результат через «Предпросмотр данных», сохраните черновик и опубликуйте новую версию. Ссылки появятся в разделе «Публикации».
+
+Предпросмотр показывает текущие изменения. Ссылка «Предпросмотр» открывает сохранённый черновик. Публикации хранятся отдельно: редактирование черновика не меняет страницы, которые уже доступны студентам. Новая публикация получает новый номер и новые ссылки; прежние работают, пока версия не снята с публикации.
+
+Замена исходного файла сбрасывает настройки представлений. После первой публикации адрес коллекции изменить нельзя.
+
+Кнопка «Удалить коллекцию» удаляет черновик, все публикации и связанные исходные файлы. Действие требует подтверждения и не имеет отмены. Если файл не удалось удалить, приложение сообщит об этом и повторит очистку при следующем запуске. Промежуточные исходники от замен в старых версиях могли потерять связь с коллекцией; такие файлы автоматически не удаляются.
+
+### Форматы данных
+
+| Формат | Требования |
+| --- | --- |
+| CSV | Первая строка — уникальные непустые заголовки. Кодировка UTF-8 или Windows-1251. Разделитель — запятая, точка с запятой или табуляция. |
+| JSON | Массив объектов, один объект или выбранный вложенный массив объектов. |
+| JSONL | Один объект JSON на строку, кодировка UTF-8. Пустые строки пропускаются. |
+
+Порядок записей и дубликаты сохраняются. Значения CSV остаются строками, в том числе числа с ведущими нулями. В JSON различаются отсутствующее поле, `null`, пустая строка, `false` и `0`. Максимальная глубина JSON — 20. Ошибки формата блокируют импорт; пустую коллекцию опубликовать нельзя.
+
+Вложенные поля обозначаются путями: `/profile/age` — поле `age` внутри `profile`. Массивы вопросов с полями `question` и `answer` приложение умеет распознавать как анкеты. Для неоднозначной структуры уровень записей и поля можно выбрать вручную. Сайт отображает загруженные ответы, но не собирает новые.
+
+## Обновление и резервные копии
+
+SQLite и исходные файлы находятся в одном каталоге. Локально это `data/`, в контейнере — `/data`. Compose сохраняет его в томе с именем вида `<имя-проекта>_lab-data`.
+
+Перед обновлением остановите приложение и скопируйте весь каталог данных. Для каждой копии используйте новый каталог. Пример для Compose:
+
+```sh
+mkdir -p backups/before-update
+docker compose stop app
+docker compose cp app:/data/. ./backups/before-update/
+docker compose start app
+```
+
+Для обычного Docker аналогичные команды — `docker stop parser-lab`, `docker cp parser-lab:/data/. ./backups/before-update/` и `docker start parser-lab`. При локальном запуске остановите процесс и скопируйте `data/`. Отдельно сохраните `.env` в защищённом месте.
+
+После получения новой версии исходного кода обновите Compose-развёртывание:
+
+```sh
+docker compose up -d --build
+docker compose ps
+```
+
+Для Docker без Compose пересоберите образ, остановите и удалите контейнер, затем повторите команду `docker run`, сохранив прежний том `parser-lab-data`. Локально выполните `uv sync --locked` и перезапустите приложение.
+
+Восстановление копии в **новую установку Compose с пустым томом**:
+
+```sh
+docker compose create app
+docker compose cp ./backups/before-update/. app:/data/
+docker compose run --rm --no-deps --user root app chown -R 10001:10001 /data
+docker compose up -d
+```
+
+Перед этим подготовьте образ и `.env`. После восстановления проверьте вход в кабинет и открытие опубликованной коллекции.
+
+## Для разработки и учебных заданий
+
+Основные файлы проекта:
+
+```text
+src/parser_lab/
+ app.py HTTP-маршруты и запуск приложения
+ admin.py Импорт и настройки коллекций
+ repository.py SQLite, исходные файлы и публикации
+ importing.py Чтение CSV, JSON и JSONL
+ views.py Выбор полей и подготовка записей
+ web/templates/ Шаблоны страниц
+ web/static/ Стили и JavaScript
+examples/parsers/ Примеры парсеров
+tests/ Проверки приложения
+```
+
+Установка зависимостей и запуск тестов:
+
+```sh
+uv sync --locked
+uv run --locked pytest -q
+```
+
+Браузерные тесты запускаются отдельно:
+
+```sh
+uv run --locked playwright install chromium
+LAB_BROWSER_TESTS=1 uv run --locked pytest -q tests/test_browser.py
+```
+
+При использовании установленного Chrome задайте `PLAYWRIGHT_CHANNEL=chrome`. Фикстура браузерных тестов также поддерживает `PLAYWRIGHT_EXECUTABLE_PATH` для явного пути к браузеру. Несколько тестов анкет ожидают файл `examples/data/questionnaires.json`; если его нет в рабочей копии, эти проверки завершатся с `FileNotFoundError`.
+
+Примеры парсеров принимают ссылку на опубликованную коллекцию:
+
+```sh
+uv run --locked python examples/parsers/scrape_html.py \
+ "http://localhost:8080/html/SLUG/v/1/table" --output html.jsonl
+
+uv run --locked python examples/parsers/scrape_interactive.py \
+ "http://localhost:8080/interactive/SLUG/v/1/table" --output interactive.jsonl
+```
+
+Замените `SLUG` и номер версии значениями из ссылки в кабинете. Для второго примера нужен браузер Playwright; установленный Chrome можно выбрать аргументом `--channel chrome`.
+
+В HTML записи доступны по селектору `.record[data-record-id]`, значения — `.field[data-field]`. Ссылка `.detail-link` открывает полную запись, `a[rel=next]` ведёт на следующую страницу. Атрибуты `data-label`, `data-value-state` и `data-value-type` содержат подпись, состояние и тип значения. В динамическом варианте первые записи тоже находятся в HTML; остальные загружаются кнопкой «Загрузить ещё».
diff --git a/docs/design-spec.md b/docs/design-spec.md
deleted file mode 100644
index 860a01a..0000000
--- a/docs/design-spec.md
+++ /dev/null
@@ -1,156 +0,0 @@
-> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md.
-
-# Спецификация учебного сайта для парсинга данных
-
-Документ описывает первую версию сайта для занятий по основам ML, в том числе для психологов. Преподаватель загружает данные неизвестной заранее структуры и публикует их в разных представлениях. Студенты собирают опубликованные записи обычными HTTP-парсерами или браузерной автоматизацией. Реализация начнётся после согласования спецификации и плана.
-
-## Согласованные требования
-
-- Один преподаватель, закрытый кабинет на Python Rio, вход по логину и паролю без регистрации.
-- Публичные учебные страницы без аккаунтов студентов.
-- Импорт CSV, JSON и JSONL через кабинет.
-- Таблицы, карточки, сообщения и заполненные анкеты; сбор новых ответов не входит в первую версию.
-- Два режима доступа к одному набору: HTML для requests, BeautifulSoup и Scrapy; динамический Rio для Playwright и Selenium.
-- Предпросмотр, черновики, публикация и снятие с публикации.
-- Docker Compose, постоянное хранение данных и возможность разместить приложение на выбранном сервере.
-- Современное адаптивное оформление и сохранение исходных пропусков и неоднородных значений.
-
-Описанные ниже конкретные правила, пределы и адреса являются предлагаемыми решениями для первой версии.
-
-## Архитектура
-
-Один Python-процесс и контейнер обслуживают Rio и HTML-маршруты через FastAPI. Кабинет расположен под `/admin`, динамические страницы под `/interactive`, серверные HTML-страницы под `/html`. Конкретную интеграцию проверяем на закреплённой версии rio-ui до разработки остальных экранов.
-
-Импорт, хранение, настройка полей и выборка записей образуют общее ядро. Оба механизма отображения получают одинаковые записи и конфигурацию; логика фильтрации и пагинации не дублируется. HTML генерируется сервером из шаблонов с экранированием текста. Полезное содержимое присутствует в исходном HTTP-ответе без запуска JavaScript.
-
-SQLite хранит наборы, записи, настройки представлений, черновики и публикации. Исходные файлы находятся отдельно в постоянном каталоге. Данные пользователя не размещаются в публичном каталоге статических файлов. Схема базы имеет версию и последовательные миграции.
-
-Модули разделяются по ответственности: импорт; модель данных и хранилище; публикации; авторизация; кабинет Rio; динамические представления Rio; HTML-маршруты и шаблоны. Запуск приложения не требует pandas для каждого запроса.
-
-## Путь преподавателя и экраны
-
-1. **Вход.** Логин и пароль, понятная ошибка без раскрытия существования пользователя, выход из кабинета.
-2. **Каталог.** Название набора, число записей, статус, время изменения и действия «Редактировать», «Предпросмотр», «Опубликовать», «Снять с публикации».
-3. **Импорт.** Выбор файла, параметры чтения, список ошибок и первые 20 записей. После подтверждения создаётся черновик; исходный файл сохраняется.
-4. **Настройка.** Название, описание, адрес набора, представления, поля, подписи и количество записей на странице.
-5. **Предпросмотр.** Проверка обоих режимов с реальными данными черновика. Предпросмотр доступен только преподавателю.
-6. **Публикация.** Итоговая проверка, создание версии и ссылки на HTML и Rio для каждого включённого представления.
-
-Изменения сохраняются явной кнопкой. При уходе с несохранёнными изменениями показывается предупреждение. Замена файла создаёт новую редакцию черновика и требует повторной проверки соответствия полей.
-
-## Правила импорта
-
-Начальные настраиваемые ограничения: файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней. Превышение вызывает понятную ошибку до публикации. Это границы приёма, а не обещание конкретной скорости работы; производительность проверяется отдельно на целевом оборудовании.
-
-### CSV
-
-Поддерживаются UTF-8 с BOM и без него, а также выбираемая Windows-1251. Преподаватель может указать запятую, точку с запятой или табуляцию; автоматическое предположение видно в предпросмотре. Первая строка содержит заголовки. Пустые и повторяющиеся заголовки блокируют импорт с указанием колонок. Поддерживаются кавычки, разделители и переводы строк внутри значений.
-
-Значения сохраняются строками, включая ведущие нули, даты и десятичные запятые. Пустая ячейка остаётся пустой строкой. Ошибка количества полей или синтаксиса сообщает позицию; повреждённые строки не пропускаются молча.
-
-### JSON
-
-Вход — массив объектов, одиночный объект как одна запись либо объект, внутри которого преподаватель выбирает массив объектов. Выбор вложенного массива не выполняется скрыто. Массив скалярных значений и смесь объектов со скалярами отклоняются с объяснением поддерживаемой структуры. Повторяющиеся ключи объектов и нестандартные NaN/Infinity отклоняются, чтобы исключить потерю значений.
-
-Объекты и массивы сохраняются вложенными. Поля выбираются деревом структуры; внутри конфигурации используются однозначные пути JSON Pointer. Отсутствующее поле, null, пустая строка, false и 0 остаются различимыми.
-
-### JSONL
-
-Каждая непустая строка содержит один JSON-объект. Пустые строки игнорируются с отображением их количества. Некорректная строка блокирует сохранение импорта; сообщение содержит номер строки и причину. Используется UTF-8 с допустимым BOM в начале файла.
-
-### Общие правила
-
-Порядок записей сохраняется. Дубликаты и пропуски автоматически не удаляются. Каждая запись получает внутренний идентификатор, который не зависит от наличия уникального поля в файле и стабилен внутри опубликованной версии. Поля определяются по всему принятому набору, а не только по предпросмотру. Если необязательного поля нет в части записей, это отражается при настройке.
-
-Некорректный импорт не заменяет существующие данные. Файлы хранятся под внутренними именами; пользовательское имя используется только для отображения. Загруженное содержимое считается данными и не исполняется.
-
-## Представления
-
-| Вид | Настройка | Отображение |
-|---|---|---|
-| Таблица | Произвольные выбранные поля, подписи и порядок колонок | Заголовки и строки, переходы на страницу записи |
-| Карточки | Заголовок, необязательное описание и дополнительные поля | Сетка карточек с переходом к деталям |
-| Сообщения | Текст, необязательные автор и дата, дополнительные поля | Линейная лента сообщений без веток обсуждений |
-| Анкеты | Идентификатор или заголовок участника, выбранные ответы | Заполненный бланк с вопросами и значениями |
-
-Для анкет поддерживаются два явных способа настройки: колонки как ответы с подписями вопросов либо вложенный массив пар «вопрос — ответ», для которого задаются пути к обоим значениям. Нет расчёта шкал, интерпретации результатов или конструктора опросов.
-
-Один набор может включать несколько представлений с независимыми настройками. Отсутствие необходимого поля, например текста сообщения, блокирует публикацию этого представления. Необязательные поля можно не назначать. Объекты и массивы в обычной ячейке выводятся как экранированный JSON; специальный способ вывода вложенных анкет применяется только при явной настройке.
-
-Для отсутствующего значения и null используются разные видимые обозначения; пустая строка остаётся пустой. Их семантика также доступна через атрибут состояния значения в HTML. Исходный тип не определяется по внешнему виду текста.
-
-## Учебные режимы
-
-Пример адреса HTML: `/html/study-a/v/1/table?page=2`. Пример Rio: `/interactive/study-a/v/1/table`. У каждого представления есть собственный сегмент адреса. В ссылках содержится версия публикации.
-
-### Обычный HTML
-
-Таблицы используют table, thead, tbody, th и td. Остальные представления используют обычные семантические блоки, заголовки и ссылки. Записи и значения имеют документированные устойчивые CSS-классы и идентификаторы. Ни данные, ни переходы не требуют JavaScript, WebSocket или особого User-Agent.
-
-Предлагаемые размеры страниц: 10, 25, 50, 100; по умолчанию 25. Порядок соответствует импорту. Страницы доступны через реальные ссылки. Нецелый, отрицательный или нулевой номер возвращает 400; номер за пределами набора — 404. Публикация пустого набора запрещена. Индивидуальные страницы записей дают упражнение на переходы по ссылкам.
-
-### Динамический Rio
-
-Первая порция содержит настроенное число записей. Кнопка «Загрузить ещё» добавляет следующую порцию, показывает состояние загрузки и исчезает после последней записи. Повторные нажатия во время запроса не создают дубликатов.
-
-Преподаватель выбирает одно текстовое поле для поиска и, при необходимости, одно поле категориального фильтра. Изменение фильтра сбрасывает список и позицию загрузки. Доступны состояния ожидания, отсутствия результатов и ошибки. Динамические упражнения не включают CAPTCHA, искусственные задержки или противодействие парсерам.
-
-В HTML те же фильтры передаются query-параметрами и применяются сервером. Для обоих режимов поиск — регистронезависимое вхождение в выбранное текстовое поле, категориальный фильтр — точное совпадение значения. Результат всегда сохраняет исходный порядок.
-
-Публичная выдача исходного файла и отдельный JSON API не включаются в первую версию. Это учебная организация доступа, а не гарантия, что видимые данные невозможно получить другим способом. Сервер отдаёт только поля, включённые в соответствующее представление; скрытые поля не передаются клиенту внутри полного объекта записи.
-
-## Публикации и сохранность
-
-Публикация атомарно создаёт неизменяемый снимок записей и настроек всех включённых представлений. Неудачная операция оставляет предыдущую публикацию доступной. Изменения черновика не затрагивают опубликованные версии.
-
-Повторная публикация создаёт новый номер и новые ссылки. Старые ссылки продолжают показывать прежний материал до снятия соответствующей версии с публикации. Это предотвращает изменение данных между запросами студенческого парсера. Снятая версия возвращает 404 в обоих режимах; черновик остаётся у преподавателя. В первой версии достаточно снятия с публикации, без интерфейса безвозвратного удаления истории.
-
-## Внешний вид
-
-Интерфейс на русском языке: светлый фон, тёмный читаемый текст, спокойный сине-зелёный акцент, единые размеры отступов и контрастные состояния элементов. Кабинет использует боковую навигацию на широком экране и компактную на узком. Основные действия визуально выделены, технические параметры появляются только там, где нужны для выбора.
-
-Таблицы прокручиваются горизонтально, длинные сообщения переносятся, анкеты сохраняют связь вопроса и ответа. Карточки перестраиваются в одну колонку на телефоне. HTML и Rio имеют общий визуальный стиль, но не требуют идентичной внутренней разметки. Шрифты и необходимые статические ресурсы поставляются с приложением, чтобы интерфейс не зависел от внешнего CDN.
-
-## Авторизация и Docker
-
-Учётные данные задаются при развёртывании: имя пользователя и хеш пароля через переменные окружения или файл секрета. Открытая регистрация и стандартный встроенный пароль отсутствуют. Инструкция содержит способ создания хеша и смены пароля. Вход ограничивается по частоте; сессия защищает все операции кабинета, предпросмотр и доступ к исходным файлам. Изменяющие операции проверяют подлинность сессии и защищены от CSRF. Пароли и данные анкет не попадают в журналы запросов.
-
-Контейнер запускается непривилегированным пользователем, слушает порт 8080 и использует `/data` как постоянный том. SQLite и исходные файлы находятся в этом томе. Порт хоста настраивается через Compose; доступны проверка работоспособности и корректное завершение процесса. При отсутствии настроек входа приложение сообщает ошибку запуска, а не открывает кабинет без защиты.
-
-HTTPS завершается на внешнем reverse proxy, который передаёт HTTP и WebSocket. Поддерживаемый вариант первой версии — приложение в корне отдельного домена; размещение под произвольным префиксом URL не обещается. При HTTPS cookie сессии получает Secure; для локального HTTP есть явная настройка. Работа нескольких реплик с общей SQLite в первую версию не входит.
-
-В комплект входят Dockerfile, compose.yaml, пример переменных окружения без секретов и инструкция запуска, обновления и резервного копирования. Простой согласованный способ резервного копирования — остановить контейнер и скопировать весь том; восстановление проверяется отдельно. Данные не встраиваются в образ как изменяемое хранилище.
-
-## Демонстрационные данные
-
-Старый хоккейный CSV сохраняется в качестве примера таблицы. Дополнительные небольшие синтетические наборы демонстрируют сообщения, карточки и оба формата заполненных анкет. Примеры включают Unicode, пустые значения, длинный текст и повторяющиеся значения. Демонстрационные наборы импортируются явно и не перезаписывают рабочие данные при перезапуске.
-
-Старый NiceGUI-проект остаётся источником примера, а не основой нового интерфейса. Совместимость старого адреса `?pageNum=` не входит в первую версию.
-
-## Проверка и критерии готовности
-
-1. Преподаватель импортирует каждый поддерживаемый формат без редактирования кода; ошибки показывают причину и расположение.
-2. Назначение полей работает с произвольными заголовками и вложенными значениями. Несовместимые настройки нельзя опубликовать.
-3. Для одного набора включаются несколько представлений, и каждое доступно в обоих режимах.
-4. Парсер requests и BeautifulSoup собирает все видимые записи по ссылкам и страницам без пропусков и дублей. Проверяются значения, а не только количество.
-5. Парсер Playwright получает те же записи, используя динамическую загрузку и фильтры. При отсутствии фильтров порядок совпадает с HTML.
-6. Редактирование и повторная публикация не изменяют старые опубликованные ссылки. Черновики, исходные файлы и скрытые поля недоступны студентам.
-7. Замена контейнера сохраняет данные; резервная копия восстанавливается в чистом окружении.
-8. Через браузер проверяются все четыре представления, узкий экран, длинные тексты, пустые значения, вход и ошибки загрузки.
-9. Контейнер собирается из документированной команды; проверяются старт, HTTP, WebSocket, том и завершение.
-
-Примеры парсеров передаются преподавателю отдельными файлами, без автоматической публикации решений на учебном сайте. Версии Python и библиотек закрепляются после проверки совместимости Rio, FastAPI и контейнера. До этого документация текущего Rio подтверждает направление интеграции, но не заменяет проверку установленной версии.
-
-## Границы первой версии
-
-Не входят: несколько преподавателей, аккаунты студентов, сбор ответов, оценивание работ, обучение моделей, произвольный редактор HTML, ветки комментариев, импорт по внешним URL, публичный API выгрузки, горизонтальное масштабирование и автоматическое размещение на конкретном сервере. Объём первой версии заканчивается работающим проверенным Docker-пакетом и инструкциями.
-
-## Последовательность дальнейшей работы
-
-После проверки этой спецификации готовится отдельный план реализации с файлами, зависимостями, этапами и проверками. Порядок: совместимость Rio и HTML в одном приложении; хранение и вход; импорт; настройка представлений; публикации и оба режима; оформление; парсеры и проверка Docker. Продуктовый код в рамках подготовки спецификации не изменяется.
-
-## Основания технических решений
-
-Изученный старый проект расположен в `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse`: NiceGUI, pandas, один CSV, страницы по 25 строк, Docker Compose. Окружение с установленным Rio в изученном проекте не найдено.
-
-Официальные источники Rio, проверенные при обсуждении: [App и as_fastapi](https://github.com/rio-labs/rio/blob/main/rio/app.py), [сервер и динамическая загрузка интерфейса](https://github.com/rio-labs/rio/blob/main/rio/app_server/fastapi_server.py). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
deleted file mode 100644
index cd54ea2..0000000
--- a/docs/implementation-plan.md
+++ /dev/null
@@ -1,210 +0,0 @@
-> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md.
-
-# План реализации учебного сайта для парсинга
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for implementation in this session, or superpowers:subagent-driven-development if the user chooses delegation. Steps use checkbox syntax for tracking.
-
-**Goal:** Создать универсальный учебный сайт с кабинетом преподавателя на Rio, импортом данных, HTML и динамическими упражнениями, готовый к запуску через Docker Compose.
-
-**Architecture:** Один Python-сервис объединяет Rio и FastAPI с Jinja2. Импорт, выборка, публикации и SQLite общие для обоих режимов; публичные запросы читают неизменяемые версии.
-
-**Tech Stack:** Python, rio-ui, FastAPI, Jinja2, SQLite через sqlite3, Argon2 для пароля, pytest, requests, BeautifulSoup, Playwright, Docker Compose. Точные версии закрепляются после проверки совместимости в задаче 1.
-
-**Spec:** [Утверждённая спецификация](design-spec.md).
-
-## Рабочее место и ограничения
-
-Новый проект создаётся в `outputs/parser-lab/` относительно текущего рабочего каталога. Все пути ниже относятся к этому корню. Старый проект `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse` служит источником хоккейного примера и не перезаписывается. Документы копируются в `docs/` нового проекта. Временные результаты проверок находятся в `work/` текущего рабочего каталога. При наличии Git этапы фиксируются отдельными коммитами; отсутствие Git не блокирует сборку и передачу проекта.
-
-- Один аккаунт преподавателя; без регистрации и аккаунтов студентов.
-- Четыре представления; оба режима для каждого включённого представления.
-- Файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней; пределы настраиваемы.
-- Размеры страниц 10, 25, 50, 100; исходное значение 25. Предпросмотр первых 20 записей.
-- Unicode, пропуски, дубликаты, порядок и исходные типы сохраняются; CSV остаётся строковым.
-- Русский интерфейс, светлая тема, адаптивность, локальные ресурсы.
-- Неизменяемые публикации, отсутствие доступа к черновикам и скрытым полям извне.
-- Один контейнер, порт 8080, постоянный каталог `/data`, HTTP и WebSocket за reverse proxy.
-- Нет автоматического деплоя на внешний сервер, обучения моделей или публичного API выгрузки.
-
-## Особое внимание при проверке
-
-1. Заголовки JSON с точками, слешами и тильдами не должны путаться с вложенными путями — задачи 2 и 4.
-2. Значения null, false, 0 и пустая строка не должны схлопываться в отсутствие поля — задачи 2 и 5.
-3. Замена файла и авария публикации не должны повреждать доступный студентам снимок — задачи 3 и 7.
-4. Прямой запрос страницы деталей или callback Rio не должен обходить авторизацию либо раскрывать скрытое поле — задачи 6, 8 и 10.
-5. Две вкладки преподавателя не должны незаметно перезаписывать чужие изменения — задача 3; сохранять черновик с ожидаемой ревизией и показывать конфликт.
-
-## Карта файлов
-
-| Файлы | Ответственность |
-|---|---|
-| `pyproject.toml`, `requirements.lock`, `requirements-dev.lock` | Зависимости приложения и проверок |
-| `src/parser_lab/app.py`, `config.py`, `__main__.py` | Сборка сервиса, конфигурация, запуск |
-| `src/parser_lab/domain.py`, `paths.py` | Общие типы и JSON Pointer |
-| `src/parser_lab/importing/csv_reader.py`, `json_reader.py`, `service.py` | Разбор файлов и диагностика |
-| `src/parser_lab/storage/database.py`, `repository.py`, `migrations/001.sql` | База, миграции, транзакции |
-| `src/parser_lab/views.py`, `query.py`, `publishing.py` | Конфигурация представлений, выборка, публикация |
-| `src/parser_lab/auth.py`, `cli.py` | Сессии, вход, создание хеша пароля |
-| `src/parser_lab/web/routes.py`, `templates/`, `static/` | HTML, стили и ресурсы |
-| `src/parser_lab/rio_ui/admin/`, `rio_ui/public/`, `rio_ui/theme.py` | Кабинет, динамические страницы, тема |
-| `tests/unit/`, `tests/integration/`, `tests/browser/` | Проверки поведения |
-| `examples/data/`, `examples/parsers/` | Данные и решения для преподавателя |
-| `Dockerfile`, `compose.yaml`, `.env.example`, `.dockerignore`, `README.md` | Развёртывание и использование |
-
-Каждая задача включает запуск своей проверки до реализации, подтверждение ожидаемого сбоя, реализацию, повторную проверку и фиксацию результата. Ошибки настройки тестового окружения не считать ожидаемым провалом поведения.
-
-## Задача 1 Совместимый каркас сервиса
-
-**Files:** создать `pyproject.toml`, оба lock-файла, `src/parser_lab/{__init__,app,config,__main__}.py`, `tests/integration/test_app.py`.
-
-**Interfaces:** `Settings.from_env() -> Settings`; `create_app(settings: Settings) -> FastAPI`. Settings содержит каталог данных, лимиты импорта, имя администратора, хеш пароля, секрет сессий, secure-cookie и доверенные origin. В тестах настройки передаются явно.
-
-- [ ] Проверить доступные Python и Docker, прочитать API установленной для проекта Rio, выбрать поддерживаемую Python и закрепить совместимые версии. Не обновлять глобальное окружение пользователя.
-- [ ] Добавить тест `test_html_route_coexists_with_rio`: `client.get('/healthz').status_code == 200`, HTML-проба содержит `'
ImportResult`; `resolve_pointer(record: Record, pointer: str) -> ResolvedValue(present, value)`.
-
-- [ ] Добавить проверки: CSV `'id;answer\n001;\n'` даёт `{'id': '001', 'answer': ''}`; JSON сохраняет `False`, `0`, `None`; `resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c').value == 0`; отсутствующий путь имеет `present == False`.
-- [ ] Добавить параметризованные случаи: UTF-8 BOM, Windows-1251, многострочные CSV-ячейки, плохая длина строки, повторные заголовки и JSON-ключи, JSONL с ошибкой на строке 3, пустые строки JSONL, одиночный объект, вложенный массив, NaN/Infinity, глубина 21, лимиты размера и количества, массив скаляров.
-- [ ] Выполнить `python -m pytest tests/unit/test_importing.py tests/unit/test_paths.py -q`, проверить ожидаемые провалы.
-- [ ] Реализовать чтение с `csv` и `json`, строгой обработкой повторяющихся ключей, ограничением размера до разбора и контролем глубины/числа записей. Не угадывать формат по пользовательскому имени без проверки содержимого. JSONL читает построчно; результаты не сохраняются частично.
-- [ ] Повторить тесты. Проверить объединение полей по всем записям, явный выбор вложенного массива и различение пустого набора от ошибки.
-
-## Задача 3 Хранение черновиков и исходных файлов
-
-**Files:** создать `storage/{__init__,database,repository}.py`, `storage/migrations/001.sql`, `tests/integration/test_repository.py`.
-
-**Interfaces:** `Repository.create_draft(title: str, source: bytes, imported: ImportResult) -> Draft`; `get_draft(dataset_id: str) -> Draft`; `save_draft(draft: Draft, expected_revision: int) -> Draft`; `replace_import(dataset_id: str, source: bytes, imported: ImportResult, expected_revision: int) -> Draft`. `Draft` содержит id, slug, revision, записи, источник, представления, название и описание. Ошибки: `NotFound`, `RevisionConflict`.
-
-- [ ] Добавить тесты сохранения и повторного открытия SQLite: `reopened.records == imported.records`; повторное сохранение со старой revision вызывает `RevisionConflict`; авария транзакции не меняет старую запись. Проверить, что исходное имя `'../../outside.json'` не влияет на фактический путь хранения.
-- [ ] Запустить `python -m pytest tests/integration/test_repository.py -q` до реализации.
-- [ ] Реализовать таблицы datasets, draft_records, sources, views, publications, published_records, sessions, schema_version. Записи хранятся JSON вместе с ordinal и внутренним id. Все SQL параметризованы; опубликованный снимок не зависит от изменяемого черновика.
-- [ ] Реализовать файлы под UUID, временную запись с последующим переносом в пределах `/data`, транзакционную привязку к базе и очистку несвязанных временных файлов. Замена импорта сбрасывает подтверждение настройки полей.
-- [ ] Повторить тесты и проверить идемпотентный запуск миграции и откат при ошибке записи файла.
-
-## Задача 4 Настройки представлений
-
-**Files:** создать `views.py`, `tests/unit/test_views.py`; дополнить общие модели в `domain.py`.
-
-**Interfaces:** `ViewConfig(kind, columns, roles, questionnaire_mode, questions_pointer, page_size, search_pointer, category_pointer)`; `FieldConfig(pointer, label)`; `validate_view(config: ViewConfig, records: list[Record]) -> list[ImportIssue]`; `project_record(record: Record, config: ViewConfig) -> DisplayRecord`. `DisplayRecord` содержит только назначенные значения и сведения об их наличии/типе.
-
-- [ ] Добавить проверки для всех четырёх видов: произвольные подписи колонок; текст сообщения обязателен в конфигурации, автор — нет; анкета из колонок и массива question/answer; отсутствующее значение в отдельной записи допустимо и помечается. Путь, которого нет ни в одной записи, не допускается как обязательная роль.
-- [ ] Проверить страницу 25 по умолчанию и отклонение 24; выбор только текстового поля для поиска; категориальное поле должно быть скалярным. Проверить, что `secret` отсутствует во всём сериализованном DisplayRecord, когда оно не выбрано.
-- [ ] Запустить `python -m pytest tests/unit/test_views.py -q` до реализации.
-- [ ] Реализовать явные конфигурации без эвристического назначения смысла полям. JSON Pointer отвечает за чтение и экранирование имён; структура анкеты не смешивается с универсальным табличным рендером.
-- [ ] Повторить тесты, включая слеши и тильды в именах полей, смешанные записи и многострочные ответы.
-
-## Задача 5 Общая выборка и HTML
-
-**Files:** создать `query.py`, `web/{__init__,routes}.py`, `web/templates/{base,list,detail,table,cards,messages,questionnaire}.html`, `web/static/site.css`, `tests/unit/test_query.py`, `tests/integration/test_html.py`.
-
-**Interfaces:** `Query(page=1, search='', category=None)`; `select_records(records: list[StoredRecord], config: ViewConfig, query: Query) -> PageResult`; `PageResult(items, total, page, page_size, has_next)`. `StoredRecord(id, ordinal, data)` используется обоими режимами. `register_html_routes(app: FastAPI, repository: Repository) -> None` после задачи 7 читает Publication; до этого HTML тестируется на явной фикстуре снимка.
-
-- [ ] Добавить тесты выборки: 51 запись и размер 25 дают страницы 25/25/1, ids не пересекаются; поиск регистронезависимый, фильтр точный и сохраняет различие числа 0 и строки `'0'`; порядок стабилен.
-- [ ] Добавить тест HTML: `BeautifulSoup(response.text, 'html.parser').select('tbody tr')` содержит нужные значения без JavaScript. Вредоносное значение `` остаётся текстом, исполняемый тег не появляется. Проверить отсутствие скрытых полей на списке и деталях.
-- [ ] Запустить `python -m pytest tests/unit/test_query.py tests/integration/test_html.py -q` до реализации.
-- [ ] Реализовать общий отбор, Jinja2 с autoescape, реальные ссылки пагинации и деталей. CSS-контракт: `.record`, `.field`, `data-record-id`, `data-field`, `data-value-state`; состояния `missing`, `null`, `value`. Ссылки сохраняют фильтры и версию, значения URL кодируются.
-- [ ] Определить детали по `/html/{slug}/v/{version}/{view}/records/{id}`. Некорректная страница — 400, отсутствующая — 404. Для фильтра без совпадений страница 1 возвращает 200 с пустым состоянием; следующие — 404.
-- [ ] Повторить тесты и проверить ручным HTTP-запросом, что содержимое и ссылки входят в исходный ответ.
-
-## Задача 6 Авторизация преподавателя
-
-**Files:** создать `auth.py`, `cli.py`, `tests/integration/test_auth.py`; дополнить `app.py`, `config.py`.
-
-**Interfaces:** `AuthService.login(username: str, password: str, client_key: str) -> SessionToken`; `require_admin(token: str) -> AdminSession`; `logout(token: str) -> None`; CLI `python -m parser_lab.cli hash-password` читает пароль скрытым вводом.
-
-- [ ] Написать тесты: неверный пароль не создаёт сессию; приватный маршрут без сессии отклонён; выход отзывает сессию; изменяющий POST без CSRF отклонён; внешний Origin при WebSocket/callback отклонён; cookie HttpOnly/SameSite и Secure соответствуют настройке.
-- [ ] Зафиксировать предел входа: 5 неудачных попыток на client_key за 5 минут, далее 429 до истечения окна; срок сессии 12 часов. Добавить тест управляемым временем, без реального ожидания. Не доверять произвольному X-Forwarded-For.
-- [ ] Запустить `python -m pytest tests/integration/test_auth.py -q` до реализации.
-- [ ] Реализовать Argon2, случайный токен с хранением только его хеша, проверку срока сессии в каждом защищённом действии. HTML-формы и Rio-сессии используют одну авторизацию; редирект страницы сам по себе не считается защитой callback.
-- [ ] Повторить тесты, проверить отсутствие паролей в логах и безопасный отказ запуска без обязательных настроек. Изменение учётных данных отзывает прежние сессии.
-
-## Задача 7 Неизменяемые публикации
-
-**Files:** создать `publishing.py`, `tests/integration/test_publications.py`; дополнить repository и HTML routes.
-
-**Interfaces:** `PublishingService.publish(dataset_id: str, expected_revision: int) -> Publication`; `get_publication(slug: str, version: int) -> Publication`; `unpublish(slug: str, version: int) -> None`. `Publication` содержит slug, version, title, description, views и снимок records. Неопубликованная версия вызывает `NotFound`.
-
-- [ ] Добавить тест: после публикации A и изменения черновика на B `get_publication(slug, 1).records` остаются A; повторная публикация создаёт version 2. Проверить откат при сбое копирования, запрет пустого набора и конфликт revision.
-- [ ] Запустить `python -m pytest tests/integration/test_publications.py -q` до реализации.
-- [ ] Реализовать атомарную публикацию всех включённых валидных представлений и отдельное снятие версии. Если одно включённое представление невалидно, публикация целиком отклоняется с указанием причины.
-- [ ] Закрепить slug после первой публикации, чтобы переименование заголовка не ломало ссылки. Разрешить ASCII lowercase, цифры и дефисы; уникальность проверяет база.
-- [ ] Подключить HTML к реальным снимкам и повторить тесты: снятая версия и неизвестный id возвращают 404; прежние ссылки остаются прежними после редактирования.
-
-## Задача 8 Кабинет Rio
-
-**Files:** создать `rio_ui/admin/{__init__,login,catalog,import_wizard,view_editor,preview,publications}.py`, `rio_ui/theme.py`, `tests/browser/test_admin.py`.
-
-**Interfaces:** компоненты `LoginPage`, `CatalogPage`, `ImportWizard`, `ViewEditor`, `PreviewPage`, `PublicationsPage` получают сервисы через подтверждённый механизм Rio attachment. Состояние несохранённых изменений принадлежит конкретной сессии, постоянные данные — Repository. Вызовы методов изменения требуют `require_admin` и expected_revision.
-
-- [ ] Создать браузерные сценарии входа, импорта CSV, выбора полей, чернового предпросмотра, публикации и выхода; до реализации сценарии не находят соответствующие экраны.
-- [ ] Реализовать каталог и мастер с шагами «Файл», «Проверка», «Отображение», «Предпросмотр». Внешние названия не содержат JSON Pointer; выбор путей показан деревом. Ошибка импорта сохраняет выбранные настройки и объясняет причину.
-- [ ] Реализовать оба предпросмотра за авторизацией, смену представлений, предупреждение о несохранённых изменениях и сообщение о конфликте двух вкладок. Дублирующие операции отключаются до завершения запроса.
-- [ ] При работе FileInfo прочитать загруженные байты один раз и хранить до подтверждения/отмены; валидация повторяется на сервере, а не только фильтром расширений. Предпросмотр показывает 20 записей, число всех записей и предупреждения.
-- [ ] Выполнить `python -m pytest tests/browser/test_admin.py -q` с браузером Playwright и отдельной временной базой. Проверить ручной вход, восстановление после ошибки и невозможность действия после logout в другой вкладке.
-
-## Задача 9 Динамические упражнения Rio
-
-**Files:** создать `rio_ui/public/{__init__,listing,detail,renderers}.py`, `tests/browser/test_interactive.py`.
-
-**Interfaces:** `InteractivePage` загружает Publication по slug/version и использует `select_records` и `project_record`; `RecordPage` показывает только выбранные значения. Состояние страницы хранит фильтры, next_page, generation и флаг loading.
-
-- [ ] Создать сценарий: 51 запись, размер 25; после двух нажатий «Загрузить ещё» получены 51 уникальных id, кнопки больше нет. Проверить каждое из четырёх представлений.
-- [ ] Проверить быстрые повторные клики, смену фильтра во время загрузки и снятие публикации при открытой странице. Устаревший ответ фильтра не должен добавляться к новому списку; снятые данные не выдаются следующими запросами.
-- [ ] Запустить `python -m pytest tests/browser/test_interactive.py -q` до реализации.
-- [ ] Реализовать дозагрузку, состояния и детали, используя подтверждённые Rio API. Стабильные доступные подписи и роли обязательны; способы установки дополнительных DOM-атрибутов сначала проверить по версии Rio. Не строить тесты на случайных сгенерированных классах.
-- [ ] Повторить сценарии; проверить прямое открытие вложенного URL, обновление браузера и отсутствие скрытых значений в передаваемых данных.
-
-## Задача 10 Дизайн и проверка пользовательского сценария
-
-**Files:** доработать `rio_ui/theme.py`, Rio-компоненты, `web/static/site.css`, HTML-шаблоны; создать `tests/browser/test_layout.py`, `tests/integration/test_visibility.py`.
-
-- [ ] Реализовать общие цветовые и типографические параметры, боковую навигацию, компактную навигацию, карточки статусов и локальные ресурсы. Не добавлять новые функции ради оформления.
-- [ ] Проверить 1440×900 и 390×844: основные действия доступны; длинный текст переносится; горизонтально прокручивается таблица, а не вся страница; заголовки и ответы не перекрываются. Сохранить скриншоты в рабочую папку и визуально просмотреть.
-- [ ] Проверить клавиатурный фокус, подписи полей, ошибки рядом с полями и состояния пустых результатов. Для простых косметических правок использовать визуальную проверку, не тестировать каждую константу стиля.
-- [ ] Добавить интеграционные тесты скрытых полей на списках, деталях, HTML предпросмотре без входа и прямых служебных запросах. Проверить пользовательские `
+Кабинет · Parser Lab
{% if screen=='catalog' %}