# CLAUDE.md Конвенции репозитория. Читать до правок. **Перед правкой подсистемы прочитать её файл — там инварианты, которые ломаются молча:** | Правишь | Читай | |---|---| | `Core/Proxies/**`, `Infrastructure/Proxies/**` | [docs/proxies.md](docs/proxies.md) | | `Collecting/**`, `Media/**`, `CollectViewModel`, `GalleryViewModel` | [docs/collecting.md](docs/collecting.md) | | `.axaml`, стили, тестовые проекты, файлы сборки | [docs/avalonia.md](docs/avalonia.md) | ## Команды `./build.ps1` — полный гейт (формат → сборка → тесты), прогнать перед коммитом. Отдельные шаги: `dotnet build AvParser.slnx -c Release`, `dotnet test AvParser.slnx -c Release`, `dotnet csharpier check .`, `./run.ps1`. На Linux/macOS — `./build.sh` и `./run.sh`. ## Слои `Core ← Infrastructure ← UI ← Desktop`, строго в одну сторону. - **`Core` не ссылается на Avalonia** — домен должен запускаться из CLI, worker-а или бенчмарка. Дай ему Avalonia, и кто-нибудь потянется к `Dispatcher.UIThread` внутри источника. - **`UI` — библиотека, а не exe**: headless-тесты собирают настоящие View без `Program.cs`, Serilog и контейнера. - **`Desktop` — тонкий composition root.** Логика туда не переезжает. ## Добавить источник 1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в `Infrastructure/Collecting/`. 2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для сетевого. 3. Ключи `Source.{id}.Name` и `Source.{id}.Description` в оба resx (без них отрисуется английский текст самого класса, а не сломается). Каталог, страница «Сбор» и список источников подхватят его сами. - **Источник ищет, а не качает.** Он отдаёт `MediaCandidate`; скачивание, редиректы, тайм-ауты, сниффинг и троттлинг — на `MediaFetcher`, одном на всех. - **Сетевой обязан переопределить `RequiresNetwork => true`** — иначе поедет напрямую в обход гейта. ## Добавить страницу 1. Наследник `PageViewModel` в `UI/ViewModels/XxxViewModel.cs` (`TitleKey`, `IconKey`). 2. `UI/Views/XxxView.axaml` — имя по конвенции `ViewLocator`: `...ViewModels.XxxViewModel` → `...Views.XxxView`. 3. Регистрация в `AddAvParserUI()` конкретным типом **и** как `PageViewModel`; порядок этих регистраций = порядок пунктов в рельсе навигации. ## ReactiveUI 24 (дистрибутив Primitives) `System.Reactive` не используется: | Классика | Здесь | |---|---| | `Unit` | `RxVoid` | | `IScheduler` | `ISequencer` (`ReactiveUI.Primitives.Concurrency`) | | `Subject` / `BehaviorSubject` | `Signal` / `BehaviorSignal` | | `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` | | `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` | Привычные операторы (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`, `CombineLatest`) работают; `using ReactiveUI.Primitives;` нужен ради `Subscribe(Action)`. **ReactiveUI 24 не инициализируется сама** — первый `WhenAnyValue` бросит `InvalidOperationException`, пока не отработал builder: в приложении `AppBuilder.UseReactiveUI(...)`, в VM-тестах module initializer `ReactiveUiBootstrap`. Новый тестовый проект без Avalonia обязан сделать то же самое. ## Конвенции ViewModel - **Каждая VM принимает `ISequencer? mainThread = null`** и использует его в `outputScheduler:` и `ToProperty(..., scheduler)`. Это и делает тесты синхронными — они передают `ImmediateSequencer`. - **Регистрация такой VM в DI — явная фабрика**, не по типу: иначе выбор конструктора зависит от порядка регистраций. - `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`. - **VM с производным от языка текстом переопределяет `OnLanguageChanged`** и зовёт `base`, иначе заголовок страницы застрянет на прежнем языке. - **VM, подписанная на синглтон** (пул, каталог, настройки), — `IDisposable` и отписывается. ## Строки UI 1. Ключ и **оба** перевода — в `Strings.resx` и `Strings.ru.resx`, иначе упадёт `LocalizationTests.Russian_translates_every_english_key`. 2. В XAML `{l:Loc Ключ}`, во ViewModel `Localizer.Instance[...]` / `.Format(...)`. Никакого хардкода в `Views/` кроме имени продукта и примеров адресов. - **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`: у русского три формы, «{0} records» с приклеенным окончанием непереводимо. - **Перечисления в списках — `LocalizedOption`**, не сырые значения: конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал. - **Текст из домена переводится по коду.** `ParseError` несёт `Code` и `Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`; имена источников — `Source.{id}.Name` с откатом на `DisplayName`, поэтому «добавить источник = одна строка» остаётся в силе. ## Границы, выбранные намеренно - **robots.txt не читается.** Источники либо принадлежат пользователю, либо введены им вручную. Это решение, а не забывчивость: **с появлением источника, который ходит по чужому сайту, robots.txt становится обязательным.** - **Возобновления по `Range` нет**: частичный файл в хранилище дороже повторного скачивания. - **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE. BMP/ICO/HEIC/JPEG-XL просто отложены. - **Перцептивных хешей нет**: дедуп точный, по SHA-256. - **Кадры из видео не извлекаются** — это FFmpeg целиком ради одной картинки в плитке.