Compare commits

..
Author SHA1 Message Date
agent 2e77f6c3df Document frontend search release in changelog
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:53:01 +03:00
agent 28e42e8b55 Clear stale source link while loading editions
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:46:16 +03:00
agent 2d3f0a35c6 Fix public document edition state
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:27:37 +03:00
agent aae9657b9e fix(review): position tutorial hints beside targets
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:08:57 +03:00
agent a2ef8e0bf0 fix(frontend): refine visual match and modal focus
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 10:31:22 +03:00
agent 5a80c021d3 feat(frontend): complete public search visual review
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:34:39 +03:00
agent 133d847608 clarify baseline status in frontend plan
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:17:26 +03:00
agent 043d9d632d refine search screen visual alignment
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:16:18 +03:00
agent d01fdabd68 feat(frontend): implement legal search interface
Runner smoke test / runner-smoke (pull_request) Failing after 13m50s
CI / tests-and-images (pull_request) Failing after 13m55s
2026-10-23 00:00:41 +03:00
agent 84ae32b8a3 Merge pull request 'Keep rootless Docker accessible under runner hardening' (#52) from fix/runner-protecthome-rootless-socket into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-21 06:04:16 +00:00
agent 1f6129e009 Document runner ProtectHome fix
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 09:00:43 +03:00
agent d6799ec59b Preserve runner runtime path carve-out
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:57:00 +03:00
agent fcab9e1239 Keep rootless Docker socket visible to runner
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:54:10 +03:00
agent d2fb4b81c0 Merge pull request 'Add bounded current MinJust API staging' (#51) from fix/current-minjust-api-ingestion into main
CI / tests-and-images (push) Successful in 2m7s
Runner smoke test / runner-smoke (push) Successful in 5s
2026-10-21 05:44:35 +00:00
agent f922f6e8c7 Document current MinJust staging release
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 08:38:34 +03:00
agent 06e350fb1e Fix MinJust overlap test under umask 000
CI / tests-and-images (pull_request) Successful in 2m9s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:34:07 +03:00
agent e522b2eee3 Test rejection of unknown corpus normalizer versions
CI / tests-and-images (pull_request) Failing after 8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 02:00:07 +03:00
agent 228dfdaca0 Harden MinJust staging and legacy corpus verification
CI / tests-and-images (pull_request) Failing after 9s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-20 01:59:32 +03:00
agent 6357648c9c Add exact current MinJust API staging adapter
CI / tests-and-images (pull_request) Successful in 2m7s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 01:39:36 +03:00
agent b845316ee7 Merge pull request 'fix: validate digest-pinned rollback images safely' (#50) from fix/rollback-image-digest-reference into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-19 21:41:47 +00:00
58 changed files with 3009 additions and 83 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ credentials.json
__pycache__/
.venv/
*.py[cod]
tools/frontend-visual-review/node_modules/
# Local environment files
.env
+16
View File
@@ -2,6 +2,22 @@
## Не выпущено
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
Serif.
- Исправлены переключение на доступный язык редакции, очистка содержимого и
ссылки на официальный источник при смене документа или ошибке загрузки;
подписи фильтров локализуются на кыргызский язык.
- Infrastructure: systemd unit Gitea Actions runner теперь использует
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
writable carve-out для rootless runtime; VPS не изменялся.
- Backend `0.12.1`: добавлен ограниченный возобновляемый staging редакций из
текущего API Минюста с проверкой точных ID и метаданных, изоляцией от
legacy-корпуса и сохранением исходного порядка редакций при нормализации и
экспорте. Тест overlap guard задаёт безопасные права fixture независимо от
`umask` CI; сам guard не ослаблен.
- Rollback preflight проверяет digest-pinned image references по сохранённому
image ID и останавливается до изменений deployment; переключение production
на digest и полноценный digest rollback пока не реализованы.
+47 -2
View File
@@ -86,6 +86,15 @@ spacing:
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
ранних концепций не используется.
Изображение определяет целевую композицию, пропорции панелей и расположение
элементов поискового экрана. Эта спецификация определяет семантические токены,
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[плане точного воспроизведения поискового интерфейса](docs/product/frontend-pixel-perfect-plan.md).
План не является второй дизайн-системой и не означает завершение реализации.
**Key Characteristics:**
- Спокойное чтение и компактные, понятные элементы управления.
@@ -162,6 +171,34 @@ spacing:
Разворачивание документа и поиск внутри него реализуются только вместе
с работающим поведением, а не как декоративные кнопки.
### Композиция поискового экрана
- Первая строка desktop: знак и название, разделитель и подпись продукта
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
переключатель языка интерфейса справа. Служебные действия авторизованного
пользователя не вытесняют поиск и не меняют эту иерархию.
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
фильтры. Сведения о демонстрационном режиме показываются только в нём.
- Ниже расположены три смежные панели: фильтры, результаты, документ.
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
промежутков между самостоятельными карточками панелей.
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
и пару полей даты в одной строке при достаточной ширине. Вид элемента
выбора соответствует одиночному или множественному контракту API.
- Заголовок результатов и счётчик находятся слева, сортировка справа.
Высота строк зависит от содержимого; едины отступы и типографика.
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
и текстовый признак открытия.
- Панель документа начинается с контекстной навигации и разворачивания,
затем идут название, компактные метаданные, действия, поиск внутри
документа и единое поле чтения. Название документа имеет отдельную
типографическую роль, отличную от заголовков интерфейсных панелей.
- При коротком содержимом footer расположен у нижней границы окна;
длинное содержимое доступно прокруткой без перекрытия текста и управления.
Бренд находится слева, фактические версии frontend и backend справа.
- Поисковая страница не показывает инструменты оценивания лаборатории.
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
порядок результатов и оценивание слева, текст выбранной редакции справа.
Она не получает фильтры, аналитику или публичную навигацию исключительно
@@ -176,8 +213,9 @@ spacing:
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
область прокрутки.
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
проверки, а не заранее навязанные CSS breakpoints.
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
1280×800 — основной размер сравнения поискового экрана с референсом.
Это размеры проверки, а не заранее навязанные CSS breakpoints.
## Elevation & Depth
@@ -204,6 +242,13 @@ spacing:
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
На телефоне орнамент сокращается либо убирается.
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
части панели фильтров. Он не перекрывает длинные списки и не резервирует
место ценой недоступности управления. Знак бренда и орнамент поставляются
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
согласованные толщину линий, оптический размер и выравнивание.
## Components
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
+5 -4
View File
@@ -10,7 +10,8 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.11.3` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
`0.13.0` и frontend `0.2.3` с поисковым экраном и внутренней лабораторией.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
@@ -18,8 +19,8 @@ SQLite; первый пользователь `admin` создаётся инт
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.11.3` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
| Backend | `0.13.0` | авторизация, поиск с фильтрами, ограниченный staging и статические ресурсы |
| Frontend | `0.2.3` | поиск по правовой базе, просмотр редакций и внутренняя лаборатория |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -62,4 +63,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
+45 -2
View File
@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.11.3`
Версия: `0.13.0`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -54,6 +54,49 @@ result = sync_archive(output_path)
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
выбора будущего backend-фреймворка.
## Текущий API Минюста и отдельный staging
Старый OpenData endpoint может возвращать `404`; для отдельной ограниченной
выгрузки официальный сайт предоставляет `/api/v1/GetDocument` и
`/api/v1/GetEdition`. Адаптер `ingestion.minjust_current` получает один
указанный документ и сохраняет его в отдельное поколение staging:
```bash
PYTHONPATH=backend python3 -m ingestion.minjust_current \
--output /tmp/minjust-current-code6 \
--document-code 6
```
Продолжайте прерванную выгрузку только с тем же каталогом и `--resume`. По
умолчанию лимит скачанного текста одного документа — 4 GiB, лимит запросов —
один в секунду; каталог не должен совпадать или пересекаться с
`data/minjust-cbd` и
`data/minjust-normalized`. Адаптер сохраняет исходные ответы и checkpoints,
публикует папку документа атомарно и не заменяет существующий код в staging.
Каталог staging должен быть отдельной директорией режима 0700, принадлежащей
текущему пользователю; его родитель должен быть существующим доверенным
каталогом. Символические ссылки в generation, checkpoints и каталогах
документов отклоняются. Legacy-корни вычисляются от корня репозитория, а не
от текущего рабочего каталога.
Для `/GetEdition?lang=kg` официальный ответ помещает кыргызский HTML в
`contentRu` при `contentKg=null`; архивный язык определяется запрошенным
`lang`, а не именем поля ответа.
Исторические редакции запрашиваются только с `exact=true`; без этого параметра
официальный endpoint возвращал ID последней редакции даже для другого
запрошенного ID. Адаптер требует совпадения response ID и запрошенного ID,
сверяет названия RU/KY с metadata `GetDocument` и сохраняет request contract в
checkpoint. Старые staging generations без этого contract нельзя возобновить.
`exact=true` и совпадение ID проверены на двух контрольных редакциях; это не
подтверждает полноту всего источника.
Порядок редакций сохраняется по ответу `/GetDocument` и используется как
официальный порядок при нормализации и выборе текущей редакции в поисковом
индексе. Вложения и встроенные изображения этим адаптером не подтверждаются:
staging остаётся неполным до отдельной проверки медиа и не заменяет корпус.
Получение полного списка документов не является частью этой ограниченной
процедуры.
## Нормализация архива
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
@@ -253,4 +296,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
+720
View File
@@ -0,0 +1,720 @@
"""Read-only staging client for the current MinJust CBD API."""
from __future__ import annotations
import argparse
import hashlib
import http.client
import json
import logging
import os
import sqlite3
import stat
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Callable
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
APP_VERSION = "0.13.0"
REQUEST_CONTRACT = {
"version": 2,
"endpoint": "/GetEdition",
"edition_id_parameter": "editionId",
"exact_parameter": "true",
"language_parameter": "lang",
"response_id_must_match": True,
}
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
DEFAULT_DOCUMENT_BUDGET = 4 * 1024 * 1024 * 1024
LANGUAGES = (("ru", "ru", "textRusType"), ("kg", "ky", "textKyrType"))
LOGGER = logging.getLogger(__name__)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class SourceError(RuntimeError):
pass
class SourceChanged(SourceError):
pass
@dataclass(frozen=True)
class ApiResponse:
value: dict
body: bytes
sha256: str
class CurrentCbdClient:
def __init__(
self,
requests_per_second: float = 1,
timeout: int = 60,
retries: int = 5,
max_response_bytes: int = MAX_RESPONSE_BYTES,
opener: Callable = urllib.request.urlopen,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
if requests_per_second <= 0 or timeout <= 0 or retries <= 0 or max_response_bytes <= 0:
raise ValueError("rate, timeout, retries, and response limit must be positive")
self.interval = 1 / requests_per_second
self.timeout = timeout
self.retries = retries
self.max_response_bytes = max_response_bytes
self.opener = opener
self.sleep = sleep
self.clock = clock
self.last_request = 0.0
def request_json(
self,
path: str,
*,
method: str = "GET",
parameters: tuple[tuple[str, object], ...] = (),
payload: dict | None = None,
) -> ApiResponse:
if not path.startswith("/") or ".." in path.split("/"):
raise ValueError("API path must be an absolute endpoint path")
query = urllib.parse.urlencode(parameters, doseq=True)
url = API_ROOT + path + (f"?{query}" if query else "")
body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode() if method == "POST" else None
for attempt in range(self.retries):
wait = self.interval - (self.clock() - self.last_request)
if wait > 0:
self.sleep(wait)
request = urllib.request.Request(
url,
data=body,
method=method,
headers={
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
},
)
try:
self.last_request = self.clock()
with self.opener(request, timeout=self.timeout) as response:
raw = response.read(self.max_response_bytes + 1)
if len(raw) > self.max_response_bytes:
raise SourceError(f"MinJust response exceeds {self.max_response_bytes} bytes")
value = json.loads(raw)
if not isinstance(value, dict):
raise SourceError("MinJust response must be a JSON object")
return ApiResponse(value, raw, hashlib.sha256(raw).hexdigest())
except urllib.error.HTTPError as error:
if error.code != 429 and error.code < 500:
raise SourceError(f"MinJust returned HTTP {error.code} for {path}") from error
delay = 2**attempt
if error.code == 429:
delay = max(delay, retry_after_seconds(error.headers.get("Retry-After")))
except (TimeoutError, urllib.error.URLError, http.client.IncompleteRead, json.JSONDecodeError) as error:
delay = 2**attempt
LOGGER.warning("MinJust request failed for %s (attempt %s/%s)", path, attempt + 1, self.retries)
last_error = error
if attempt + 1 < self.retries:
self.sleep(delay)
raise SourceError(f"MinJust request failed after {self.retries} attempts: {path}") from locals().get("last_error")
def document(self, alias: str) -> ApiResponse:
response = self.request_json("/GetDocument", parameters=(("DocumentCode", alias),))
value = response.value
canonical = value.get("documentCode")
editions = value.get("editions")
if not isinstance(canonical, int) or isinstance(canonical, bool) or canonical < 1:
raise SourceError("MinJust document has an invalid canonical documentCode")
if not isinstance(editions, list):
raise SourceError("MinJust document is missing editions")
ids = [edition_id(item) for item in editions]
if len(ids) != len(set(ids)):
raise SourceError(f"MinJust document {canonical} contains duplicate edition IDs")
return response
def edition(self, identifier: int, language: str) -> ApiResponse:
if identifier < 1 or language not in {"ru", "kg"}:
raise ValueError("edition ID or requested language is invalid")
response = self.request_json(
"/GetEdition",
parameters=(("editionId", identifier), ("lang", language), ("exact", "true")),
)
returned_id = response.value.get("id")
if not isinstance(returned_id, int) or isinstance(returned_id, bool) or returned_id < 1:
raise SourceError(f"MinJust returned an invalid edition response ID for {identifier}")
if returned_id != identifier:
raise SourceChanged(f"MinJust exact edition ID {returned_id} does not match requested ID {identifier}")
return response
def retry_after_seconds(value: str | None) -> float:
if not value:
return 0
try:
return max(0, float(value))
except ValueError:
try:
return max(0, (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError, OverflowError):
return 0
def edition_id(value: object) -> int:
if not isinstance(value, dict):
raise SourceError("MinJust document contains an invalid edition")
identifier = value.get("id")
if not isinstance(identifier, int) or isinstance(identifier, bool) or identifier < 1:
raise SourceError("MinJust edition has an invalid id")
return identifier
def open_manifest(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.execute("PRAGMA journal_mode=DELETE")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS documents (
alias TEXT PRIMARY KEY,
canonical_id INTEGER,
response_sha256 TEXT,
expected_editions INTEGER,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS editions (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
source_order INTEGER NOT NULL,
response_sha256 TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id)
);
CREATE TABLE IF NOT EXISTS languages (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
request_language TEXT NOT NULL,
archive_language TEXT NOT NULL,
response_sha256 TEXT,
content_sha256 TEXT,
byte_count INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id, request_language)
);
CREATE TABLE IF NOT EXISTS generation (
contract_sha256 TEXT NOT NULL
);
"""
)
return connection
def reject_symlink_components(path: Path) -> None:
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {current}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {current}")
def ensure_stage_path(root: Path, path: Path) -> Path:
root = root.absolute()
path = path.absolute()
try:
relative = path.relative_to(root)
except ValueError as error:
raise ValueError("staging file escaped its private generation") from error
current = root
if current.is_symlink():
raise ValueError("staging root must not be a symbolic link")
for part in relative.parts:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {relative}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {relative}")
return path
def stage_write(root: Path, path: Path, content: bytes) -> None:
path = ensure_stage_path(root, path)
atomic_write(path, content)
ensure_stage_path(root, path)
def stage_mkdir(root: Path, path: Path) -> None:
path = ensure_stage_path(root, path)
path.mkdir(parents=True, exist_ok=True)
ensure_stage_path(root, path)
if not path.is_dir():
raise ValueError("staging path must be a directory")
def validate_stage_tree(output: Path) -> None:
for relative in (
Path("current-api.sqlite3"), Path("current-api.sqlite3-journal"),
Path("current-api.sqlite3-wal"), Path("current-api.sqlite3-shm"),
Path("manifest.sqlite3"), Path("manifest.sqlite3-journal"),
Path("manifest.sqlite3-wal"), Path("manifest.sqlite3-shm"),
Path("request-contract.json"),
):
path = ensure_stage_path(output, output / relative)
if path.exists() and not path.is_file():
raise ValueError(f"staging checkpoint must be a regular file: {relative}")
for relative in (Path(".work"), Path("documents")):
root = ensure_stage_path(output, output / relative)
if root.exists() and not root.is_dir():
raise ValueError(f"staging path must be a directory: {relative}")
if root.is_dir():
for directory, directories, files in os.walk(root, followlinks=False):
for name in directories + files:
ensure_stage_path(output, Path(directory) / name)
def validate_stage_root(output: Path, resume: bool) -> Path:
requested = Path(output)
if ".." in requested.parts:
raise ValueError("staging output path must not contain '..'")
destination = Path(os.path.abspath(requested))
reject_symlink_components(destination)
parent = destination.parent
if not parent.is_dir():
raise ValueError("staging output parent must already exist")
for ancestor in (parent, *parent.parents):
info = ancestor.lstat()
mode = stat.S_IMODE(info.st_mode)
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ValueError("staging output ancestors must be real directories")
sticky_parent = bool(mode & stat.S_ISVTX)
if mode & 0o022 and not sticky_parent:
raise ValueError("staging output parent is not trusted")
for relative in (Path("data/minjust-cbd"), Path("data/minjust-normalized")):
legacy = (REPOSITORY_ROOT / relative).resolve()
if destination == legacy or destination.is_relative_to(legacy) or legacy.is_relative_to(destination):
raise ValueError("current API staging must not overlap the existing corpus")
if destination.exists():
if destination.is_symlink() or not destination.is_dir():
raise ValueError("staging output must be a real directory")
else:
destination.mkdir(mode=0o700)
root_info = destination.lstat()
root_mode = stat.S_IMODE(root_info.st_mode)
if (
stat.S_ISLNK(root_info.st_mode)
or not stat.S_ISDIR(root_info.st_mode)
or root_info.st_uid != os.geteuid()
or root_mode & 0o077
or (root_mode & 0o700) != 0o700
):
raise ValueError("staging output must be a private directory owned by the current user")
validate_stage_tree(destination)
if any(destination.iterdir()) and not resume:
raise ValueError("staging output is not empty; use --resume for this generation")
contract_path = destination / "request-contract.json"
if contract_path.exists():
try:
contract = json.loads(contract_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise SourceChanged("staging generation request contract is invalid") from error
if contract != REQUEST_CONTRACT:
raise SourceChanged("staging generation uses a different request contract")
else:
if any(destination.iterdir()):
raise SourceChanged("staging generation has no exact-edition request contract; start a new generation")
stage_write(destination, contract_path, json_bytes(REQUEST_CONTRACT))
return destination
def stage_document(
output: Path,
alias: str,
client: CurrentCbdClient,
*,
resume: bool = False,
max_document_bytes: int = DEFAULT_DOCUMENT_BUDGET,
) -> dict:
"""Stage one document and its official editions without touching legacy roots."""
if not alias or len(alias) > 64 or "/" in alias or "\\" in alias:
raise ValueError("document code is invalid")
if max_document_bytes <= 0:
raise ValueError("document byte budget must be positive")
output = validate_stage_root(output, resume)
work_root = output / ".work" / "documents"
target_root = output / "documents"
connection = open_manifest(output / "current-api.sqlite3")
raw_manifest = connect_manifest(output / "manifest.sqlite3")
try:
contract_sha = hashlib.sha256(json.dumps(REQUEST_CONTRACT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
saved_contract = connection.execute("SELECT contract_sha256 FROM generation").fetchone()
if saved_contract and saved_contract[0] != contract_sha:
raise SourceChanged("checkpoint request contract changed; start a new staging generation")
if not saved_contract:
if connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0]:
raise SourceChanged("existing checkpoints have no request contract; start a new staging generation")
with connection:
connection.execute("INSERT INTO generation (contract_sha256) VALUES (?)", (contract_sha,))
response = client.document(alias)
source = response.value
canonical = source["documentCode"]
record_sha = response.sha256
editions = source["editions"]
previous = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias = ?",
(alias,),
).fetchone()
if previous and previous[1] is not None and (
previous[0] != canonical or previous[1] != record_sha or previous[2] != len(editions)
):
raise SourceChanged("document metadata changed; start a new staging generation")
working_document = work_root / str(canonical)
final_document = target_root / str(canonical)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.is_symlink():
raise ValueError("staging target must not be a symbolic link")
if final_document.exists() and not previous:
raise ValueError("staging target already exists without a matching checkpoint")
if final_document.exists():
final_metadata = ensure_stage_path(output, final_document / "metadata.json")
source_document = ensure_stage_path(output, final_document / "source-document.json")
metadata = json.loads(final_metadata.read_text(encoding="utf-8"))
if (
metadata.get("SourceApi", {}).get("response_sha256") != record_sha
or hashlib.sha256(source_document.read_bytes()).hexdigest() != record_sha
):
raise SourceChanged("published staging document does not match the current source record")
for edition in editions:
identifier = edition_id(edition)
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(
output,
final_document / "editions" / str(identifier) / f"{archive_language}.html",
)
checkpoint = connection.execute(
"SELECT state, content_sha256 FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if not edition.get(type_field):
if not checkpoint or checkpoint[0] != "unavailable":
raise SourceError(f"published staging document has no checkpoint for edition {identifier} {request_language}")
elif (
not checkpoint
or checkpoint[0] != "complete"
or not html_path.is_file()
or sha256_file(html_path) != checkpoint[1]
):
raise SourceError(f"published staging document failed verification for edition {identifier} {request_language}")
document_sha = hash_tree(final_document)
fetched_at = utc_now()
manifest_row = raw_manifest.execute(
"SELECT source_sha256 FROM documents WHERE code = ?", (canonical,)
).fetchone()
if manifest_row and manifest_row[0] != document_sha:
raise SourceError("published staging document does not match its raw manifest")
with raw_manifest:
raw_manifest.execute(
"INSERT OR IGNORE INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?) "
"ON CONFLICT(code) DO UPDATE SET message=excluded.message, failed_at=excluded.failed_at",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', error=NULL, updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
"resumed": True,
}
stage_mkdir(output, working_document)
document_metadata = map_document_metadata(source, alias, response.sha256)
stage_write(output, working_document / "metadata.json", json_bytes(document_metadata))
stage_write(output, working_document / "source-document.json", response.body)
with connection:
connection.execute(
"INSERT INTO documents (alias, canonical_id, response_sha256, expected_editions, state, updated_at) VALUES (?, ?, ?, ?, 'fetching', ?) "
"ON CONFLICT(alias) DO UPDATE SET canonical_id=excluded.canonical_id, response_sha256=excluded.response_sha256, "
"expected_editions=excluded.expected_editions, state='fetching', error=NULL, updated_at=excluded.updated_at",
(alias, canonical, record_sha, len(editions), utc_now()),
)
for order, edition in enumerate(editions):
identifier = edition_id(edition)
existing = connection.execute(
"SELECT source_order, response_sha256 FROM editions WHERE canonical_id = ? AND edition_id = ?",
(canonical, identifier),
).fetchone()
if existing and existing != (order, hashlib.sha256(json_bytes(edition)).hexdigest()):
raise SourceChanged(f"edition metadata changed for {identifier}")
connection.execute(
"INSERT OR IGNORE INTO editions (canonical_id, edition_id, source_order, response_sha256) VALUES (?, ?, ?, ?)",
(canonical, identifier, order, hashlib.sha256(json_bytes(edition)).hexdigest()),
)
incomplete = []
for order, edition in enumerate(editions):
identifier = edition_id(edition)
directory = working_document / "editions" / str(identifier)
stage_mkdir(output, directory)
stage_write(output, directory / "metadata.json", json_bytes(map_edition_metadata(edition, order)))
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(output, directory / f"{archive_language}.html")
checkpoint = connection.execute(
"SELECT state, content_sha256, byte_count FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if checkpoint and checkpoint[0] == "complete":
if not html_path.is_file() or sha256_file(html_path) != checkpoint[1]:
raise SourceError(f"staged content hash mismatch for edition {identifier} {request_language}")
continue
if not edition.get(type_field):
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "unavailable", "source metadata reports no language file",
)
continue
try:
language_response = client.edition(identifier, request_language)
response_id = language_response.value.get("id")
if isinstance(response_id, bool) or not isinstance(response_id, int) or response_id != identifier:
raise SourceChanged(f"MinJust exact edition ID mismatch for {identifier}")
for field in ("nameRus", "nameKyr"):
if edition.get(field) is not None and language_response.value.get(field) != edition[field]:
raise SourceChanged(f"MinJust exact edition metadata mismatch for {identifier}: {field}")
content = language_response.value.get("contentRu")
if not isinstance(content, str) or not content:
raise SourceError(f"MinJust returned no content for edition {identifier} language {request_language}")
encoded = content.encode("utf-8")
completed_bytes = connection.execute(
"SELECT COALESCE(SUM(byte_count), 0) FROM languages WHERE canonical_id = ? AND state = 'complete'",
(canonical,),
).fetchone()[0]
if completed_bytes + len(encoded) > max_document_bytes:
raise SourceError(f"document {canonical} exceeds its {max_document_bytes}-byte staging budget")
response_metadata = {
key: value
for key, value in language_response.value.items()
if key not in {"contentRu", "contentKg"}
}
response_metadata["requested_language"] = request_language
response_metadata["archive_language"] = archive_language
response_metadata["requested_edition_id"] = identifier
response_metadata["exact"] = True
response_metadata["response_sha256"] = language_response.sha256
stage_write(
output,
directory / "source" / f"{request_language}.json",
json_bytes(response_metadata),
)
stage_write(output, html_path, encoded)
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
language_response.sha256, hashlib.sha256(encoded).hexdigest(), len(encoded), "complete", None,
)
except Exception as error:
message = str(error)[:500]
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "error", message,
)
incomplete.append({"edition_id": identifier, "language": request_language, "error": message})
if isinstance(error, SourceChanged) or (isinstance(error, SourceError) and "budget" in message):
raise
if incomplete:
with connection:
connection.execute(
"UPDATE documents SET state='incomplete', error=?, updated_at=? WHERE alias=?",
(json.dumps(incomplete, ensure_ascii=False), utc_now(), alias),
)
return {"alias": alias, "canonical_id": canonical, "editions": len(editions), "incomplete": incomplete}
working_metadata = ensure_stage_path(output, working_document / "metadata.json")
metadata = json.loads(working_metadata.read_text(encoding="utf-8"))
metadata["SourceCompleteness"] = {
"text": "complete",
"attachments": "not independently verified",
}
stage_write(output, working_metadata, json_bytes(metadata))
stage_mkdir(output, target_root)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.exists():
raise ValueError("refusing to replace a document in this staging generation")
os.replace(working_document, final_document)
validate_stage_tree(output)
document_sha = hash_tree(final_document)
fetched_at = utc_now()
with raw_manifest:
raw_manifest.execute(
"INSERT INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
}
except Exception as error:
with connection:
connection.execute(
"INSERT INTO documents (alias, state, error, updated_at) VALUES (?, 'error', ?, ?) "
"ON CONFLICT(alias) DO UPDATE SET state='error', error=excluded.error, updated_at=excluded.updated_at",
(alias, str(error)[:500], utc_now()),
)
raise
finally:
raw_manifest.close()
connection.close()
def map_document_metadata(source: dict, alias: str, response_sha256: str) -> dict:
canonical = source["documentCode"]
return {
"Code": canonical,
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"Title": {"Rus": source.get("headerRu"), "Kyr": source.get("headerKg")},
"Number": source.get("number"),
"DateAdopted": source.get("dateAdopted"),
"DateInclusion": source.get("dateInclusion"),
"DateOfEntry": source.get("dateOfEntry"),
"DatePublication": source.get("datePublication"),
"DateRegistration": source.get("dateRegistration"),
"IsPublicInRegister": source.get("isPublicInRegister"),
"SourceStatus": source.get("status"),
"SourceClassId": source.get("classId"),
"SourceRefTypeId": source.get("refTypeId"),
"SourceAuthorityIds": source.get("authoritiesId"),
"SourcePublicationIds": source.get("sourcePublicationsId"),
"SourceMetadata": {key: value for key, value in source.items() if key != "editions"},
"SourceDocumentCode": alias,
"SourceMediaStatus": "not independently verified",
"SourceApi": {"endpoint": "/GetDocument", "response_sha256": response_sha256},
}
def map_edition_metadata(source: dict, source_order: int) -> dict:
return {
"Code": edition_id(source),
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"SourceOrder": source_order,
"SourceTextTypes": {
"ru": source.get("textRusType"),
"ky": source.get("textKyrType"),
},
"SourceMediaStatus": "not independently verified",
"SourceMetadata": source,
}
def save_language_checkpoint(
connection: sqlite3.Connection,
canonical: int,
edition: int,
request_language: str,
archive_language: str,
response_sha256: str | None,
content_sha256: str | None,
byte_count: int,
state: str,
error: str | None,
) -> None:
with connection:
connection.execute(
"INSERT INTO languages (canonical_id, edition_id, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(canonical_id, edition_id, request_language) DO UPDATE SET "
"archive_language=excluded.archive_language, response_sha256=excluded.response_sha256, content_sha256=excluded.content_sha256, "
"byte_count=excluded.byte_count, state=excluded.state, error=excluded.error, updated_at=excluded.updated_at",
(canonical, edition, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, utc_now()),
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def hash_tree(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(item for item in root.rglob("*") if item.is_file()):
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b"\0")
digest.update(sha256_file(path).encode("ascii"))
digest.update(b"\0")
return digest.hexdigest()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True, help="new, separate staging generation")
parser.add_argument("--document-code", required=True, help="one official document alias or canonical code")
parser.add_argument("--resume", action="store_true", help="resume only this staging generation")
parser.add_argument("--requests-per-second", type=float, default=1)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--retries", type=int, default=5)
parser.add_argument("--max-document-bytes", type=int, default=DEFAULT_DOCUMENT_BUDGET)
parser.add_argument("--version", action="version", version=APP_VERSION)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
result = stage_document(
arguments.output,
arguments.document_code,
CurrentCbdClient(arguments.requests_per_second, arguments.timeout, arguments.retries),
resume=arguments.resume,
max_document_bytes=arguments.max_document_bytes,
)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return int(result.get("state") != "text_complete_incomplete_attachments")
if __name__ == "__main__":
raise SystemExit(main())
+15 -3
View File
@@ -23,9 +23,9 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.9.3"
APP_VERSION = "0.10.0"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
NORMALIZER_VERSION = "1.1.0"
LANGUAGES = ("ru", "ky")
LOGGER = logging.getLogger(__name__)
@@ -376,13 +376,16 @@ def load_json(path: Path) -> dict:
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
metadata = load_json(edition_directory / "metadata.json")
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
return {
summary = {
"source_code": str(metadata.get("Code", edition_directory.name)),
"name": bilingual(metadata.get("Name")),
"source_type": clean_value(metadata.get("Type")),
"available_languages": languages,
"source_path": edition_directory.relative_to(input_root).as_posix(),
}
if "SourceOrder" in metadata:
summary["source_order"] = metadata["SourceOrder"]
return summary
def normalize_document(
@@ -406,6 +409,15 @@ def normalize_document(
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
) if edition_root.is_dir() else []
summaries = [edition_summary(path, input_root) for path in edition_directories]
source_orders = [item.get("source_order") for item in summaries]
if any(order is not None for order in source_orders):
if any(not isinstance(order, int) or isinstance(order, bool) or order < 0 for order in source_orders):
raise ValueError(f"Invalid source edition order in {document_directory}")
if len(set(source_orders)) != len(source_orders):
raise ValueError(f"Duplicate source edition order in {document_directory}")
ordered = sorted(zip(source_orders, edition_directories, summaries), key=lambda item: item[0])
edition_directories = [item[1] for item in ordered]
summaries = [item[2] for item in ordered]
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
normalized_metadata = clean_value(metadata)
document = {
+33
View File
@@ -26,6 +26,16 @@ LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
MAX_RESULT_WINDOW = 10_000
STATIC_ASSETS = {
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
}
class ApiError(Exception):
@@ -303,6 +313,17 @@ class Api:
except (OSError, UnicodeError) as error:
raise ApiError(500, "review interface is unavailable") from error
@staticmethod
def static_asset(route: str) -> tuple[str, bytes]:
asset = STATIC_ASSETS.get(route)
if asset is None:
raise ApiError(404, "static asset not found")
path = Path(__file__).with_name("static") / asset[0]
try:
return asset[1], path.read_bytes()
except OSError as error:
raise ApiError(500, "static asset is unavailable") from error
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
@@ -386,6 +407,18 @@ def handler(api: Api):
self.end_headers()
self.wfile.write(body)
return
if method == "GET" and route in STATIC_ASSETS:
content_type, body = api.static_asset(route)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "public, max-age=604800, immutable")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
if not user:
raise ApiError(401, "authentication required")
+7 -2
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.11.3"
APP_VERSION = "0.13.0"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -129,7 +129,12 @@ def bulk_pairs(
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
if not editions:
continue
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
official_editions = document.get("editions") or []
current_edition = (
str(official_editions[-1]["source_code"])
if official_editions
else max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
)
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
+659 -53
View File
@@ -3,8 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#F7F2E8">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<title>Оценка поисковой выдачи · Акылдаш</title>
<style>
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-cyrillic.woff2") format("woff2"); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-latin.woff2") format("woff2"); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic.woff2") format("woff2"); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-latin.woff2") format("woff2"); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", serif; }
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: #F7F2E8; }
@@ -65,14 +74,15 @@
.review__empty { padding: 3rem 1rem; color: var(--muted); text-align: center; }
.review__empty strong { display: block; margin-block-end: .35rem; color: var(--ink); }
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: 6px; box-shadow: 0 0 0 100vmax rgb(56 38 42 / .58), 0 0 0 8px rgb(163 123 50 / .25); }
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: .5rem; color: var(--ink); }
.review__tour { position: fixed; inset: auto; width: min(20rem, calc(100vw - 1.5rem)); max-width: none; max-height: calc(100dvh - 1.5rem); margin: 0; overflow: auto; padding: 0; color: var(--ink); font: inherit; background: rgb(255 255 255 / .92); border: 1px solid rgb(222 211 206 / .9); border-radius: 8px; box-shadow: 0 .75rem 2rem rgb(56 38 42 / .18); backdrop-filter: blur(12px); }
.review__tour::backdrop { background: transparent; }
.review__tour-content { padding: clamp(1.5rem, 4vw, 2rem); }
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.2; letter-spacing: -.02em; }
.review__tour p { margin: 0; color: var(--muted); }
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1.5rem; }
.review__tour-content { padding: 1rem; }
.review__tour h2 { margin: 0 0 .4rem; font: 600 1.125rem/1.3 var(--serif); }
.review__tour p { margin: 0; color: var(--muted); font-size: .9rem; }
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1rem; }
.review__tour-progress { color: var(--muted); font-size: .8125rem; }
.review__tour-actions { display: flex; gap: .5rem; }
.review__tour-actions button { min-height: 38px; padding: .4rem .7rem; font-size: .875rem; }
.review__nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; max-width: 1440px; margin: auto; padding: .75rem clamp(1rem, 3vw, 3rem); border-block-end: 1px solid var(--line); }
.review__nav-links, .review__nav-account { display: flex; align-items: center; gap: .5rem; }
.review__nav button, .review__nav a { min-height: 44px; }
@@ -138,15 +148,214 @@
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); } .search__query-row { grid-template-columns: 1fr 1fr; } .search__query-row > input { grid-column: 1 / -1; } .review__nav { align-items: start; flex-wrap: wrap; } .review__nav-links { order: 3; width: 100%; flex-wrap: wrap; } .review__nav-account { margin-inline-start: auto; } }
@media (hover: hover) { button:hover { background: var(--surface-muted); border-color: var(--muted); } .review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); } .review__result:hover { background: var(--subtle); } .review__result-title:hover, .search__result-title:hover { background: none; color: var(--brand); } .review__rating label:hover { border-color: var(--brand); } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
:root { --color-bg: #F7F2E8; --color-surface: #fff; --color-surface-muted: #FBF8F4; --color-text: #38262A; --color-text-secondary: #786469; --color-border: #DED3CE; --color-primary: #9D2633; --color-primary-hover: #84202B; --color-accent: #A37B32; --color-highlight: #FFF0C7; --color-focus: #245C78; --color-success: #246843; --search-header-height: 76px; --search-nav-height: 50px; }
body:has(.search:not([hidden])) { width: 100%; height: 100dvh; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
body:has(.search:not([hidden])) > .review__header { display: none; }
body:has(.search:not([hidden])) > footer.review__header { display: none; }
body:has(.search:not([hidden])) > .review__actions { display: none; }
body.review__locked .search__chrome { visibility: hidden; }
body:has(.search:not([hidden])) > .search__chrome { display: block; }
.search__chrome { display: none; background: var(--surface); border-block-end: 1px solid var(--line); }
.search[hidden] { display: none !important; }
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: minmax(390px, 430px) minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 26px; border-block-end: 1px solid var(--line); }
.search__brand { display: flex; min-width: 0; align-items: center; gap: 12px; color: var(--ink); text-decoration: none; white-space: nowrap; }
.search__brand-mark { width: 44px; height: 44px; flex: none; color: var(--brand); }
.search__brand-name { font: 700 26px/1.1 var(--serif); }
.search__brand-divider { width: 1px; height: 32px; background: var(--line); }
.search__brand-description { color: var(--muted); font-size: 14px; }
.search__query { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 12px; align-items: end; min-width: 0; padding: 0; border: 0; background: transparent; }
.search__query label { display: grid; gap: 4px; color: var(--muted); font-size: 14px; font-weight: 500; }
.search__query label > span { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.search__query-input { position: relative; }
.search__query input { height: 44px; padding-inline: 42px; }
.search__query-icon { position: absolute; inset-inline-start: 12px; inset-block-start: 11px; width: 22px; height: 22px; color: var(--ink); pointer-events: none; }
.search__query-clear { position: absolute; inset-inline-end: 4px; inset-block-start: 3px; width: 38px; height: 38px; min-height: 38px; padding: 0; border: 0; color: var(--muted); }
.search__query .review__button--primary { height: 44px; }
.search__ui-language { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.search__ui-language button { min-width: 36px; min-height: 40px; border: 0; border-radius: 0; padding: 0 4px; color: var(--muted); }
.search__ui-language button[aria-pressed="true"] { color: var(--ink); border-block-end: 2px solid var(--brand); }
.search__account { display: flex; align-items: center; gap: 6px; margin-inline-start: auto; }
.search__account button { width: 40px; min-height: 40px; padding: 8px; }
.search__account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 16px; }
.search__subnav { min-height: var(--search-nav-height); display: flex; align-items: center; gap: 12px; padding: 6px 26px; overflow-x: auto; white-space: nowrap; }
.search__subnav a, .search__subnav button { min-height: 36px; padding: 5px 14px; text-decoration: none; }
.search__mobile-filters, .search__mobile-back, .search__filter-close, .search__filter-overlay { display: none; }
.search__subnav a.is-active { min-width: 97px; background: var(--brand); color: #fff; border-color: var(--brand); }
.search__subnav .search__nav-text { margin-inline: 11px 28px; padding-inline: 0; border: 0; color: var(--ink); font-size: 13px; }
.search__chips { display: flex; flex: 0 1 auto; gap: 8px; align-items: center; min-width: 0; overflow-x: auto; margin-inline-start: -4px; }
.search__chip { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font-size: 14px; }
.search__chips .search__chip { padding-inline: 4px; }
.search__chips .search__chip button { width: 16px; }
.search__language-chip { padding-inline: 4px; }
.search__language-chip select { width: 78px; min-width: 0; min-height: 24px; border: 0; padding: 0 12px 0 0; background: transparent; font-size: 14px; }
html[lang="ky"] .search__language-chip select { width: 100px; }
.search__chip button { min-height: 24px; width: 24px; padding: 0; border: 0; font-size: 18px; line-height: 1; }
.search { width: 100%; max-width: none; min-height: 0; flex: 1; display: flex; flex-direction: column; margin: 0; }
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.search__status { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.search__layout { flex: 1; min-height: 0; grid-template-columns: minmax(210px, 226px) minmax(350px, 422px) minmax(400px, 1fr); border-block: 1px solid var(--line); }
.search__filters, .search__results, .search__document { min-height: 0; padding: 14px 16px 20px; }
.search__filters { position: relative; display: flex; flex-direction: column; overflow: auto; background: var(--surface-muted); padding-inline: 30px 16px; }
.search__filters .review__panel-heading { margin-block-end: 18px; }
.search__filters .review__panel-heading button { min-height: 36px; padding: 2px; border: 0; color: var(--brand); text-decoration: underline; text-underline-offset: 3px; }
.search__filter { margin-block: 0 16px; }
.search__filter--document-type { margin-block-end: 24px; }
.search__filter h3 { margin-block-end: 6px; font-size: 16px; }
.search__filter label { min-height: 30px; gap: 10px; }
.search__filter input[type="radio"] { appearance: none; position: relative; width: 24px; min-height: 24px; height: 24px; flex: none; margin: 0; border: 1px solid var(--line); border-radius: 6px; padding: 0; background: #fff; }
.search__filter input[type="radio"]:checked { border-color: var(--success); background: var(--success); }
.search__filter input[type="radio"]:checked::after { content: ''; position: absolute; width: 6px; height: 11px; inset: 3px 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.search__filter--authority { display: grid; gap: 5px; margin-block: 0 20px; }
.search__filter--authority label { display: grid; min-height: auto; gap: 5px; font-weight: 600; }
.search__filter--authority select { min-height: 42px; padding-block: 7px; font-weight: 400; }
.search__date-label { display: block; margin-block: 0 7px; font-size: 16px; font-weight: 600; }
.search__dates { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.search__dates label { position: relative; display: block; min-width: 0; }
.search__dates input { min-height: 42px; padding: 7px 9px; }
.search__dates label:not(.has-value) input { color: transparent; }
.search__date-placeholder { position: absolute; inset-inline-start: 9px; inset-block-start: 50%; color: var(--muted); font-size: 14px; pointer-events: none; transform: translateY(-50%); }
.search__dates label.has-value .search__date-placeholder { display: none; }
.search__ornament { width: calc(100% - 12px); height: 56px; flex: none; margin: auto 6px 0; color: var(--accent); pointer-events: none; }
.search__results { overflow: auto; background: var(--surface-muted); }
.search__results .review__panel-heading { align-items: start; margin-block-end: 0; }
.search__results .review__panel-heading select { min-height: 40px; padding: 6px 10px; font-size: 14px; }
.search__sort-label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; }
.search__result { position: relative; padding: 12px 10px; border-block-end: 1px solid var(--line); }
.search__result:not(.search__result--selected) { padding-block: 20px; }
.search__result--selected { margin-inline: -6px; border: 1px solid var(--brand); border-radius: 5px; background: var(--subtle); }
.search__result-title { display: block; width: 100%; min-height: auto; margin: 0; padding: 0; text-align: start; font-size: 18px; line-height: 1.35; }
.search__result--selected .search__result-title { padding-inline-end: 68px; }
.search__opened { position: absolute; inset-block-start: 18px; inset-inline-end: 8px; color: var(--muted); font-size: 14px; }
.search__result-meta { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
.search__result-snippet { margin: 6px 0 0; color: var(--ink); font-size: 15px; line-height: 1.45; }
.search__result-snippet mark, .search__document-body mark { color: inherit; background: var(--color-highlight); padding: 0 2px; }
.search__document-body mark.is-current { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.search__result em { font-style: normal; }
.search__pagination { padding-block-start: 12px; }
.search__pagination button { min-height: 40px; padding: 6px 12px; }
.search__document { padding: 14px 28px 28px; background: var(--surface); overflow: auto; }
.search__document-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
.search__breadcrumbs { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 14px; }
.search__breadcrumbs span:first-child { color: var(--brand); }
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 4px; color: var(--ink); font: 700 36px/1 var(--serif); }
.search__document-head .search__expand { min-height: 32px; border: 0; padding: 0 4px; }
.search__document-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; color: var(--muted); }
.search__document-meta a { color: var(--brand); }
.search__document-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 2px; }
.search__document-toolbar button, .search__document-toolbar select { min-height: 42px; padding: 6px 12px; }
.search__document-toolbar label { display: grid; gap: 2px; color: var(--muted); font-size: 12px; }
.search__document-toolbar select { width: 180px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; color: var(--ink); font-size: 14px; }
.search__document-toolbar .search__edition-label { display: block; }
.search__document-toolbar .search__edition-label select { width: 160px; max-width: 160px; }
.search__source-link { max-width: 150px; margin-inline-start: auto; color: var(--brand); font-size: 14px; white-space: nowrap; }
.search__document-language { display: inline-flex !important; align-items: center; gap: 6px !important; margin-inline-start: auto; font-size: 12px !important; }
.search__document-language select { min-height: 28px; max-width: 130px; padding: 4px 8px; font-size: 14px; }
.search__toc { margin-block-end: 0; }
.search__toc summary { width: fit-content; min-height: 42px; padding: 9px 8px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 14px; }
.search__toc ol { margin-block: 8px; padding-inline-start: 24px; }
.search__toc a { color: var(--brand); }
.search__find { display: grid; grid-template-columns: minmax(0,1fr) auto auto auto; gap: 4px; align-items: center; padding: 4px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-muted); }
.search__find label { grid-column: 1 / -1; font-size: 14px; line-height: 1.2; }
.search__find input { min-height: 32px; }
.search__find button { min-width: 40px; min-height: 32px; padding: 4px 10px; }
.search__find-count { white-space: nowrap; font-size: 14px; }
.search__document-note { margin: 2px 0 18px; padding: 4px 10px; border-radius: 6px; background: var(--surface-muted); color: var(--muted); font-size: 14px; line-height: 1.2; }
.search__document-body { max-width: 75ch; margin: 0; font: 400 18px/1.65 "Noto Sans", sans-serif; }
.search__document-body h1, .search__document-body h2, .search__document-body h3 { font-family: var(--serif); }
.search__document-body h2 { margin-block: 27px 0; font-size: 24px; }
.search__document-body p { margin-block: 0 18px; }
.search__document-body h2 + p { margin-block-start: 2px; }
.search__document-body p + h2 { margin-block-start: 39px; }
.search__document-body table { display: block; max-width: 100%; overflow-x: auto; }
.search__document-body img { max-width: 100%; height: auto; }
.search__footer { min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 10px 26px; color: var(--muted); font-size: 14px; }
.search__footer strong { color: var(--ink); }
.search__about { max-width: 42rem; }
.search__about h2 { font: 600 24px/1.3 var(--serif); }
.search__about a { color: var(--brand); }
#document-dialog.search__expanded { width: min(1200px, calc(100vw - 32px)); max-width: none; padding: 24px; }
#document-dialog.search__expanded .search__document-body { max-width: 78ch; margin: 24px auto; }
@media (max-width: 1100px) {
.search__topbar { grid-template-columns: minmax(230px, 310px) minmax(0,1fr) auto; gap: 12px; padding-inline: 20px; }
.search__brand-description, .search__account #signed-in-user { display: none; }
.search__layout { grid-template-columns: minmax(190px, 210px) minmax(320px, 1fr) minmax(380px, 1.3fr); }
.search__filters { padding-inline: 20px 12px; }
}
@media (max-width: 900px) {
body:has(.search:not([hidden])) { height: auto; min-height: 100dvh; overflow: visible; }
.search__topbar { grid-template-columns: auto minmax(0,1fr) auto; }
.search__brand-divider, .search__brand-description { display: none; }
.search__query { grid-template-columns: minmax(0,1fr) 112px; }
.search__layout { grid-template-columns: minmax(0, 1fr); }
.search__filters, .search__results { border-inline-end: 0; border-block-end: 1px solid var(--line); }
.search__filters { padding: 16px 24px; }
.search__filter label { min-height: 44px; }
.search__filters .review__panel-heading button, .search__document-toolbar button, .search__document-toolbar select, .search__document-language select, .search__find input, .search__find button, .search__expand { min-height: 44px; }
.search__mobile-filters { display: inline-flex; }
.search__filters { display: none; }
.search__filters.is-open { position: fixed; z-index: 21; inset: 0 0 0 auto; display: block; width: min(360px, calc(100vw - 32px)); overflow: auto; overscroll-behavior: contain; background: var(--surface-muted); box-shadow: -12px 0 32px rgb(56 38 42 / .18); }
.search__filter-overlay.is-open { position: fixed; z-index: 20; inset: 0; display: block; background: rgb(56 38 42 / .42); }
.search__filter-close { display: inline-flex; }
.search__mobile-back { display: inline-flex; }
.search__layout[data-mobile-view="document"] .search__results { display: none; }
.search__layout[data-mobile-view="document"] .search__document { display: block; }
.search__layout[data-mobile-view="results"] .search__document { display: none; }
.search__layout[data-mobile-view="results"] .search__results { display: block; }
.search__layout[data-mobile-view="document"] .search__filters { display: none; }
.search__ornament { display: none; }
.search__document { min-height: 30rem; }
}
@media (max-width: 600px) {
.search__topbar { grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 10px 16px; }
.search__brand { grid-column: 1; gap: 8px; }
.search__brand-mark { width: 36px; height: 36px; }
.search__brand-name { font-size: 22px; }
.search__ui-language { grid-column: 2; grid-row: 1; }
.search__account { grid-column: 1 / -1; grid-row: 3; }
.search__query { grid-column: 1 / -1; grid-row: 2; grid-template-columns: minmax(0,1fr) 96px; gap: 8px; }
.search__query .review__button--primary { padding-inline: 8px; }
.search__subnav { padding-inline: 12px; gap: 4px; }
.search__chips { margin-inline-start: 4px; }
.search__filters, .search__results { padding: 16px; }
.search__document { padding: 18px 16px; }
.search__document-head h2 { font-size: 26px; }
.search__document-toolbar { gap: 6px; }
.search__source-link { margin-inline-start: 0; }
.search__find { grid-template-columns: minmax(0,1fr) auto auto; }
.search__find-count { grid-column: 1 / -1; grid-row: 3; }
.search__footer { align-items: start; flex-direction: column; padding: 12px 16px; }
.search__dates { max-width: 320px; }
.search__date-label { margin-block-start: 16px; }
}
@media print {
body:has(.search:not([hidden])) { display: block; background: #fff; }
.search__chrome, .search__filters, .search__results, .search__document-toolbar, .search__toc, .search__find, .search__footer, .search__expand { display: none !important; }
.search__document-toolbar { display: block !important; }
.search__document-toolbar label, .search__document-toolbar button, .search__document-toolbar .search__toc { display: none !important; }
.search__source-link { display: inline !important; }
.search, .search__layout, .search__document { display: block; width: auto; min-height: 0; overflow: visible; border: 0; padding: 0; }
.search__document-body { max-width: none; }
}
</style>
</head>
<body>
<a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header">
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="search-nav" href="/">Поиск</a><a id="review-nav" href="/review">Лаборатория</a><button id="account-open" type="button">Личный кабинет</button><button id="users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="signed-in-user"></span><button id="logout" type="button">Выйти</button></div></div>
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><button id="review-users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
<div class="review__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
<h1>Проверка качества поиска</h1><p>Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
</header>
<header class="search__chrome" aria-label="Акылдаш">
<div class="search__topbar">
<a class="search__brand" href="/" aria-label="Акылдаш — поиск по правовой базе">
<svg class="search__brand-mark" viewBox="0 0 48 48" role="img" aria-label="Знак Акылдаш"><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="3"/><path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37M17 7c2 8 12 8 14 0M17 41c2-8 12-8 14 0M7 17c8 2 8 12 0 14M41 17c-8 2-8 12 0 14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
<span class="search__brand-name">Акылдаш</span><span class="search__brand-divider" aria-hidden="true"></span><span class="search__brand-description">Правовая база Кыргызстана</span>
</a>
<form class="search__query" id="public-search-form"><div><label for="public-query">Поиск по правовой базе</label><div class="search__query-input"><svg class="search__query-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m16 16 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="search__query-clear" id="clear-query" type="button" aria-label="Очистить запрос"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></div><button class="review__button--primary" type="submit">Найти</button></form>
<div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="ui-ky" type="button" aria-pressed="false">KY</button></div>
</div>
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></nav>
</header>
<main class="review__main" id="review">
<form class="review__search" id="search-form">
<label class="review__field">Юридический запрос<input id="query" name="q" maxlength="500" required autocomplete="off" placeholder="Например, как открыть ОсОО?"></label>
@@ -162,21 +371,22 @@
</main>
<main class="search" id="search-page" hidden>
<h1 class="search__sr-only">Поиск по правовой базе</h1>
<form class="search__query" id="public-search-form"><label for="public-query">Поиск по правовой базе</label><div class="search__query-row"><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="review__button--primary" type="submit">Найти</button><label class="search__language">Язык<select id="public-language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label></div></form>
<p class="review__status" id="public-status" role="status" aria-live="polite"></p>
<div class="search__layout"><aside class="search__filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button></div><div id="filter-fields"></div><label>Дата принятия от<input id="date-from" type="date"></label><label>Дата принятия до<input id="date-to" type="date"></label></aside>
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label>Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
<section class="search__document" aria-labelledby="public-document-heading"><div class="review__panel-heading"><h2 id="public-document-heading">Документ</h2></div><div class="search__document-controls"><label>Редакция<select id="document-edition" disabled></select></label><label>Язык документа<select id="document-language" disabled></select></label></div><p id="public-document-meta" class="review__document-meta">Выберите результат, чтобы прочитать доступную редакцию.</p><article id="public-document-body" class="review__document-body"></article></section>
<p class="search__sr-only" id="public-status" role="status" aria-live="polite"></p>
<div class="search__layout" id="search-layout"><aside class="search__filters" id="search-filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button><button class="search__filter-close" id="mobile-filters-close" type="button">Закрыть фильтры</button></div><div id="filter-fields"></div><span class="search__date-label">Дата принятия</span><div class="search__dates"><label><span class="search__sr-only">Дата принятия от</span><input id="date-from" type="date" aria-label="Дата принятия от"><span class="search__date-placeholder" aria-hidden="true">От</span></label><label><span class="search__sr-only">Дата принятия до</span><input id="date-to" type="date" aria-label="Дата принятия до"><span class="search__date-placeholder" aria-hidden="true">До</span></label></div><svg class="search__ornament" viewBox="0 0 220 56" aria-hidden="true"><path d="M2 34h60c15 0 17 17 31 17s16-17 30-17h95M2 41h55c13 0 17 13 31 13 15 0 17-13 31-13h99M98 42c-15-16 7-17 13-29 6 12 28 13 13 29-8 8-18 8-26 0Zm13-29v34M106 39l15-15m-15 15L96 27" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></aside><button class="search__filter-overlay" id="filter-overlay" type="button" aria-label="Закрыть фильтры"></button>
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label class="search__sort-label">Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs"><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите результат, чтобы прочитать доступную редакцию.</span><label class="search__document-language">Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
</div>
</main>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Концепция Түндүк</span><span>Frontend v0.2.3 <span aria-hidden="true">·</span> Backend v0.13.0</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div></div>
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Закрыть</button></div></form></section></dialog>
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Закрыть</button></div></form></section></dialog>
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.1.2 · Backend v0.11.3 · Внутренняя лаборатория релевантности</div></footer>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.3 · Backend v0.13.0 · Внутренняя лаборатория релевантности</div></footer>
<script>
document.body.classList.add('review__locked');
const DRAFT_KEY = 'akyldash-search-review-draft';
@@ -191,74 +401,436 @@
const state = { results: [], token: '', selected: null, query: '' };
const $ = (id) => document.getElementById(id);
let account = null;
let uiLanguage = 'ru';
const searchCopy = {
ru: {brand: 'Правовая база Кыргызстана', queryLabel: 'Поиск по правовой базе', queryPlaceholder: 'Введите правовой вопрос или название документа', clearQuery: 'Очистить запрос', search: 'Поиск', sources: 'Об источниках', searchLanguage: 'Язык поиска', russian: 'Русский', kyrgyz: 'Кыргызский', filters: 'Фильтры', clear: 'Сбросить', close: 'Закрыть фильтры', date: 'Дата принятия', from: 'От', to: 'До', results: 'Результаты', enterQuery: 'Введите запрос', sort: 'Сортировка', relevance: 'По релевантности', dateSort: 'По дате', noResults: 'Документы не найдены', noResultsHint: 'Измените запрос или сбросьте фильтры.', noTitle: 'Название не указано', noSnippet: 'Для документа нет выдержки из текста.', oneResult: 'результат на странице', manyResults: 'результатов на странице', noResultsCount: 'Поиск недоступен', page: 'Страница', previous: 'Назад', next: 'Далее', document: 'Документ', chooseResult: 'Выберите результат', chooseDocument: 'Выберите результат, чтобы прочитать доступную редакцию.', documentLanguage: 'Язык', toc: 'Содержание', noSections: 'В тексте нет разделов', editionMissing: 'В этой редакции нет доступного текста на выбранном языке.', loadingEdition: 'Загрузка редакции…', editionUnavailable: 'Редакция недоступна.', noEditions: 'Для документа нет доступных редакций с текстом.', find: 'Найти в документе', findPlaceholder: 'Фраза в документе', noMatches: 'Совпадений нет', print: 'Печать', source: 'Официальный источник', expand: 'Развернуть', back: 'К результатам', opened: 'Открыт', queryRequired: 'Введите запрос, чтобы начать поиск.', searching: 'Выполняется поиск…', searchError: 'Не удалось выполнить поиск. Повторите попытку.', noDocuments: 'документов нет. Измените запрос или фильтры.', resultsFor: 'Результаты для запроса', allAuthorities: 'Все органы'},
ky: {brand: 'Кыргызстандын укуктук базасы', queryLabel: 'Укуктук база боюнча издөө', queryPlaceholder: 'Укуктук суроону же документтин аталышын киргизиңиз', clearQuery: 'Суроону тазалоо', search: 'Издөө', sources: 'Булактар жөнүндө', searchLanguage: 'Издөө тили', russian: 'Орусча', kyrgyz: 'Кыргызча', filters: 'Чыпкалар', clear: 'Тазалоо', close: 'Чыпкаларды жабуу', date: 'Кабыл алынган күнү', from: 'Баштап', to: 'Чейин', results: 'Натыйжалар', enterQuery: 'Суроо киргизиңиз', sort: 'Иреттөө', relevance: 'Маанилүүлүгү боюнча', dateSort: 'Датасы боюнча', noResults: 'Документтер табылган жок', noResultsHint: 'Суроону өзгөртүңүз же чыпкаларды тазалаңыз.', noTitle: 'Аталышы көрсөтүлгөн эмес', noSnippet: 'Документтин үзүндүсү жок.', oneResult: 'натыйжа бетте', manyResults: 'натыйжа бетте', noResultsCount: 'Издөө жеткиликсиз', page: 'Бет', previous: 'Артка', next: 'Кийинки', document: 'Документ', chooseResult: 'Натыйжаны тандаңыз', chooseDocument: 'Текстин окуу үчүн тизмеден натыйжаны тандаңыз.', documentLanguage: 'Тили', toc: 'Мазмуну', noSections: 'Текстте бөлүмдөр жок', editionMissing: 'Бул редакцияда тандалган тилдеги текст жок.', loadingEdition: 'Редакция жүктөлүүдө…', editionUnavailable: 'Редакция жеткиликсиз.', noEditions: 'Документтин жеткиликтүү редакциялары жок.', find: 'Документтен издөө', findPlaceholder: 'Документтен сөз айкашын издөө', noMatches: 'Дал келген нерсе табылган жок', print: 'Басып чыгаруу', source: 'Расмий булак', expand: 'Кеңейтүү', back: 'Натыйжаларга кайтуу', opened: 'Ачылды', queryRequired: 'Издөө үчүн суроо киргизиңиз.', searching: 'Издөө аткарылууда…', searchError: 'Издөө аткарылган жок. Кайра аракет кылыңыз.', noDocuments: 'документ табылган жок. Суроону же чыпкаларды өзгөртүңүз.', resultsFor: 'Суроо боюнча натыйжалар', allAuthorities: 'Бардык органдар'}
};
const copy = (key) => searchCopy[uiLanguage][key];
const modalCopy = {
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
};
const modalMessage = (key) => modalCopy[uiLanguage][key];
const reviewRoute = location.pathname === '/review';
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.1.2 · Backend v0.11.3'; }
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', !reviewRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.3 · Backend v0.13.0'; }
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
const filterFields = [['document_types', 'Вид документа', 'document_type'], ['statuses', 'Статус', 'status'], ['authorities', 'Орган принятия', 'authority'], ['legal_forces', 'Юридическая сила', 'legal_force']];
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('review-signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('search-users-open').hidden = $('review-users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
let filterCatalogs = {};
let filterCatalogsLoaded = false;
let publicPage = 1;
let selectedPublicDocument = null;
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
function renderFilterCatalogs(payload) { filterCatalogs = payload; const root = $('filter-fields'); root.replaceChildren(); filterFields.forEach(([key, title, parameter]) => { const items = payload[key] || []; if (!items.length) return; const group = document.createElement('section'); group.className = 'search__filter'; const heading = document.createElement('h3'); heading.textContent = title; group.append(heading); items.forEach((item) => { const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'radio'; input.name = `filter-${parameter}`; input.value = item.code; input.addEventListener('change', () => runSearch(1)); const text = document.createElement('span'); text.textContent = `${item.labels.ru || item.labels.ky || item.code} (${item.count})`; label.append(input, text); group.append(label); }); root.append(group); }); }
function renderPublicResults(results) { const list = $('public-results'); list.replaceChildren(); if (!results.length) { list.innerHTML = '<li class="review__empty"><strong>Документы не найдены</strong>Измените запрос или сбросьте фильтры.</li>'; return; } results.forEach((result, index) => { const row = document.createElement('li'); row.className = 'search__result'; const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || 'Название не указано'; title.addEventListener('click', () => openPublicDocument(result, row)); const meta = document.createElement('p'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · '); const snippet = document.createElement('p'); snippet.textContent = result.snippet || 'Для документа нет выдержки из текста.'; row.append(title, meta, snippet); list.append(row); }); }
async function runSearch(page = 1) { const query = $('public-query').value.trim(); if (!query) { $('public-status').textContent = 'Введите запрос, чтобы начать поиск.'; return; } publicPage = page; $('public-status').textContent = 'Выполняется поиск…'; const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(page), page_size: '20', sort: $('sort-order').value}); for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value); if ($('date-from').value) params.set('date_from', $('date-from').value); if ($('date-to').value) params.set('date_to', $('date-to').value); history.replaceState(null, '', `/?${params}`); try { const response = await apiFetch(`/search?${params}`); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || 'Не удалось выполнить поиск.'); renderPublicResults(payload.results); $('result-count').textContent = `${payload.results.length} документов`; $('public-status').textContent = payload.results.length ? `Результаты для запроса «${query}».` : `По запросу «${query}» документов нет. Измените запрос или фильтры.`; $('page-label').textContent = `Страница ${page}`; $('page-prev').disabled = page <= 1; $('page-next').disabled = !payload.has_next; } catch (error) { $('public-status').textContent = error.message || 'Не удалось выполнить поиск. Повторите попытку.'; $('result-count').textContent = 'Поиск недоступен'; } }
let searchRequest = 0;
let editionRequest = 0;
let currentDocumentHtml = '';
let documentMatches = [];
let activeDocumentMatch = -1;
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, parameter === 'authority' ? $('filter-authority')?.value || '' : document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
const syncDateHint = (id) => $(`${id}`).parentElement.classList.toggle('has-value', Boolean($(`${id}`).value));
function renderFilterCatalogs(payload) {
filterCatalogs = payload;
filterCatalogsLoaded = true;
const root = $('filter-fields');
root.replaceChildren();
filterFields.forEach(([key, titles, parameter]) => {
const title = titles[uiLanguage];
const items = payload[key] || [];
if (!items.length) return;
const group = document.createElement('section');
group.className = parameter === 'authority' ? 'search__filter search__filter--authority' : parameter === 'document_type' ? 'search__filter search__filter--document-type' : 'search__filter';
if (parameter === 'authority') {
const label = document.createElement('label');
label.htmlFor = 'filter-authority';
label.append(document.createTextNode(title));
const select = document.createElement('select');
select.id = 'filter-authority';
const all = document.createElement('option');
all.value = '';
all.textContent = copy('allAuthorities');
select.append(all);
items.forEach((item) => { const option = document.createElement('option'); option.value = item.code; option.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`; select.append(option); });
select.addEventListener('change', () => runSearch(1));
label.append(select);
group.append(label);
} else {
const heading = document.createElement('h3');
heading.textContent = title;
group.append(heading);
items.forEach((item) => {
const label = document.createElement('label');
const input = document.createElement('input');
input.type = 'radio'; input.name = `filter-${parameter}`; input.value = item.code;
input.addEventListener('change', () => runSearch(1));
const text = document.createElement('span');
text.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`;
label.append(input, text); group.append(label);
});
}
root.append(group);
});
}
function syncFilterChips() {
const root = $('active-filters');
root.replaceChildren();
const filters = selectedFilters();
const labels = Object.fromEntries(filterFields.map(([key, titles, parameter]) => [parameter, {title: titles[uiLanguage], items: filterCatalogs[key] || []}]));
Object.entries(filters).forEach(([parameter, value]) => {
if (!value) return;
const item = labels[parameter].items.find((candidate) => candidate.code === value);
const chip = document.createElement('span'); chip.className = 'search__chip';
chip.append(document.createTextNode(item?.labels[uiLanguage] || item?.labels.ru || item?.labels.ky || value));
const remove = document.createElement('button'); remove.type = 'button'; remove.innerHTML = '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; remove.setAttribute('aria-label', `Убрать фильтр «${labels[parameter].title}»`);
remove.addEventListener('click', () => { if (parameter === 'authority') $('filter-authority').value = ''; else { const input = document.querySelector(`input[name="filter-${parameter}"]:checked`); if (input) input.checked = false; } runSearch(1); });
chip.append(remove); root.append(chip);
});
[['date-from', 'date_from', copy('from')], ['date-to', 'date_to', copy('to')]].forEach(([id, key, label]) => {
if (!$(`${id}`).value) return;
const value = $(`${id}`).value;
const chip = document.createElement('span'); chip.className = 'search__chip'; chip.append(document.createTextNode(`${label}: ${value}`));
const remove = document.createElement('button'); remove.type = 'button'; remove.innerHTML = '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; remove.setAttribute('aria-label', `Убрать дату ${value}`); remove.addEventListener('click', () => { $(id).value = ''; syncDateHint(id); runSearch(1); });
chip.append(remove); root.append(chip);
});
}
function appendHighlighted(parent, value, query) {
const text = String(value || '');
if (!query) { parent.textContent = text; return; }
const terms = query.split(/\s+/).map((term) => `${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${/^\p{L}{5,}$/u.test(term) ? '\\p{L}{0,2}' : ''}`);
const pattern = new RegExp(terms.join('\\s+'), 'giu');
let cursor = 0;
for (const match of text.matchAll(pattern)) {
parent.append(document.createTextNode(text.slice(cursor, match.index)));
const mark = document.createElement('mark'); mark.textContent = match[0]; parent.append(mark);
cursor = match.index + match[0].length;
}
parent.append(document.createTextNode(text.slice(cursor)));
}
function renderPublicResults(results) {
const list = $('public-results');
list.replaceChildren();
if (!results.length) {
const empty = document.createElement('li'); empty.className = 'review__empty';
const heading = document.createElement('strong'); heading.textContent = copy('noResults');
empty.append(heading, document.createTextNode(copy('noResultsHint'))); list.append(empty); return;
}
results.forEach((result) => {
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || copy('noTitle');
title.addEventListener('click', () => openPublicDocument(result, row));
const meta = document.createElement('p'); meta.className = 'search__result-meta'; meta.textContent = [result.type, result.status, $('public-language').value.toUpperCase(), result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · ');
const snippet = document.createElement('p'); snippet.className = 'search__result-snippet'; appendHighlighted(snippet, result.snippet || copy('noSnippet'), $('public-query').value.trim());
row.append(title, meta, snippet); list.append(row);
});
}
async function runSearch(page = 1, updateUrl = true) {
const query = $('public-query').value.trim();
if (!query) { $('public-status').textContent = copy('queryRequired'); syncFilterChips(); return; }
publicPage = Math.max(1, page);
const requestId = ++searchRequest;
$('public-status').textContent = copy('searching');
const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(publicPage), page_size: '20', sort: $('sort-order').value});
for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value);
if ($('date-from').value) params.set('date_from', $('date-from').value);
if ($('date-to').value) params.set('date_to', $('date-to').value);
if (updateUrl) history.pushState(null, '', `/?${params}`);
syncFilterChips();
try {
const response = await apiFetch(`/search?${params}`);
const payload = await response.json();
if (requestId !== searchRequest) return;
if (!response.ok) throw new Error(payload.error || copy('searchError'));
renderPublicResults(payload.results || []);
const count = (payload.results || []).length;
$('result-count').textContent = `${count} ${count === 1 ? copy('oneResult') : copy('manyResults')}`;
$('public-status').textContent = count ? `${copy('resultsFor')} «${query}».` : `«${query}»: ${copy('noDocuments')}`;
$('page-label').textContent = `${copy('page')} ${publicPage}`;
$('page-prev').disabled = publicPage <= 1;
$('page-next').disabled = !payload.has_next;
} catch (error) {
if (requestId !== searchRequest) return;
$('public-status').textContent = error.message || copy('searchError');
$('result-count').textContent = copy('noResultsCount');
}
}
function buildDocumentToc() {
const body = $('public-document-body');
const headings = [...body.querySelectorAll('h1, h2, h3')];
const root = $('document-toc-list'); root.replaceChildren();
if (!headings.length) { const item = document.createElement('li'); item.textContent = copy('noSections'); root.append(item); return; }
headings.forEach((heading, index) => {
if (!heading.id) heading.id = `document-section-${index + 1}`;
const item = document.createElement('li'); const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent.trim();
link.addEventListener('click', (event) => { event.preventDefault(); heading.scrollIntoView({block: 'start'}); heading.focus({preventScroll: true}); });
if (!heading.hasAttribute('tabindex')) heading.tabIndex = -1;
item.append(link); root.append(item);
});
}
function highlightDocumentMatches() {
const body = $('public-document-body'); body.innerHTML = currentDocumentHtml;
documentMatches = []; activeDocumentMatch = -1;
const query = $('document-find').value.trim();
if (query) {
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {acceptNode: (node) => node.parentElement.closest('script, style, mark') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT});
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(escaped, 'giu');
nodes.forEach((node) => {
const text = node.nodeValue; const matches = [...text.matchAll(pattern)];
if (!matches.length) return;
const fragment = document.createDocumentFragment(); let cursor = 0;
matches.forEach((match) => { fragment.append(document.createTextNode(text.slice(cursor, match.index))); const mark = document.createElement('mark'); mark.textContent = match[0]; mark.dataset.match = String(documentMatches.length); documentMatches.push(mark); fragment.append(mark); cursor = match.index + match[0].length; });
fragment.append(document.createTextNode(text.slice(cursor))); node.replaceWith(fragment);
});
}
const count = documentMatches.length;
$('document-find-count').textContent = count ? `1 ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${count}` : query ? copy('noMatches') : '';
$('document-find-prev').disabled = $('document-find-next').disabled = count === 0;
if (count) moveDocumentMatch(1);
buildDocumentToc();
}
function moveDocumentMatch(step) {
if (!documentMatches.length) return;
activeDocumentMatch = (activeDocumentMatch + step + documentMatches.length) % documentMatches.length;
documentMatches.forEach((mark, index) => { mark.classList.toggle('is-current', index === activeDocumentMatch); });
$('document-find-count').textContent = `${activeDocumentMatch + 1} ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${documentMatches.length}`;
documentMatches[activeDocumentMatch].scrollIntoView({block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'});
}
let filterReturnFocus = null;
function closeMobileFilters() {
$('search-filters').classList.remove('is-open'); $('filter-overlay').classList.remove('is-open');
$('search-filters').removeAttribute('aria-modal'); $('search-filters').removeAttribute('role');
if (matchMedia('(max-width: 900px)').matches) $('search-filters').setAttribute('aria-hidden', 'true'); else $('search-filters').removeAttribute('aria-hidden');
$('mobile-filters-open').setAttribute('aria-expanded', 'false');
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = false);
filterReturnFocus?.focus();
}
function openMobileFilters() {
filterReturnFocus = document.activeElement;
$('search-filters').classList.add('is-open'); $('filter-overlay').classList.add('is-open');
$('search-filters').setAttribute('role', 'dialog'); $('search-filters').setAttribute('aria-modal', 'true'); $('search-filters').setAttribute('aria-hidden', 'false'); $('mobile-filters-open').setAttribute('aria-expanded', 'true');
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = true);
$('mobile-filters-close').focus();
}
function setMobileView(view) {
if (!matchMedia('(max-width: 900px)').matches) { $('search-layout').removeAttribute('data-mobile-view'); return; }
$('search-layout').dataset.mobileView = view;
if (view === 'document') { $('public-document-heading').tabIndex = -1; $('public-document-heading').focus(); }
}
async function loadPublicEdition() {
if (!selectedPublicDocument) return;
const requestId = ++editionRequest;
const {code} = selectedPublicDocument;
const edition = $('document-edition').value;
const editionInfo = selectedPublicDocument.editions.find((item) => item.source_code === edition);
let language = $('document-language').value;
const editionInfo = selectedPublicDocument.editions.find((item) => String(item.source_code) === String(edition));
const languages = editionInfo?.available_languages || [];
const languageSelect = $('document-language');
[...languageSelect.options].forEach((option) => { option.disabled = !languages.includes(option.value); });
if (!languages.length) { $('public-document-meta').textContent = 'В этой редакции нет доступного текста.'; $('public-document-body').replaceChildren(); return; }
if (!languages.includes(languageSelect.value)) languageSelect.value = languages[0];
const language = languageSelect.value;
$('public-document-meta').textContent = 'Загрузка редакции…';
if (!languages.includes(language) && languages.length) { language = languages[0]; $('document-language').value = language; }
$('document-source').hidden = true; $('document-source').removeAttribute('href');
currentDocumentHtml = ''; $('public-document-body').replaceChildren();
if (!languages.includes(language)) { $('document-facts').textContent = copy('editionMissing'); return; }
$('document-facts').textContent = copy('loadingEdition');
try {
const response = await apiFetch(`/documents/${encodeURIComponent(code)}/editions/${encodeURIComponent(edition)}?language=${encodeURIComponent(language)}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Редакция недоступна.');
if (requestId !== editionRequest || selectedPublicDocument?.code !== code || $('document-edition').value !== edition || $('document-language').value !== language) return;
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
const content = payload.content[language];
const meta = $('public-document-meta'); meta.replaceChildren();
const details = document.createElement('span'); details.textContent = [selectedPublicDocument.type, selectedPublicDocument.status, selectedPublicDocument.date_adopted].filter(Boolean).join(' · ');
const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = 'Официальный источник';
meta.append(details, document.createTextNode(' · '), source);
$('public-document-body').innerHTML = content.html;
} catch (error) { $('public-document-meta').textContent = error.message || 'Не удалось загрузить документ.'; $('public-document-body').replaceChildren(); }
const meta = $('document-facts'); meta.replaceChildren();
const type = document.createElement('span'); type.textContent = selectedPublicDocument.type || copy('document');
const status = document.createElement('span'); status.textContent = selectedPublicDocument.status || '';
const date = document.createElement('span'); date.textContent = selectedPublicDocument.date_adopted || '';
meta.append(type, document.createTextNode(' · '), status, ...(date.textContent ? [document.createTextNode(' · '), date] : []));
const source = $('document-source'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.hidden = false;
currentDocumentHtml = content.html || '';
$('public-document-body').innerHTML = currentDocumentHtml;
$('public-document-body').lang = language;
$('document-find').disabled = false; $('document-note').hidden = true;
highlightDocumentMatches();
} catch (error) {
if (requestId !== editionRequest) return;
$('document-source').hidden = true; $('document-source').removeAttribute('href');
currentDocumentHtml = '';
$('document-facts').textContent = error.message || copy('editionUnavailable');
$('public-document-body').replaceChildren();
}
}
async function openPublicDocument(result, row) {
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
row.classList.add('search__result--selected'); selectedPublicDocument = result;
$('public-document-heading').textContent = result.name || 'Документ'; $('public-document-body').replaceChildren();
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest; currentDocumentHtml = '';
row.querySelector('.search__opened')?.remove();
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); row.append(opened);
$('document-breadcrumb').textContent = result.name || copy('document');
$('public-document-heading').textContent = result.name || copy('document'); $('public-document-body').replaceChildren();
setMobileView('document');
$('document-find').value = ''; $('document-find').disabled = true; $('document-note').hidden = true;
$('document-source').hidden = true; $('document-source').removeAttribute('href'); $('document-edition').replaceChildren(); $('document-language').replaceChildren();
try {
const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Не удалось получить список редакций.');
if (selectedPublicDocument !== result) return;
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
selectedPublicDocument.editions = payload.editions || [];
const editionSelect = $('document-edition'); editionSelect.replaceChildren();
const editionSelect = $('document-edition');
(payload.editions || []).forEach((edition) => { const option = document.createElement('option'); option.value = edition.source_code; const name = edition.name?.[$('public-language').value] || edition.name?.ru || edition.name?.ky; option.textContent = [name, edition.source_code].filter(Boolean).join(' · '); editionSelect.append(option); });
const matchedEdition = [...editionSelect.options].find((option) => option.value === String(result.edition));
if (matchedEdition) editionSelect.value = matchedEdition.value;
editionSelect.disabled = !editionSelect.options.length;
const languages = payload.available_languages || [];
const languageSelect = $('document-language'); languageSelect.replaceChildren();
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? 'Русский' : 'Кыргызский'; languageSelect.append(option); });
const languageSelect = $('document-language');
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? copy('russian') : copy('kyrgyz'); languageSelect.append(option); });
languageSelect.disabled = !languages.length;
if (!languages.length || !editionSelect.options.length) throw new Error('Для документа нет доступных редакций с текстом.');
if (!languages.length || !editionSelect.options.length) throw new Error(copy('noEditions'));
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
await loadPublicEdition();
} catch (error) { $('public-document-meta').textContent = error.message || 'Документ недоступен.'; }
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
}
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
async function initSearchPage() { if (reviewRoute || filterCatalogs.length) return; const params = new URLSearchParams(location.search); $('public-query').value = params.get('q') || ''; $('public-language').value = params.get('language') || 'ru'; $('sort-order').value = params.get('sort') || 'relevance'; $('date-from').value = params.get('date_from') || ''; $('date-to').value = params.get('date_to') || ''; const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); for (const [key,, parameter] of filterFields) { const value = params.get(parameter); const input = value && document.querySelector(`input[name="filter-${parameter}"][value="${CSS.escape(value)}"]`); if (input) input.checked = true; } const query = params.get('q'); if (query) runSearch(Number(params.get('page') || 1)); }
async function initSearchPage() {
if (reviewRoute) return;
const params = new URLSearchParams(location.search);
$('public-query').value = params.get('q') || '';
$('public-language').value = params.get('language') || 'ru';
$('sort-order').value = params.get('sort') || 'relevance';
$('date-from').value = params.get('date_from') || '';
$('date-to').value = params.get('date_to') || '';
syncDateHint('date-from'); syncDateHint('date-to');
const uiLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })();
setUiLanguage(uiLanguage);
setMobileView('results');
if (!filterCatalogsLoaded) { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); }
document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; });
if ($('filter-authority')) $('filter-authority').value = '';
for (const [key,, parameter] of filterFields) {
const value = params.get(parameter);
if (parameter === 'authority') { const select = $('filter-authority'); if (select) select.value = value || ''; }
else { const input = value && document.querySelector(`input[name="filter-${parameter}"][value="${CSS.escape(value)}"]`); if (input) input.checked = true; }
}
syncFilterChips();
if (params.get('q')) await runSearch(Number(params.get('page') || 1), false);
}
function setUiLanguage(language) {
uiLanguage = language === 'ky' ? 'ky' : 'ru';
const ky = uiLanguage === 'ky';
$('ui-ru').setAttribute('aria-pressed', String(!ky)); $('ui-ky').setAttribute('aria-pressed', String(ky));
document.documentElement.lang = ky ? 'ky' : 'ru';
const setLabel = (selector, value) => { const label = document.querySelector(selector); if (label?.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${value} `; };
const setLabels = (selector, values) => document.querySelectorAll(selector).forEach((label, index) => { if (label.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${values[index]} `; });
const setText = (id, key) => { if ($(id)) $(id).textContent = copy(key); };
setText('search-nav', 'search'); setText('sources-open', 'sources'); setText('filters-heading', 'filters');
setText('clear-filters', 'clear'); setText('mobile-filters-open', 'filters'); setText('mobile-filters-close', 'close');
setText('search-results-heading', 'results'); setText('document-print', 'print');
$('search-account-open').setAttribute('aria-label', ky ? 'Жеке кабинет' : 'Личный кабинет'); $('search-account-open').title = $('search-account-open').getAttribute('aria-label');
$('public-search-form').querySelector('button[type="submit"]').textContent = ky ? 'Издөө' : 'Найти';
$('public-search-form').querySelector('label[for="public-query"]').textContent = copy('queryLabel');
$('public-query').placeholder = copy('queryPlaceholder'); $('clear-query').setAttribute('aria-label', copy('clearQuery'));
$('search-page').querySelector('h1').textContent = copy('queryLabel');
document.querySelector('.search__brand-description').textContent = copy('brand');
document.querySelector('.search__ui-language').setAttribute('aria-label', ky ? 'Туюнтма тили' : 'Язык интерфейса');
$('public-language').setAttribute('aria-label', copy('searchLanguage'));
$('public-language').options[0].textContent = copy('russian'); $('public-language').options[1].textContent = copy('kyrgyz');
$('sort-order').options[0].textContent = copy('relevance'); $('sort-order').options[1].textContent = copy('dateSort');
setLabel('.search__sort-label', copy('sort')); setText('result-count', 'enterQuery');
document.querySelector('.search__date-label').textContent = copy('date');
document.querySelectorAll('.search__date-placeholder')[0].textContent = copy('from'); document.querySelectorAll('.search__date-placeholder')[1].textContent = copy('to');
$('date-from').setAttribute('aria-label', ky ? 'Кабыл алынган күндөн' : 'Дата принятия от'); $('date-to').setAttribute('aria-label', ky ? 'Кабыл алынган күнгө чейин' : 'Дата принятия до');
$('page-prev').textContent = copy('previous'); $('page-next').textContent = copy('next');
setText('document-back', 'back'); setText('document-expand', 'expand'); $('document-expand').setAttribute('aria-label', copy('expand'));
const crumbs = document.querySelector('.search__breadcrumbs').children; crumbs[0].textContent = copy('document');
if (!selectedPublicDocument) { setText('document-breadcrumb', 'chooseResult'); setText('document-facts', 'chooseDocument'); setText('public-document-heading', 'document'); }
setLabel('.search__document-language', ky ? 'Тили' : 'Язык'); $('document-language').setAttribute('aria-label', ky ? 'Документтин тили' : 'Язык документа');
$('document-toc').querySelector('summary').textContent = ky ? 'Мазмуну' : 'Содержание';
$('document-edition').setAttribute('aria-label', ky ? 'Редакциясы' : 'Редакция документа');
$('document-find').previousElementSibling.textContent = copy('find'); $('document-find').placeholder = copy('findPlaceholder');
$('document-find-prev').setAttribute('aria-label', ky ? 'Мурунку дал келүү' : 'Предыдущее совпадение'); $('document-find-next').setAttribute('aria-label', ky ? 'Кийинки дал келүү' : 'Следующее совпадение');
$('document-source').textContent = copy('source');
const modal = modalCopy[uiLanguage];
setText('account-heading', 'account'); $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.users; $('search-logout').textContent = modal.logout;
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
const accountLabels = document.querySelectorAll('#account-dialog dt'); accountLabels[1].textContent = modal.role; accountLabels[2].textContent = modal.created;
$('profile-created').textContent = account?.created_at ? new Intl.DateTimeFormat(ky ? 'ky-KG' : 'ru-RU').format(new Date(account.created_at * 1000)) : '';
document.querySelector('#profile-password-form h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword]);
document.querySelector('#profile-password-form p').textContent = modal.passwordRequirement;
document.querySelector('#profile-password-form button[type="submit"]').textContent = modal.change;
document.querySelector('[data-close="account-dialog"]').textContent = modal.close;
$('users-heading').textContent = modal.usersHeading; setLabels('#create-user-form label', [modal.username, modal.temporaryPassword]);
document.querySelector('#create-user-form p').textContent = modal.temporaryPasswordHelp;
document.querySelector('#create-user-form button').textContent = modal.createUser;
document.querySelector('#users-dialog [data-close="users-dialog"]').textContent = modal.close;
$('login-heading').textContent = modal.loginHeading; document.querySelector('#login-form > p').textContent = modal.loginHelp;
setLabels('#login-form label', [modal.username, modal.password]); document.querySelector('#login-form button').textContent = modal.login;
$('password-heading').textContent = modal.newPasswordHeading; document.querySelector('#password-form .review__notice').textContent = modal.newPasswordHelp;
setLabels('#password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]); document.querySelector('#password-form button').textContent = modal.change;
$('dialog-close').textContent = $('sources-close').textContent = modal.close;
$('sources-heading').textContent = modal.sourcesHeading;
document.querySelector('#sources-dialog p:first-of-type').textContent = modal.sourceIntro;
document.querySelector('#sources-dialog p:nth-of-type(2)').firstChild.textContent = `${modal.officialSource} `;
document.querySelector('#sources-dialog a').textContent = modal.sourceName;
document.querySelectorAll('.search__opened').forEach((item) => { item.textContent = copy('opened'); });
if (documentMatches.length) $('document-find-count').textContent = `${activeDocumentMatch + 1} ${ky ? 'ичинен' : 'из'} ${documentMatches.length}`;
else if ($('document-find').value.trim()) $('document-find-count').textContent = copy('noMatches');
const resultTotal = $('public-results').querySelector('.review__empty') ? 0 : $('public-results').children.length;
if ($('public-query').value.trim()) {
$('result-count').textContent = `${resultTotal} ${resultTotal === 1 ? copy('oneResult') : copy('manyResults')}`;
$('public-status').textContent = resultTotal ? `${copy('resultsFor')} «${$('public-query').value.trim()}».` : `«${$('public-query').value.trim()}»: ${copy('noDocuments')}`;
} else {
setText('result-count', 'enterQuery'); setText('public-status', 'queryRequired');
}
const empty = $('public-results').querySelector('.review__empty');
if (empty) { empty.querySelector('strong').textContent = copy('noResults'); empty.lastChild.textContent = copy('noResultsHint'); }
const pageMatch = $('page-label').textContent.match(/\d+/); if (pageMatch) $('page-label').textContent = `${copy('page')} ${pageMatch[0]}`;
if (filterCatalogsLoaded) {
const selected = selectedFilters(); renderFilterCatalogs(filterCatalogs);
for (const [key, , parameter] of filterFields) {
if (parameter === 'authority') { if ($('filter-authority')) $('filter-authority').value = selected[parameter]; }
else { const input = [...document.querySelectorAll(`input[name="filter-${parameter}"]`)].find((item) => item.value === selected[parameter]); if (input) input.checked = true; }
}
syncFilterChips();
}
try { localStorage.setItem('akyldash-ui-language', ky ? 'ky' : 'ru'); } catch (_) {}
}
$('ui-ru').addEventListener('click', () => setUiLanguage('ru'));
$('ui-ky').addEventListener('click', () => setUiLanguage('ky'));
$('public-search-form').addEventListener('submit', (event) => { event.preventDefault(); runSearch(1); });
$('sort-order').addEventListener('change', () => runSearch(1)); $('date-from').addEventListener('change', () => runSearch(1)); $('date-to').addEventListener('change', () => runSearch(1)); $('public-language').addEventListener('change', async () => { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); if ($('public-query').value.trim()) runSearch(1); });
$('clear-query').addEventListener('click', () => { $('public-query').value = ''; $('public-query').focus(); $('public-query').dispatchEvent(new Event('input', {bubbles: true})); });
$('sort-order').addEventListener('change', () => runSearch(1));
['date-from', 'date-to'].forEach((id) => { $(id).addEventListener('input', () => syncDateHint(id)); $(id).addEventListener('change', () => { syncDateHint(id); runSearch(1); }); });
$('public-language').addEventListener('change', async () => { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); if ($('public-query').value.trim()) runSearch(1); });
$('page-prev').addEventListener('click', () => runSearch(publicPage - 1)); $('page-next').addEventListener('click', () => runSearch(publicPage + 1));
$('clear-filters').addEventListener('click', () => { document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; }); $('date-from').value = ''; $('date-to').value = ''; $('sort-order').value = 'relevance'; runSearch(1); });
async function loadUsers() {
$('clear-filters').addEventListener('click', () => { document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; }); if ($('filter-authority')) $('filter-authority').value = ''; $('date-from').value = ''; $('date-to').value = ''; syncDateHint('date-from'); syncDateHint('date-to'); $('sort-order').value = 'relevance'; runSearch(1); });
window.addEventListener('popstate', () => { if (!reviewRoute) initSearchPage(); });
window.addEventListener('resize', () => { if (!matchMedia('(max-width: 900px)').matches) { closeMobileFilters(); $('search-layout').removeAttribute('data-mobile-view'); } });
$('document-find').addEventListener('input', highlightDocumentMatches);
$('document-find-prev').addEventListener('click', () => moveDocumentMatch(-1)); $('document-find-next').addEventListener('click', () => moveDocumentMatch(1));
$('mobile-filters-open').addEventListener('click', openMobileFilters); $('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
$('search-filters').addEventListener('keydown', (event) => {
if (event.key === 'Escape') { closeMobileFilters(); return; }
if (event.key !== 'Tab' || !$('search-filters').classList.contains('is-open')) return;
const controls = [...$('search-filters').querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [href]')].filter((node) => node.offsetParent !== null);
if (!controls.length) return;
const first = controls[0]; const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
$('document-back').addEventListener('click', () => { setMobileView('results'); document.querySelector('.search__result--selected .search__result-title')?.focus(); });
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
$('document-print').addEventListener('click', () => window.print());
$('document-expand').addEventListener('click', () => { $('dialog-heading').textContent = $('public-document-heading').textContent; $('dialog-body').innerHTML = currentDocumentHtml; $('document-dialog').classList.add('search__expanded'); $('document-dialog').showModal(); });
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
$('document-dialog').addEventListener('close', () => $('document-dialog').classList.remove('search__expanded'));
$('sources-open').addEventListener('click', () => $('sources-dialog').showModal());
$('sources-close').addEventListener('click', () => $('sources-dialog').close());
document.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const dialog = document.querySelector('dialog[open]');
if (!dialog) return;
const controls = [...dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((item) => item.getClientRects().length && getComputedStyle(item).visibility !== 'hidden');
if (!controls.length) { event.preventDefault(); dialog.focus(); return; }
const active = document.activeElement;
if (!dialog.contains(active) || (event.shiftKey && active === controls[0]) || (!event.shiftKey && active === controls.at(-1))) {
event.preventDefault(); (event.shiftKey ? controls.at(-1) : controls[0]).focus();
}
});
async function loadUsers() {
$('users-error').textContent = '';
const response = await apiFetch('/admin/users');
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
@@ -287,10 +859,10 @@
$('password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('password-error').textContent = ''; if ($('new-password').value !== $('confirm-password').value) { $('password-error').textContent = 'Новые пароли не совпадают.'; $('confirm-password').focus(); return; } const response = await fetch('/auth/change-password', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('current-password').value, new_password: $('new-password').value})}); const payload = await response.json(); if (!response.ok) { $('password-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('password-dialog').close(); sessionTemporaryPassword = ''; $('password-form').reset(); enterWorkspace(payload.account); });
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); await loadUsers(); });
$('account-open').addEventListener('click', () => $('account-dialog').showModal());
$('users-open').addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); });
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => $('account-dialog').showModal()));
['review-users-open', 'search-users-open'].forEach((id) => $(id).addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); }));
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
$('logout').addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); });
['review-logout', 'search-logout'].forEach((id) => $(id).addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); }));
(async () => { const response = await fetch('/auth/me', {credentials: 'same-origin'}); if (!response.ok) { showLogin(); return; } const payload = await response.json(); if (payload.account.must_change_password) { $('password-dialog').showModal(); $('current-password').focus(); return; } enterWorkspace(payload.account); })();
const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
const setError = (text) => setStatus(text, true);
@@ -321,9 +893,43 @@
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
let tutorialStep = 0; let tutorialReturnFocus = null;
function clearTourTarget() { $('tutorial-spotlight').hidden = true; $('document-panel').classList.remove('review__document--tour-visible'); }
function renderTutorial() { const step = TOUR_STEPS[tutorialStep]; const target = $(step.target); if (step.target === 'document-panel' && getComputedStyle(target).display === 'none') target.classList.add('review__document--tour-visible'); target.scrollIntoView({block: 'nearest', inline: 'nearest'}); const bounds = target.getBoundingClientRect(); $('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`; $('tutorial-spotlight').hidden = false; $('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label; $('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее'; }
function renderTutorial() {
const step = TOUR_STEPS[tutorialStep]; const target = $(step.target);
$('document-panel').classList.toggle('review__document--tour-visible', step.target === 'document-panel' && matchMedia('(max-width: 800px)').matches);
const dialog = $('tutorial-dialog');
const focusedControl = dialog.open && dialog.contains(document.activeElement) ? document.activeElement.id : 'tutorial-next';
if (dialog.open) dialog.close();
target.scrollIntoView({block: 'nearest', inline: 'nearest'});
const bounds = target.getBoundingClientRect();
$('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`;
$('tutorial-spotlight').hidden = false;
$('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label;
$('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее';
dialog.showModal();
const gap = 12; const edge = 12;
const width = dialog.offsetWidth; const height = dialog.offsetHeight;
const viewportWidth = document.documentElement.clientWidth; const viewportHeight = window.innerHeight;
const clamp = (value, max) => Math.max(edge, Math.min(value, max - edge));
const placements = [
{fits: viewportWidth - bounds.right >= width + gap + edge, left: bounds.right + gap, top: bounds.top + bounds.height / 2 - height / 2},
{fits: bounds.left >= width + gap + edge, left: bounds.left - width - gap, top: bounds.top + bounds.height / 2 - height / 2},
{fits: viewportHeight - bounds.bottom >= height + gap + edge, left: bounds.left + bounds.width / 2 - width / 2, top: bounds.bottom + gap},
{fits: bounds.top >= height + gap + edge, left: bounds.left + bounds.width / 2 - width / 2, top: bounds.top - height - gap}
];
const placement = placements.find((item) => item.fits) || placements.reduce((best, item) => {
const overlap = (candidate) => {
const left = clamp(candidate.left, viewportWidth - width); const top = clamp(candidate.top, viewportHeight - height);
return Math.max(0, Math.min(left + width, bounds.right) - Math.max(left, bounds.left)) * Math.max(0, Math.min(top + height, bounds.bottom) - Math.max(top, bounds.top));
};
return overlap(item) < overlap(best) ? item : best;
});
dialog.style.left = `${clamp(placement.left, viewportWidth - width)}px`;
dialog.style.top = `${clamp(placement.top, viewportHeight - height)}px`;
$(focusedControl)?.focus();
}
function closeTutorial() { clearTourTarget(); $('tutorial-dialog').close(); markTourSeen(); tutorialReturnFocus?.focus(); }
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); $('tutorial-dialog').showModal(); $('tutorial-next').focus(); }
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
import json
import os
import sqlite3
import tempfile
import unittest
import urllib.error
from email.message import Message
from pathlib import Path
from unittest.mock import Mock, patch
from ingestion import minjust_current
from ingestion.minjust_current import (
ApiResponse,
CurrentCbdClient,
SourceChanged,
SourceError,
stage_document,
)
def api_response(value):
body = json.dumps(value, ensure_ascii=False).encode()
import hashlib
return ApiResponse(value, body, hashlib.sha256(body).hexdigest())
def document_record(alias="6", canonical=6, editions=None):
return api_response(
{
"documentCode": canonical,
"nameRus": "Закон",
"nameKyr": "Мыйзам",
"headerRu": "Заголовок",
"headerKg": "Баш сөз",
"number": 114,
"dateAdopted": "1998-08-04",
"classId": 1,
"refTypeId": 10,
"status": 1,
"editions": editions if editions is not None else [
{"id": 920492, "nameRus": "Редакция A", "nameKyr": "Редакция A", "textRusType": "docx", "textKyrType": "docx"},
{"id": 920292, "nameRus": "Редакция B", "nameKyr": "Редакция B", "textRusType": "docx", "textKyrType": "docx"},
],
}
)
class CurrentCbdClientTests(unittest.TestCase):
def test_rejects_legacy_roots_when_called_from_an_unrelated_working_directory(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
repository = base / "repository"
elsewhere = base / "elsewhere"
elsewhere.mkdir()
legacy_roots = [
repository / "data/minjust-cbd",
repository / "data/minjust-normalized",
]
for legacy in legacy_roots:
legacy.mkdir(parents=True)
# Root CI uses umask 000, so make the trusted fixture's parent permissions explicit.
for directory in (repository, repository / "data", *legacy_roots):
directory.chmod(0o755)
original_cwd = Path.cwd()
client = Mock()
try:
os.chdir(elsewhere)
with patch.object(minjust_current, "REPOSITORY_ROOT", repository):
for legacy in legacy_roots:
with self.subTest(legacy=legacy), self.assertRaisesRegex(ValueError, "must not overlap"):
stage_document(legacy / "staging", "6", client)
self.assertFalse((legacy / "staging").exists())
finally:
os.chdir(original_cwd)
client.document.assert_not_called()
def test_requires_private_owned_staging_root(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "shared"
output.mkdir(mode=0o755)
output.chmod(0o755)
with self.assertRaisesRegex(ValueError, "private directory"):
stage_document(output, "6", Mock())
def test_resume_rejects_symlinked_checkpoints_and_document_paths(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
targets = [
("request-contract.json", False),
("current-api.sqlite3", False),
("manifest.sqlite3", False),
(".work/documents/6", True),
(".work/documents/6/editions/920492/ru.html", False),
("documents/6", True),
]
client = Mock()
for index, (relative, directory) in enumerate(targets):
with self.subTest(relative=relative):
output = base / f"generation-{index}"
output.mkdir(mode=0o700)
contract = output / "request-contract.json"
if relative != "request-contract.json":
contract.write_text(json.dumps(minjust_current.REQUEST_CONTRACT), encoding="utf-8")
outside = base / f"outside-{index}"
if directory:
outside.mkdir()
else:
outside.write_text("preserve", encoding="utf-8")
link = output / relative
link.parent.mkdir(parents=True, exist_ok=True)
try:
link.symlink_to(outside, target_is_directory=directory)
except (NotImplementedError, OSError):
self.skipTest("symlinks are unavailable")
with self.assertRaisesRegex(ValueError, "symbolic link"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
if not directory:
self.assertEqual(outside.read_text(encoding="utf-8"), "preserve")
else:
self.assertEqual(list(outside.iterdir()), [])
def test_respects_retry_after_and_retries_server_errors(self):
headers = Message()
headers["Retry-After"] = "7"
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
opener = Mock(side_effect=[
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
urllib.error.HTTPError("https://example.test", 503, "", Message(), None),
response,
])
delays = []
CurrentCbdClient(opener=opener, sleep=delays.append, clock=lambda: 0, retries=3).request_json("/GetEdition")
self.assertIn(7.0, delays)
self.assertIn(2, delays)
self.assertEqual(opener.call_count, 3)
def test_enforces_request_interval(self):
now = [0.0]
starts = []
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
def opener(_request, timeout):
starts.append(now[0])
return response
client = CurrentCbdClient(
opener=opener,
clock=lambda: now[0],
sleep=lambda delay: now.__setitem__(0, now[0] + delay),
)
client.request_json("/GetEdition")
client.request_json("/GetEdition")
self.assertGreaterEqual(starts[1] - starts[0], 1.0)
def test_rejects_oversized_response(self):
response = Mock()
response.read.return_value = b"x" * 11
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceError, "exceeds 10 bytes"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response, max_response_bytes=10).request_json("/GetEdition")
def test_get_edition_uses_exact_query_and_checks_requested_id(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920292, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
requests = []
def opener(request, timeout):
requests.append(request)
return response
result = CurrentCbdClient(opener=opener, sleep=lambda _delay: None).edition(920292, "kg")
self.assertEqual(requests[0].full_url, "https://cbd.minjust.gov.kg/api/v1/GetEdition?editionId=920292&lang=kg&exact=true")
self.assertEqual(requests[0].method, "GET")
self.assertEqual(result.value["id"], 920292)
def test_rejects_response_id_that_does_not_match_exact_request(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920492, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceChanged, "does not match requested ID"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response).edition(920292, "ru")
def test_stages_alias_mapping_language_quirk_and_official_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
output = base / "generation"
old_raw = base / "legacy" / "documents" / "6" / "sentinel"
old_raw.parent.mkdir(parents=True)
old_raw.write_text("preserve", encoding="utf-8")
class Client:
def document(self, alias):
self.alias = alias
return document_record(alias=alias, canonical=6)
def edition(self, identifier, language):
text = "<p>Russian</p>" if language == "ru" else "<p>Кыргызча</p>"
name = "Редакция A" if identifier == 920492 else "Редакция B"
return api_response({
"id": identifier, "nameRus": name, "nameKyr": name,
"contentRu": text, "contentKg": None,
})
client = Client()
result = stage_document(output, "6", client)
raw = output / "documents" / "6"
self.assertEqual(result["state"], "text_complete_incomplete_attachments")
self.assertEqual((raw / "editions/920492/ru.html").read_text(), "<p>Russian</p>")
self.assertEqual((raw / "editions/920492/ky.html").read_text(), "<p>Кыргызча</p>")
first = json.loads((raw / "editions/920492/metadata.json").read_text())
second = json.loads((raw / "editions/920292/metadata.json").read_text())
self.assertEqual((first["SourceOrder"], second["SourceOrder"]), (0, 1))
language_meta = json.loads((raw / "editions/920292/source/ru.json").read_text())
self.assertEqual((language_meta["requested_edition_id"], language_meta["exact"]), (920292, True))
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertTrue(connection.execute("SELECT contract_sha256 FROM generation").fetchone()[0])
self.assertEqual(old_raw.read_text(), "preserve")
with sqlite3.connect(output / "current-api.sqlite3") as connection:
rows = connection.execute(
"SELECT request_language, archive_language, state FROM languages ORDER BY request_language, edition_id"
).fetchall()
self.assertEqual(rows, [("kg", "ky", "complete")] * 2 + [("ru", "ru", "complete")] * 2)
def test_resolves_official_alias_to_canonical_staging_directory(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, alias):
return document_record(alias=alias, canonical=205064, editions=[])
result = stage_document(output, "1-4", Client())
self.assertEqual(result["canonical_id"], 205064)
self.assertTrue((output / "documents/205064/metadata.json").is_file())
self.assertFalse((output / "documents/1-4").exists())
def test_rejects_non_exact_edition_response_id_without_publishing(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920292, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, _identifier, language):
return api_response({"id": 920492, "contentRu": f"<{language} edition>", "contentKg": None})
with self.assertRaisesRegex(SourceChanged, "exact edition ID mismatch"):
stage_document(output, "6", Client())
self.assertFalse((output / "documents/6").exists())
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT state FROM languages").fetchone()[0], "error")
def test_rejects_exact_edition_metadata_contradiction(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
client = CurrentCbdClient()
client.document = lambda _alias: document_record(editions=[{
"id": 920292, "nameRus": "04.08.1998", "nameKyr": "04.08.1998",
"textRusType": "docx", "textKyrType": "docx",
}])
client.request_json = lambda *_args, **_kwargs: api_response({
"id": 920292, "nameRus": "13.04.2017", "nameKyr": "13.04.2017",
"contentRu": "<wrong edition>", "contentKg": None,
})
with self.assertRaisesRegex(SourceChanged, "metadata mismatch"):
stage_document(output, "6", client)
self.assertFalse((output / "documents/6").exists())
def test_recovers_atomic_publish_before_manifest_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
connection.execute("UPDATE documents SET state='fetching' WHERE alias='6'")
with sqlite3.connect(output / "manifest.sqlite3") as connection:
connection.execute("DELETE FROM documents WHERE code=6")
connection.execute("DELETE FROM errors WHERE code=6")
resumed = stage_document(output, "6", client, resume=True)
self.assertEqual(resumed["source_sha256"], first["source_sha256"])
self.assertTrue(resumed["resumed"])
with sqlite3.connect(output / "manifest.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT COUNT(*) FROM documents WHERE code=6").fetchone()[0], 1)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM errors WHERE code=6").fetchone()[0], 1)
def test_retries_after_document_metadata_request_failed_before_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
if not self.failed:
self.failed = True
raise SourceError("temporary DNS failure")
return document_record(editions=[])
client = Client()
with self.assertRaisesRegex(SourceError, "DNS"):
stage_document(output, "6", client)
result = stage_document(output, "6", client, resume=True)
self.assertEqual(result["canonical_id"], 6)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
row = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias='6'"
).fetchone()
self.assertEqual(row, (6, document_record(editions=[]).sha256, 0))
def test_does_not_resume_generation_without_exact_request_contract(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "legacy-generation"
output.mkdir(mode=0o700)
sentinel = output / "old-response.json"
sentinel.write_text("preserve", encoding="utf-8")
client = Mock()
with self.assertRaisesRegex(SourceChanged, "no exact-edition request contract"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_incomplete_translation_resumes_without_rewriting_successful_language(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
if language == "kg" and not self.failed:
self.failed = True
raise SourceError("simulated interrupted response")
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
self.assertIn("incomplete", first)
self.assertFalse((output / "documents/6").exists())
second = stage_document(output, "6", client, resume=True)
self.assertEqual(second["state"], "text_complete_incomplete_attachments")
self.assertEqual((output / "documents/6/editions/920492/ru.html").read_text(), "<ru>")
self.assertEqual((output / "documents/6/editions/920492/ky.html").read_text(), "<kg>")
self.assertEqual(client.failed, True)
def test_refuses_budget_overrun_without_publishing_partial_document(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, _language):
return api_response({"id": identifier, "contentRu": "123456", "contentKg": None})
with self.assertRaisesRegex(SourceError, "budget"):
stage_document(output, "6", Client(), max_document_bytes=5)
self.assertFalse((output / "documents/6").exists())
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -86,6 +86,47 @@ class MinjustNormalizationTest(unittest.TestCase):
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
self.assertFalse(empty["quality"]["has_html"])
def test_preserves_official_edition_order_instead_of_sorting_ids(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 920492, {"ru": "<p>Первая редакция</p>"})
self.edition(document, 920292, {"ru": "<p>Последняя редакция</p>"})
for code, order in ((920492, 0), (920292, 1)):
metadata_path = document / "editions" / str(code) / "metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = order
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
normalize_archive(source, output)
normalized = json.loads((output / "documents/6/document.json").read_text(encoding="utf-8"))
self.assertEqual([item["source_code"] for item in normalized["editions"]], ["920492", "920292"])
self.assertEqual([item["source_order"] for item in normalized["editions"]], [0, 1])
def test_rejects_partial_source_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 10, {"ru": "<p>Один</p>"})
self.edition(document, 20, {"ru": "<p>Два</p>"})
metadata_path = document / "editions/10/metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = 0
metadata_path.write_text(json.dumps(metadata), encoding="utf-8")
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
result = normalize_archive(source, output)
self.assertEqual((result.failed, result.normalized), (1, 0))
with sqlite3.connect(output / "manifest.sqlite3") as connection:
error = connection.execute("SELECT error FROM documents WHERE code='6'").fetchone()[0]
self.assertIn("Invalid source edition order", error)
def test_continues_after_bad_document_and_clears_repaired_error(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
+44
View File
@@ -25,6 +25,50 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertEqual(legal_force_code({"ru": "Указ", "ky": "Жарлыгы"}), "subordinate")
self.assertEqual(source_code("document_type", {"ru": "устав"}), "charter")
def test_export_marks_last_official_edition_current_even_when_ids_are_out_of_order(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
input_root = root / "normalized"
document_root = input_root / "documents/6"
document_root.mkdir(parents=True)
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
with connection:
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
connection.execute("INSERT INTO documents VALUES ('6', 'success')")
(document_root / "document.json").write_text(
json.dumps({"schema_version": "1", "source_code": "6", "editions": [
{"source_code": "920492", "source_order": 0},
{"source_code": "920292", "source_order": 1},
]}),
encoding="utf-8",
)
for edition in ("920292", "920492"):
text = f"Текст {edition}"
fragment_path = document_root / f"editions/{edition}/ru/fragments.json"
fragment_path.parent.mkdir(parents=True)
fragment_path.write_text(json.dumps([{
"id": f"document:6:edition:{edition}:lang:ru:fragment:1",
"document_code": "6",
"edition_code": edition,
"language": "ru",
"position": 1,
"type": "paragraph",
"text": text,
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
"source_path": f"documents/6/editions/{edition}/ru.html",
"source_sha256": "b" * 64,
}]), encoding="utf-8")
output = root / "bulk.ndjson"
self.assertEqual(export_bulk(input_root, output, "test-index"), (1, 2))
lines = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
flags = {
lines[index]["index"]["_id"].split(":")[3]: lines[index + 1]["is_current_edition"]
for index in range(0, len(lines), 2)
}
self.assertEqual(flags, {"920292": True, "920492": False})
def test_switches_alias_atomically_after_successful_load(self):
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
+13
View File
@@ -24,6 +24,19 @@ class SearchApiTest(unittest.TestCase):
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_static_font_assets_are_allowlisted(self):
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
self.assertEqual(content_type, "font/woff2")
self.assertTrue(body.startswith(b"wOF2"))
content_type, body = Api.static_asset("/static/favicon.svg")
self.assertEqual(content_type, "image/svg+xml")
self.assertIn(b"<svg", body)
content_type, body = Api.static_asset("/static/favicon-32.png")
self.assertEqual(content_type, "image/png")
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
with self.assertRaisesRegex(ApiError, "static asset not found"):
Api.static_asset("/static/fonts/../../DESIGN.md")
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
+1 -1
View File
@@ -45,4 +45,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
+7 -2
View File
@@ -25,8 +25,13 @@ rootless Docker setup and user service use `.config/act-runner` as writable
`.config/act-runner/.local/share` as `XDG_DATA_HOME`. This keeps Docker config
and its systemd user unit in the existing `.config` tree without granting
write access to the protected home.
rootless Docker daemon
and runner user are separate from production Docker; `codex` is not added to
The runner systemd unit uses `ProtectHome=read-only` so `/run/user` remains
visible; `ReadWritePaths` retains the existing `/run/user/$uid` writable
carve-out for the runner's rootless runtime. `ProtectHome=yes` would hide
`/run/user` and break the configured runtime and Docker socket paths. See the
[systemd.exec documentation](https://www.freedesktop.org/software/systemd/man/255/systemd.exec.html#ProtectHome=).
The rootless Docker daemon and runner user are separate from production Docker;
`codex` is not added to
sudo or Docker groups. Jobs do not receive the rootless Docker socket, the
production Docker socket, production environment/secrets, or arbitrary host
bind mounts. The runner account has no access to `/etc/akyldash` or the
+51
View File
@@ -96,6 +96,57 @@ From the VPS, a metadata-only HTTPS check to `CheckAvailable` failed at the
HTTP/2 transport layer (`curl` exit 92, `INTERNAL_ERROR`, HTTP status `000`,
TLS verification `0`). An HTTP/1.1 retry was not completed.
## Current API and bounded code 6 recovery (2026-10-20)
The current official site uses `POST /api/v1/GetDocuments` (page size 12),
`GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded first-page probe
reported 210,639 records; this recovery does not enumerate the full list. The
official alias `1-4` resolves to canonical code `205064`. Code `6` is present
and has 201 editions. The edition order in `GetDocument` is authoritative;
numeric IDs do not define currentness.
For `lang=kg`, the API returns Kyrgyz HTML in `contentRu` and `contentKg=null`.
The bounded adapter maps that response by the requested language to archive
`ky`, preserves the response and checkpoints, limits requests to one per
second, caps JSON responses at 16 MiB, and caps one document's text at 4 GiB.
It writes only to a separate generation under `/tmp`; it neither resumes nor
overwrites the existing ignored corpus.
Without `exact=true`, a request for `920292` returned response field
`id=920492` despite different text length/hash. Two exact probes for `920292`
and `920492` returned matching IDs and names/dates (`04.08.1998` and
`13.04.2017`) matching `GetDocument`; their RU text lengths/hashes were
485,487 / `2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`
and 1,139,908 / `e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`.
The adapter now requires `exact=true`, returned-ID equality, and matching RU/KY
names before a language checkpoint can complete. Its versioned request
contract is part of both staging metadata and the SQLite resume identity;
pre-contract generations cannot be resumed.
The adapter does not independently validate attachments or embedded media.
Even after the text stage completes, the raw manifest records this as an
unresolved error. Do not merge this single document into the existing corpus,
claim corpus completeness, or transfer it to production without a separate
review and comparison. The prior no-exact pass at
`/tmp/akyldash-current-code6.ZAMC37u4ex` is marked quarantined in
`qualification.json`; its 240 text checkpoints and 304,493,520 bytes are
retained as unverified, with no document directory published. Its preserved
work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`. It must
not be reused or normalized.
The exact-only generation at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP`
contains 201 editions and 402 complete RU/KY text checkpoints (604,377,885
raw bytes). The raw manifest passes `quick_check`; its stored source SHA-256
matches the staged document tree at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`. Separate
normalization at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized`
passes `quick_check`, records normalizer `1.1.0`, and preserves first/last
edition order `920292` (`04.08.1998`) to `920492` (`13.04.2017`). The normalized
generation is about 2.05 GB. Neither temporary generation has been merged into
the existing corpus. Attachments and embedded media remain unverified.
The first full pass (run `b4a4556e30cc464d90bc8d75ef1673e9`) stopped before
validation with `Python int too large to convert to SQLite INTEGER` after
2,120,420 raw entries and 397,856 normalized entries. This was an inventory
+61 -5
View File
@@ -63,8 +63,8 @@ protected file metadata remain unverified.
unverified.
- No public application listener is observed on TCP `80`/`443`. Docker
container/volume state still requires the root audit. Repository Compose describes
Caddy `2.11.6` pinned by digest, Python Search API `0.11.3`, SQLite
account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
Caddy `2.11.6` pinned by digest, Python Search API `0.12.2` in this branch,
SQLite account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
- The target publishes only Caddy on TCP `80`/`443`. Backend and Caddy share a
proxy network; OpenSearch and backend share a separate internal network, and
backend has no host port. Caddy certificate storage, OpenSearch, and
@@ -111,7 +111,15 @@ protected file metadata remain unverified.
with exit status 1 since 2026-10-14; its journal had no entries, so the cause
is unknown. The latest verified main CI run 75 and smoke run 76 succeeded on
head `0e276ae6`. The Actions API returned 403 during this recheck, so no newer
run state was independently retrieved. On
run state was independently retrieved. A read-only SSH recheck on 2026-10-21
confirmed the active `akyldash-act-runner.service` runs as
`akyldash-runner` with `ProtectHome=read-only`; `ReadWritePaths` includes the
runner runtime/config/log paths and `/run/user/999`. Its `ExecStart` uses
`/etc/akyldash-runner/config.yaml`. The live unit retains a writable carveout
for `/run/user/999`; the installer keeps that existing carveout and changes
`ProtectHome` from `yes` to `read-only` so the runtime path remains visible.
No VPS unit was changed. Main commit
`d2fb4b81` passed CI run 115 and smoke run 116. On
head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run 64
succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
with `exec: "validate": executable file not found`; Kaniko was skipped and
@@ -127,8 +135,9 @@ protected file metadata remain unverified.
reached healthy status for OpenSearch, backend, and Caddy, and returned HTTP
200 for `/review` through Caddy. Its containers, networks, and volumes were
removed afterward. This does not verify VPS startup, DNS, or public TLS.
- The app has no migration framework or SMTP setup. Current app versions:
backend `0.11.3`, frontend `0.1.2`, Telegram bot `0.2.2` (the bot is not
- The app has no migration framework or SMTP setup. Repository versions on this
branch are:
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
deployed on this VPS).
## Corpus readiness (2026-10-18)
@@ -168,6 +177,53 @@ protected file metadata remain unverified.
unknown. No corpus was transferred and no production service, Docker socket,
runner policy, or VPS configuration was changed.
## Current MinJust API staging (2026-10-20)
- The public site's current API uses `POST /api/v1/GetDocuments` with page size
12, `GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded page-1 probe
reported 210,639 filtered records; no full listing was enumerated for this
recovery. Official alias `1-4` resolves to canonical document code `205064`.
- Document code `6` has 201 official editions. The API's `lang=kg` response
places Kyrgyz HTML in `contentRu` while returning `contentKg=null`; staging
maps by requested language (`kg` to archive `ky`) and preserves the edition
order from the document response. Normalization and search export now retain
and use that order instead of sorting IDs.
- Without `exact=true`, one probe for edition `920292` returned response field
`id=920492` despite a different text length/hash. Two bounded `exact=true`
probes returned matching IDs and names/dates from `GetDocument`: edition
`920292` / `04.08.1998` (485,487 RU characters, SHA-256
`2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`) and
edition `920492` / `13.04.2017` (1,139,908 RU characters, SHA-256
`e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`).
Every edition request now uses `exact=true`, requires returned ID equality,
and checks RU/KY names against `GetDocument` metadata.
- `backend/ingestion/minjust_current.py` stages one document into a separate
generation, at no more than one request per second, with a 16 MiB response
limit, a 4 GiB text budget per document, contract-bound checkpoints, and
atomic publication. Staging requires a private 0700 directory owned by the
current user and rejects symlinks under checkpoints/document paths; legacy
corpus exclusions are anchored to the repository root. The corpus verifier
continues to validate unchanged normalized manifests from versions 1.0.0
and 1.1.0. The earlier non-exact pass is quarantined at
`/tmp/akyldash-current-code6.ZAMC37u4ex/qualification.json`: its 240 language
responses (304,493,520 bytes) remain unverified and are not reusable. The
retained work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`.
- Exact-only code-6 staging is complete at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP`: 201 editions and 402
complete language checkpoints, 604,377,885 raw bytes. The raw manifest
passes `quick_check`; its document checksum matches the staged directory at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`.
Separate normalization at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized` contains 201
editions in official response order, passes `quick_check`, and records
normalizer `1.1.0` (2,048,797,024 bytes). The prior `data/minjust-cbd` and
`data/minjust-normalized` trees and production were not used as input or
modified. The raw staging manifest still records attachments and embedded
media as unverified; this single document does not establish corpus
completeness or production readiness.
## Checks not completed
- Docker Engine version from the server API, container and volume inventory,
+2 -2
View File
@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.11.3`
- Backend: `0.12.2`
- Frontend: `0.1.2` — поиск, лаборатория релевантности, аккаунты
## Краткий итог
@@ -282,4 +282,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+355
View File
@@ -0,0 +1,355 @@
# План точного воспроизведения поискового интерфейса
Дата: 23 октября 2026 года.
Статус: основная реализация и локальная приёмка выполнены в
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
навигация, плотность результатов, заголовок документа, fixture-контент,
клавиатурный trap для диалогов и компактные позиционируемые подсказки
лаборатории. Скриншоты, покропленные сравнения и расширенный
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
сборка production images. Независимый review запускает владелец. Скринридер и
реальный корпус всё ещё требуют проверки.
## 1. Цель, границы и источники истины
Привести существующую авторизованную поисковую страницу `/` к композиции
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
Источники:
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
токены, типографика, доступность, постоянные правила компонентов.
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
этому сохранённому изображению более высокого разрешения.
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
готовности публичного продукта, которые этот план не отменяет.
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
Временные задачи и результаты измерений остаются здесь; новые постоянные
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
индекса, production, регистрация, аналитика, сравнение редакций, переход
на новый frontend framework. Содержимое демонстрационного документа
не становится юридическими данными продукта.
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
При необходимости совместного редактирования можно подготовить отдельный
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
это не заменяет проверку фактической вёрстки в браузере.
## 2. Исходное состояние до реализации
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
изображениям без браузера; строки не описывают текущее состояние. Последующие
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
состояние production или актуального OpenSearch.
Основные файлы:
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
сценариев, footer и авторизация.
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
фильтров и редакций.
- `backend/test_search_api.py` — существующие проверки API.
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
| Проблема | Подтверждение в реализации | Требуемый результат |
|---|---|---|
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
## 3. Этап 0 — воспроизводимый эталон
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; использовать установленный
Ponytail согласно правилам репозитория.
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
`feature/` ветку и не переносить посторонние изменения в PR.
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
три результата из изображения, выбран первый, открыта соответствующая
демонстрационная редакция, фильтр статуса и исходная прокрутка.
- [x] Изолировать fixture от реального режима и пометить демонстрационное
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
в production-путь.
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
пропорций и сохранить параметры подготовки эталона.
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
согласованные ограничения приведены в отчёте.
Критерий завершения: один и тот же тестовый сценарий воспроизводится
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
продолжает использовать API. Авторизация не ослабляется ради снимков.
## 4. Этап 1 — карта геометрии
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
около 1 CSS px.
| Область | Нормализованный эталон | Реализация |
|---|---:|---:|
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
| Footer | y ≈ 744–800 px | y = 744–800 px |
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
| Результаты | x ≈ 226–648 px | x = 226–648 px |
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
Внутренние контрольные координаты и ограничения см. в
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
разница по каждой крупной области.
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
документа по исходнику; фактические значения записаны в отчёте.
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
интервалы и разделители по всем областям.
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
реальные длинные данные ещё не проверены.
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
Не выравнивать принудительно высоту всех результатов или разделов.
- [ ] Определить владельца прокрутки каждой области, поведение footer
при коротком содержимом и доступность длинного документа.
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
а не независимыми приблизительными настройками каждого блока.
## 5. Этап 2 — разделение сценариев и базовых стилей
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
авторизацию, кабинет, управление пользователями, обучение и черновики.
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
- [ ] Использовать общий слой семантических токенов и базовых элементов;
стили поискового экрана изолировать BEM-классами.
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
static-файлов с MIME, защитой от обхода путей и сохранением защитных
заголовков. Убедиться, что ресурсы входят в production-образ.
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
Не вводить Next.js, React или новый сборщик только ради этой задачи.
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
их композицию случайным образом; существующие права доступа сохранены.
## 6. Этап 3 — шрифты, знак, орнамент и favicon
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
с лицензиями; исключить случайное синтетическое начертание.
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
Визуальные снимки выполнять после `document.fonts.ready`.
- [ ] Подготовить SVG-знак по визуальному направлению референса;
сверить размер, толщину линий и положение относительно названия.
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
`aria-hidden`, без перекрытия управления и текста.
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
Критерий завершения: шрифты реально загружены, знак и орнамент являются
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
## 7. Этап 4 — сборка композиции
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
демонстрационная подпись только для fixture. «Об источниках» открывает
реальные сведения о происхождении данных, а не пустую заглушку.
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
Сообщения для скринридера сохраняются.
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
орнамент в свободной нижней части без перекрытия длинного содержимого.
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
безопасная подсветка выдержки, пагинация.
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
компактные метаданные, язык, действия, поиск внутри, поле чтения.
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
не копировать устаревшую backend-версию и слово «макет» из изображения.
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
соответствуют эталону; нет лишних лабораторных блоков.
## 8. Этап 5 — рабочее поведение и интеграция
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
остальных; сохранение второго фильтра проверяется Playwright.
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
значения. Не имитировать множественный выбор checkbox без поддержки API.
Если нужен multiselect, отдельно описать и проверить расширение контракта.
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
- [ ] Не выдавать число на странице за общее: использовать явную подпись
либо отдельно реализовать достоверный total с проверкой его семантики.
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
при отсутствии структуры показать понятное состояние.
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
подсветка, активное совпадение, счётчик, предыдущий/следующий,
прокрутка и состояние нулевого результата. Не повреждать HTML,
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
прокрутки и корректного фокуса.
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей; служебные элементы проверены в print media.
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
через отмену запроса или проверку актуальности результата.
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
обработку ошибок и ограничения авторизации.
Критерий завершения: каждый видимый элемент эталона выполняет действие;
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
## 9. Этап 6 — адаптивность, типографика и состояния
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
Сверить переносы названия документа, результатов и плотность абзацев.
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
Высоты и параметры окружения записаны в отчёте.
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
breakpoints на реальных данных корпуса.
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
широкое изображение синтетическими данными.
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
длинный контент из реального корпуса.
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
диалогов, а также `prefers-reduced-motion`.
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
Критерий завершения: точность desktop не приводит к переполнению,
недоступности действий или потере контекста на узком экране.
## 10. Этап 7 — визуальная приёмка
Выполнить один пакет первичных снимков всех контрольных размеров,
собрать дефекты по областям, исправить одним пакетом и выполнить один
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
прокрутку; снимки делаются после `document.fonts.ready`.
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
login, ошибку или другой маршрут.
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
сравнения шапки, фильтров, результатов, документа и footer.
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
исправлениями и известными ограничениями.
- [ ] Передать независимому reviewer запрос пользователя, эталон,
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
Критерии для детерминированного fixture при 1280×800:
| Проверка | Условие приёмки |
|---|---|
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
Нулевая попиксельная разница не является критерием: сглаживание текста,
растровая природа концепта и нормативные токены дают объяснимые различия.
Один процент сходства всего экрана не заменяет проверки каждой области.
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
## 11. Этап 8 — проверки, версия и PR
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
API v1 не менялся.
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
config/runner guards, shell syntax и Playwright.
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
проверка Caddyfile и сборка backend/OpenSearch production images.
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
визуальное сравнение использует стабильный fixture.
- [x] Повысить версии для совместимого функционального изменения;
frontend/backend версии согласованы в footer.
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
изменения остаются в одном PR #53.
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
прежние исправления и оставляет краткий PR-комментарий с явным выводом
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
commit/push и независимый review.
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
существующий changelog, записав значимые изменения для пользователя.
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
и независимый review окончательного состояния.
Готовность к merge: проверки проходят, финальный review не содержит
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
доступны reviewer. Не сливать автоматически и не развёртывать production.
## 12. Порядок выполнения и итоговые материалы
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
Не начинать микроподгонку кнопок до исправления общей геометрии.
Ожидаемые материалы реализации:
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
- карта измерений, снимки, overlay/diff и отчёт приёмки;
- результаты обязательных проверок и независимого review;
- версии, changelog и один PR без автоматического merge.
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
которые реально выполнены, зафиксированы в отчёте visual-review.
Этот документ не подтверждает готовность API, публичного продукта или
production.
+1 -1
View File
@@ -262,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+1 -1
View File
@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+100
View File
@@ -0,0 +1,100 @@
# Приёмка поискового экрана и лаборатории
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
## Снимки
- [1280×800 — основной экран](desktop-1280x800.png)
- [1440 px](desktop-1440px.png)
- [1024 px](desktop-1024px.png)
- [390 px — мобильный просмотр документа](mobile-390px.png)
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
- [Overlay 50% с приложенным референсом](overlay-50.png)
- [Карта абсолютной разницы пикселей](pixel-difference.png)
Покропленные сравнения областей для отдельного просмотра:
| Область | Overlay | Разница пикселей |
|---|---|---|
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
## Фактическая проверка
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
на y=744; footer — y=744–800.
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
Референсная подпись и элементы попадают в эти координаты с расхождением
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
- При 320 px также нет горизонтального переполнения.
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
на фильтрах, результатах, счётчике совпадений и открытом статусе.
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
кнопку открытия.
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
открытие редакции, поиск по документу
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
отображение поисковых подписей и выбранного статуса на кыргызском,
разворачивание документа, действия профиля/пользователей/выхода, мобильную
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
повтор после ошибки и отсутствие доступных редакций.
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
остальных URL-фильтров при удалении одного.
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
Полный скринридерный аудит этим не заменяется.
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
полупрозрачный фон и располагается у целевого элемента без пересечения.
Снимки первого шага сохранены для desktop и mobile.
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
странице перед снимком. Эти юридические тексты и результаты не входят в код
продукта. Для overlay и difference исходный растр 1586×992 вписывается
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
учётной записи и остаётся заметным дополнительным элементом справа во второй
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
доступный select; это функциональное расхождение пока оставлено явно видимым.
Содержимое документа, системный формат даты и сглаживание текста также не
совпадают попиксельно с концептом.
## Повторный запуск
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
`backend/search/review.html`, перехватывает API детерминированными fixture-
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
создаются, если найден исходный файл по стандартному пути или передан путь в
переменной `REFERENCE_IMAGE`.
## Оставшиеся визуальные отличия
Сглаживание текста и содержимое документа отличаются от растрового макета.
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
и поле языка поиска остаются разными состояниями; в макете язык показан как
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
показывают композицию, но не подтверждают юридическое содержимое или доступность
во всех браузерах.
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+24 -3
View File
@@ -14,6 +14,7 @@ from verify_corpus import (
prefix_upper_bound,
raw_file_rows,
combined_source_hash,
NORMALIZER_VERSION,
safe_relative_path,
validate_fragment_file,
verify_corpus,
@@ -39,7 +40,7 @@ class CorpusInventoryTests(unittest.TestCase):
)
connection.close()
def _nonempty_corpus(self):
def _nonempty_corpus(self, normalizer_version=NORMALIZER_VERSION):
document = self.raw / "documents/1"
edition = document / "editions/2"
edition.mkdir(parents=True)
@@ -79,7 +80,7 @@ class CorpusInventoryTests(unittest.TestCase):
document_json = {
"schema_version": "1",
"source_code": "1",
"normalizer": {"version": "1.0.0"},
"normalizer": {"version": normalizer_version},
"source": {"path": "documents/1", "files": source_files, "sha256": source_sha},
"editions": [{"source_code": "2"}],
}
@@ -98,7 +99,9 @@ class CorpusInventoryTests(unittest.TestCase):
normalized_manifest.execute(
"CREATE TABLE documents (code TEXT PRIMARY KEY, source_sha256 TEXT, schema_version TEXT, normalizer_version TEXT, state TEXT)"
)
normalized_manifest.execute("INSERT INTO documents VALUES ('1', ?, '1', '1.0.0', 'success')", (source_sha,))
normalized_manifest.execute(
"INSERT INTO documents VALUES ('1', ?, '1', ?, 'success')", (source_sha, normalizer_version)
)
normalized_manifest.commit()
normalized_manifest.close()
return normalized, source_sha
@@ -273,6 +276,24 @@ class CorpusInventoryTests(unittest.TestCase):
self.assertEqual(result["manifest_summary"]["raw"]["documents"], 1)
self.assertEqual(result["manifest_summary"]["normalized"]["fragments"], 1)
def test_verifies_legacy_normalizer_1_0_0_manifest(self):
normalized, _ = self._nonempty_corpus(normalizer_version="1.0.0")
result = verify_corpus(self.raw, normalized, self.base / "artifacts")
self.assertEqual(result["status"], "complete", result["finding_examples"])
self.assertNotIn(
"normalizer_version_mismatch",
{item["kind"] for item in result["finding_examples"]},
)
def test_rejects_unsupported_normalizer_version(self):
normalized, _ = self._nonempty_corpus(normalizer_version="9.9.9")
result = verify_corpus(self.raw, normalized, self.base / "artifacts")
self.assertEqual(result["status"], "failed")
self.assertIn(
"normalizer_version_mismatch",
{item["kind"] for item in result["finding_examples"]},
)
def test_raw_normalized_source_mismatch_is_reported(self):
normalized, _ = self._nonempty_corpus()
manifest = sqlite3.connect(normalized / "manifest.sqlite3")
+2 -1
View File
@@ -27,6 +27,7 @@ NORMALIZER_ROOT = Path(__file__).resolve().parents[2]
TOOL_SHA256 = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
sys.path.insert(0, str(NORMALIZER_ROOT))
from backend.normalization.minjust_cbd import NORMALIZER_VERSION, SCHEMA_VERSION as NORMALIZED_SCHEMA_VERSION # noqa: E402
SUPPORTED_NORMALIZER_VERSIONS = frozenset({"1.0.0", NORMALIZER_VERSION})
class InventoryError(RuntimeError):
@@ -552,7 +553,7 @@ def load_manifest(
finding(connection, run_id, root_id, "normalized_manifest_state", f"documents/{code}", str(state))
if root_id == "normalized" and schema_version != NORMALIZED_SCHEMA_VERSION:
finding(connection, run_id, root_id, "schema_version_mismatch", f"documents/{code}", str(schema_version))
if root_id == "normalized" and normalizer_version != NORMALIZER_VERSION:
if root_id == "normalized" and normalizer_version not in SUPPORTED_NORMALIZER_VERSIONS:
finding(connection, run_id, root_id, "normalizer_version_mismatch", f"documents/{code}", str(normalizer_version))
if root_id == "raw":
connection.execute("DELETE FROM manifest_errors WHERE root_id='raw'")
+1 -1
View File
@@ -179,7 +179,7 @@ TasksMax=256
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
+5
View File
@@ -23,6 +23,10 @@ grep -Fq 'test -w "$HOME" && test -w "$XDG_CONFIG_HOME" && test -w "$XDG_DATA_HO
grep -Fq 'rootless_env=(HOME="$runner_runtime_home" XDG_CONFIG_HOME="$runner_xdg_config_home" XDG_DATA_HOME="$runner_xdg_data_home"' "$installer"
grep -Fq 'Environment=XDG_CONFIG_HOME=$runner_xdg_config_home' "$installer"
grep -Fq 'Environment=XDG_DATA_HOME=$runner_xdg_data_home' "$installer"
grep -Fq 'Environment=XDG_RUNTIME_DIR=/run/user/$runner_uid' "$installer"
grep -Fq 'Environment=DOCKER_HOST=unix:///run/user/$runner_uid/docker.sock' "$installer"
grep -Fq 'ProtectHome=read-only' "$installer"
assert_absent '^ProtectHome=yes$' "$installer"
grep -Fq 'runner_home/.config/systemd/user/docker.service.d/environment.conf' "$installer"
grep -Fq 'Environment=HOME=$runner_runtime_home' "$installer"
grep -Fq 'systemctl --user restart docker.service' "$installer"
@@ -42,6 +46,7 @@ if grep -Fq 'env HOME="$runner_home"' "$installer"; then
fi
grep -Fq 'die '\''stop akyldash-act-runner.service after all jobs finish before rerunning this installer' "$installer"
grep -Fq 'ReadWritePaths=$runner_runtime_home $runner_xdg_config_home /var/log/akyldash-runner /run/user/$runner_uid' "$installer"
assert_absent 'ReadWritePaths=.*(/var/run/docker.sock|/run/docker.sock)' "$installer"
grep -Fq 'os.O_NOFOLLOW | os.O_CLOEXEC' infra/runner/secure-runner-image.py
grep -Fq 'os.posix_fallocate(image_fd, 0, int(bytes_text))' infra/runner/secure-runner-image.py
grep -Fq 'pass_fds=(image_fd,)' infra/runner/secure-runner-image.py
+110
View File
@@ -0,0 +1,110 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const repo = path.resolve(__dirname, '../..');
const artifacts = path.join(repo, 'docs/product/visual-review');
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
const root = path.join(repo, 'backend/search/static/fonts');
fs.mkdirSync(artifacts, {recursive: true});
const docs = [
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
];
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
let failNextEdition = false; let delayNextEdition = false;
(async()=>{
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*', async route=>{
const req=route.request(), url=new URL(req.url());
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search') return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
if(p==='/documents/doc-3/editions') return route.fulfill({status:503,json:{error:'Fixture editions error'}});
if(p==='/documents/doc-1/editions') return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}});
if(p==='/documents/doc-1/editions/2023') return route.fulfill({json:{content:{ru:{html:'<h2>Russian-only edition</h2><p>Regression fixture.</p>'}}}});
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}}); }
if(p==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
if(p==='/static/favicon-32.png') return route.fulfill({status:200,contentType:'image/png',body:fs.readFileSync(path.join(root,'../favicon-32.png'))});
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
});
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
await page.evaluate(()=>document.fonts.ready);
await page.locator('.search__result-title').first().click();
await page.locator('#public-document-body h2').first().waitFor();
await page.evaluate(()=>document.fonts.ready);
await page.locator('#document-find').fill('налог на прибыль');
await page.waitForTimeout(200);
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:0;padding-inline-start:12px;border-inline-start:1px solid #ded5cd;color:#786469;font-size:13px}' });
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('#active-filters').after(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>input.parentElement.textContent.trim()),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>input.parentElement.textContent.trim())})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#document-dialog').evaluate(e=>e.open); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#document-dialog').evaluate(d=>!d.open); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-document-panel').scrollTop=0});
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
const widths={}; for(const width of [1440,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
await page.setViewportSize({width:390,height:844});
await page.reload(); await page.evaluate(()=>document.fonts.ready);
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
await page.locator('#mobile-filters-open').click();
const drawer=await page.locator('#search-filters').getAttribute('aria-modal'); const modalBackground=await page.evaluate(()=>({chrome:document.querySelector('.search__chrome').inert,results:document.querySelector('.search__results').inert,document:document.querySelector('.search__document').inert,footer:document.querySelector('.search__footer').inert})); await page.locator('#search-filters').evaluate(el=>{const c=[...el.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),[href]')].filter(n=>n.offsetParent!==null);c.at(-1).focus()}); await page.keyboard.press('Tab'); const focusWrap=await page.evaluate(()=>document.activeElement.id); const focusVisible=await page.evaluate(()=>getComputedStyle(document.activeElement).outlineWidth); await page.keyboard.press('Shift+Tab'); const focusReverseWrap=await page.evaluate(()=>document.activeElement.id);
await page.keyboard.press('Escape'); const focusRestored=await page.evaluate(()=>document.activeElement.id);
await page.locator('.search__result-title').first().click();
await page.locator('#public-document-body h2').first().waitFor();
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
await page.locator('#document-back').click();
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()};
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},target);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
await page.setViewportSize({width:1280,height:800}); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent()};
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); await page.locator('#document-expand').click(); const noEditionExpandedText=await page.locator('#dialog-body').textContent(); const noEditionExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.locator('#document-expand').click(); const editionErrorExpandedText=await page.locator('#dialog-body').textContent(); const editionErrorExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
await page.locator('#sources-open').click(); const sourcesDialog=await page.evaluate(()=>({open:document.querySelector('#sources-dialog').open,labelledBy:document.querySelector('#sources-dialog').getAttribute('aria-labelledby'),focusInside:document.querySelector('#sources-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape'); const sourcesEscapeClosed=await page.locator('#sources-dialog').evaluate(d=>!d.open); const sourcesFocusRestored=await page.evaluate(()=>document.activeElement.id);
await page.locator('#search-account-open').click(); const accountDialogFocusInside=await page.locator('#account-dialog').evaluate(d=>d.contains(document.activeElement)); await page.keyboard.press('Escape'); const accountEscapeClosed=await page.locator('#account-dialog').evaluate(d=>!d.open); const accountFocusRestored=await page.evaluate(()=>document.activeElement.id);
await page.emulateMedia({media:'print'}); const printState=await page.evaluate(()=>({chrome:getComputedStyle(document.querySelector('.search__chrome')).display,filters:getComputedStyle(document.querySelector('.search__filters')).display,results:getComputedStyle(document.querySelector('.search__results')).display,footer:getComputedStyle(document.querySelector('.search__footer')).display,document:getComputedStyle(document.querySelector('#public-document-panel')).display,source:getComputedStyle(document.querySelector('#document-source')).display,sourceHidden:document.querySelector('#document-source').hidden})); await page.emulateMedia({media:'screen'});
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')};
await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}));
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}))};
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
await page.setViewportSize({width:1280,height:800});
await page.locator('#search-account-open').click(); const accountControls=page.locator('#account-dialog a, #account-dialog button, #account-dialog input'); await accountControls.last().focus(); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>document.activeElement.getAttribute('data-close')); await page.keyboard.press('Escape');
const longContent=await page.evaluate(()=>{const result=document.querySelector('.search__result-title'),heading=document.querySelector('#public-document-heading'),oldResult=result.textContent,oldHeading=heading.textContent;const longName='Кыргызская Республика '.repeat(8);result.textContent=longName;heading.textContent=longName;const image=document.createElement('img');image.width=2200;image.height=100;image.src='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="2200" height="100"><rect width="2200" height="100" fill="gold"/></svg>');document.querySelector('#public-document-body').append(image);const titleWidths=[result,heading].map(el=>({client:el.clientWidth,scroll:el.scrollWidth}));const imageWidth=image.getBoundingClientRect().width,bodyWidth=document.querySelector('#public-document-body').clientWidth,pageWidth=document.documentElement.scrollWidth;result.textContent=oldResult;heading.textContent=oldHeading;image.remove();return {titleWidths,imageWidth,bodyWidth,pageWidth,viewport:innerWidth}});
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,editionErrorExpandedText,editionErrorExpandedHeading,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens}));
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
const referencePath=process.env.REFERENCE_IMAGE || '/home/vaka/.codex/attachments/662a46bf-0f7f-4bba-a63a-bb823f4470dc/image-1.png';
if(fs.existsSync(referencePath)) {
const comparePage=await browser.newPage();
const reference=fs.readFileSync(referencePath).toString('base64'), actual=fs.readFileSync(path.join(artifacts,'desktop-1280x800.png')).toString('base64');
await comparePage.setContent(`<canvas id="overlay" width="1280" height="800"></canvas><canvas id="difference" width="1280" height="800"></canvas><img id="reference" src="data:image/png;base64,${reference}"><img id="actual" src="data:image/png;base64,${actual}">`);
await comparePage.evaluate(()=>Promise.all([...document.images].map((image)=>image.decode())));
const comparison=await comparePage.evaluate(()=>{
const ref=document.querySelector('#reference'),act=document.querySelector('#actual');
const place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};
const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;
const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);
const actualCanvas=document.createElement('canvas');actualCanvas.width=1280;actualCanvas.height=800;actualCanvas.getContext('2d').drawImage(act,0,0,1280,800);
const a=d.getImageData(0,0,1280,800),b=actualCanvas.getContext('2d').getImageData(0,0,1280,800);
for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);
const crop=(source,[x,y,width,height])=>{const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;canvas.getContext('2d').drawImage(source,x,y,width,height,0,0,width,height);return canvas.toDataURL('image/png')};
const regions={header:[0,0,1280,127],filters:[0,127,226,617],results:[226,127,422,617],document:[648,127,632,617],footer:[0,744,1280,56]};
return {overlay:overlay.toDataURL('image/png'),difference:difference.toDataURL('image/png'),regions:Object.entries(regions).map(([name,rect])=>({name,overlay:crop(overlay,rect),difference:crop(difference,rect)}))};
});
fs.writeFileSync(path.join(artifacts,'overlay-50.png'),Buffer.from(comparison.overlay.split(',')[1],'base64'));
fs.writeFileSync(path.join(artifacts,'pixel-difference.png'),Buffer.from(comparison.difference.split(',')[1],'base64'));
for(const region of comparison.regions){fs.writeFileSync(path.join(artifacts,`region-${region.name}-overlay.png`),Buffer.from(region.overlay.split(',')[1],'base64'));fs.writeFileSync(path.join(artifacts,`region-${region.name}-difference.png`),Buffer.from(region.difference.split(',')[1],'base64'));}
await comparePage.close();
}
await browser.close();
if(resultHighlight!=='налоге на прибыль' || navGeometry.search.left!==26 || navGeometry.search.right!==123 || navGeometry.sources.left!==146 || Math.abs(navGeometry.sources.right-244)>3 || Math.abs(navGeometry.language.left-284)>2 || Math.abs(navGeometry.language.right-372)>2 || navGeometry.status.left!==381 || Math.abs(navGeometry.demoText.left-510)>3 || errors.length || !fonts.sans || !fonts.serif || !fonts.kyrgyzSans || !fonts.kyrgyzSerif || fonts.ids.length || favicons.length!==2 || favicons.some(item=>item.status!==200) || Object.values(widths).some(x=>x.scrollWidth>x.viewport) || view.scrollWidth>view.width || drawer!=='true' || Object.values(modalBackground).some(value=>value!==true) || focusWrap!=='clear-filters' || focusVisible!=='3px' || focusReverseWrap==='clear-filters' || focusRestored!=='mobile-filters-open' || back!=='results' || openedCount!==1 || chipsBefore!==1 || chipsWithTwo!==2 || preservedFilters.status!==null || preservedFilters.documentType!=='code' || urlFilters.status!==null || urlFilters.documentType!==null || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)']) || JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)']) || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !expandedEscapeClosed || expandedFocusRestored!=='document-expand' || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true || !sourcesDialog.open || sourcesDialog.labelledBy!=='sources-heading' || !sourcesDialog.focusInside || !sourcesEscapeClosed || sourcesFocusRestored!=='sources-open' || !accountDialogFocusInside || !accountEscapeClosed || accountFocusRestored!=='search-account-open' || printState.chrome!=='none' || printState.filters!=='none' || printState.results!=='none' || printState.footer!=='none' || printState.document==='none' || printState.source==='none' || printState.sourceHidden || tableOverflow.scrollWidth<=tableOverflow.clientWidth || tableOverflow.pageWidth>tableOverflow.viewport || reducedMotion.scrollBehavior!=='auto' || reducedMotion.transitionDuration!=='0s' || searchError.message!=='Fixture search error' || searchError.query!=='service-error' || searchError.role!=='status' || editionError!=='Fixture edition error' || editionRetry!=='Налог на прибыль' || previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru' || !sourceDuringEditionLoad.hidden || sourceDuringEditionLoad.href!==null || !sourceAfterEditionError.hidden || sourceAfterEditionError.href!==null || sourceAfterEditionRetry.hidden || sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru' || noEditions.message!=='Для документа нет доступных редакций с текстом.' || noEditions.bodyChildren!==0 || !noEditions.source.hidden || noEditions.source.href!==null || languageFallback.language!=='ru' || languageFallback.heading!=='Russian-only edition' || noEditionExpandedText!=='' || !noEditionExpandedHeading.includes('О введении в действие') || editionErrorExpandedText!=='' || !editionErrorExpandedHeading.includes('Порядок заполнения') || !editionLoading || !accountTabWrap || accountTabTarget.id!=='review-nav' || accountReverseTabWrap!=='account-dialog' || longContent.titleWidths.some(item=>item.scroll>item.client) || longContent.imageWidth>longContent.bodyWidth || longContent.pageWidth>longContent.viewport) process.exit(1);
})();
+37
View File
@@ -0,0 +1,37 @@
{
"name": "akyldash-frontend-visual-review",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"playwright": "1.64.1"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "akyldash-frontend-visual-review",
"private": true,
"scripts": {
"test": "node check.cjs"
},
"devDependencies": {
"playwright": "1.64.1"
}
}