Files
av-parser/README.md
T
Leonid PershinandClaude Opus 5 8b552470b7 Localise the UI into Russian and English, switchable without a restart
Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.

Three places where this is more than a string swap:

Russian has three plural forms, so counted messages are assembled from .One /
.Few / .Many keys via Localizer.Plural instead of "{0} records" with an English
plural glued on. "1 запись", "3 записи", "7 записей".

Enum captions in pickers go through LocalizedOption<T>. A value converter would
resolve the caption once and never notice a language change; the wrapper keeps
identity on the enum value so the selection survives, while the label follows
the localizer. It needed a non-generic base for DataTemplate x:DataType, since
Avalonia 12 compiles bindings by default and cannot infer one for an open
generic.

Text originating in the domain would otherwise have stayed English under a
Russian UI — the screenshots showed exactly that. AvParser.Core still knows
nothing about languages: ParseError now carries a Code and Arguments, and the UI
translates Parse.Error.{Code} with a fallback to the English message. Parser
names work the same way (Parser.{id}.Name falling back to DisplayName), which
keeps "add a parser = one registration line" true — an untranslated parser shows
its own name rather than a missing-key marker.

Both .resx files are generated from one table so a key cannot exist in one and
be missing from the other, and the tests assert that, plus no blank translations
and identical {0} placeholder sets — a translation that drops a placeholder
throws at runtime rather than merely reading oddly. A headless test switches
language on a live shell and asserts the rendered text changes without the tree
being rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:15:01 +03:00

227 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AvParser
Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх
Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
`IParser<TInput, TOutput>` и два демо-парсера, чтобы каркас был запускаемым и проверяемым
end-to-end до появления настоящей логики.
---
## Быстрый старт
Нужен .NET SDK **10.0.100** (закреплён в `global.json`).
Полный локальный гейт — восстановление, проверка форматирования, сборка, тесты:
```bash
./build.ps1
```
Запуск приложения (по умолчанию Debug):
```bash
./run.ps1
```
На Linux и macOS — `./build.sh` и `./run.sh`, аргументы те же.
Полезные флаги:
```bash
./build.ps1 -Fix -Configuration Debug
```
`-Fix` переформатирует код вместо того, чтобы падать на непрошедшей проверке; `-SkipTests`
собирает без прогона тестов. То же в bash: `--fix`, `-c Debug`, `--skip-tests`.
Если нужны отдельные шаги, скрипты ничего не прячут:
```bash
dotnet build AvParser.slnx -c Release
```
```bash
dotnet test AvParser.slnx -c Release
```
```bash
dotnet csharpier check .
```
---
## Структура
```
src/
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog
AvParser.UI Avalonia class library: App-независимые View, ViewModel,
ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/
AvParser.Core.Tests парсеры, реестр, отмена, прогресс, пул прокси и стратегии
AvParser.Infrastructure.Tests разбор фида прокси, локальный список, маппинг на WebProxy
AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
```
Ссылки идут строго в одну сторону: `Core ← Infrastructure ← UI ← Desktop`.
`UI` — библиотека, а не exe, именно чтобы headless-тесты собирали настоящие View, не подтягивая
`Program.cs`, Serilog и контейнер.
---
## Адаптивный layout
В Avalonia нет `AdaptiveTrigger`, `VisualStateManager` и media-queries. Есть три примитива:
наблюдаемый `Visual.Bounds`, псевдоклассы и `SplitView`. `ResponsiveLayout` связывает первое со
вторым — получается CSS-подобная реакция на ширину.
| Брейкпоинт | Ширина окна | Навигация |
|---|---|---|
| Compact | < 720 px | выезжающий drawer поверх контента |
| Medium | 720 1100 px | рельс из одних иконок (56 px) |
| Expanded | ≥ 1100 px | полный сайдбар с подписями (248 px) |
Переключение с гистерезисом в 24 px: без неё перетаскивание края окна заставляет layout
мигать между двумя состояниями на каждом пикселе дрожания.
Разделение обязанностей, которое важно не сломать:
- `SplitView.DisplayMode` и `IsPaneOpen` **биндятся во ViewModel**. Style-сеттер навсегда
проигрывает локальному значению, поэтому первый же клик по гамбургеру заморозил бы любой
стиль, который тоже пишет в эти свойства.
- Всё чисто визуальное — ширины панели, видимость подписей, паддинги — живёт в
`Styles/Shell.axaml`.
Селекторы там написаны как `:is(UserControl).shell`, а не `UserControl.shell`: селектор типа в
Avalonia матчит **точный** тип, а `ShellView` наследуется от `ReactiveUserControl<T>` — обычная
форма молча не сматчилась бы ни с чем. На это есть тест
(`ShellViewTests.The_shell_stylesheet_is_actually_applied`).
---
## Прокси
Пул прокси с ротацией — `AvParser.Core/Proxies`, источники и сетевая часть —
`AvParser.Infrastructure/Proxies`, управление — страница **Proxies**.
Источники:
- **[proxifly/free-proxy-list](https://github.com/proxifly/free-proxy-list)** — публичный список,
обновляется каждые 5 минут. Тянем сводный `all/data.json` через jsDelivr и фильтруем локально:
один условный запрос за весь список надёжнее четырёх по протоколам, которые могут разъехаться
между собой в момент публикации. Ответ кэшируется на 5 минут, недоступность фида не роняет
приложение — остаётся прошлый список.
- **Свой список** — `proxies.custom.json` рядом с настройками. Вставляется пачкой, по одной на
строку; поддерживаются `scheme://host:port`, голый `host:port` и `user:pass@`. Непонятые строки
не проглатываются молча, а называются в статусе.
Ротация выбирается в настройках:
| Стратегия | Поведение | Когда |
|---|---|---|
| Sticky | одна прокси, смена только по отказу | по умолчанию: не рвёт сессии и cookie |
| RoundRobin | новая на каждый запрос | размазывает рейт-лимиты, но ломает сессии |
| WeightedRandom | случайно, с весом по score и доле успехов | при сильном разбросе качества |
Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один
раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков
рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется
навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
Использование из кода:
```csharp
var (http, lease) = await clientFactory.CreateFromPoolAsync();
using (http)
using (lease)
{
try { var response = await http.GetAsync(url); lease?.ReportSuccess(); }
catch { lease?.ReportFailure("request failed"); throw; }
}
```
Отчёт об исходе — не формальность: без него пул ничего не узнаёт о том, какие прокси работают.
Освобождение лизы без вердикта нейтрально — отменённая операция не вина прокси.
SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что
proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`.
## Локализация
Русский и английский, переключение **без перезапуска** — язык выбирается в настройках
(«Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в `UI/Localization/Strings.resx` и `Strings.ru.resx`; русский собирается в
сателлитную сборку `ru/AvParser.UI.resources.dll`.
В XAML — разметочное расширение:
```xml
<TextBlock Text="{l:Loc Parse.Run}" />
```
Оно возвращает **биндинг** через индексатор `Localizer`, а не готовую строку: смена языка
поднимает `PropertyChanged` для индексатора, и все такие биндинги перечитываются разом. Строка,
разрешённая один раз при загрузке, потребовала бы перезапуска.
Три места, где локализация упирается в грамматику или в слои:
- **Множественные числа.** У русского три формы, поэтому счётчики собираются не из «{0} records»
с приклеенным окончанием, а из ключей `.One` / `.Few` / `.Many` через `Localizer.Plural`.
«1 запись», «3 записи», «7 записей».
- **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка,
поэтому в списках лежат обёртки `LocalizedOption<T>`: идентичность — значение перечисления
(выбор не слетает), подпись следует за локализатором.
- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение
**и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами
парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает
непереведённым, а не показывает `!ключ!`.
Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и
одинаковый набор плейсхолдеров `{0}`.
## Дизайн-токены
Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями
для Light и Dark. В остальном XAML нет ни одного литерального цвета и ни одного «магического»
отступа, так что перекрасить тему или уплотнить интерфейс — это правка одного файла.
Semi.Avalonia даёт темы контролов; токены — это семантический слой приложения поверх них.
Кнопки `.primary` / `.destructive` описаны своими стилями, а не классами Semi, чтобы акцентный
цвет не разъезжался между двумя палитрами.
---
## Стек
| Пакет | Версия | Заметка |
|---|---|---|
| Avalonia | 12.1.1 | compiled bindings по умолчанию → `x:DataType` обязателен |
| ReactiveUI.Avalonia | 12.1.1 | `Avalonia.ReactiveUI` — deprecated, это его преемник |
| ReactiveUI | 24.1.0 | дистрибутив Primitives: `RxVoid` вместо `Unit`, `ISequencer` вместо `IScheduler` |
| Semi.Avalonia | 12.1.0.1 | темы контролов |
| xUnit | v3 (3.2.2) | `Avalonia.Headless.XUnit` 12.x требует именно v3 |
---
## Что проверить руками
1. Потянуть окно по ширине — сайдбар проходит путь
`полный → только иконки → выезжающий drawer`, без мигания на границах.
2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
3. На странице Parse нажать **50k rows**, затем **Parse** — виден прогресс; **Cancel**
останавливает на середине и пишет, сколько успело разобраться.
В Avalonia 12 инспектора «из коробки» больше нет: `Avalonia.Diagnostics` остановился на 11.3.x,
а DevTools вынесли в отдельный инструмент со своей установкой
(`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`). Поэтому `F12` здесь ничего не
открывает — зависимость намеренно не добавлена.
Настройки и логи лежат в `%APPDATA%/AvParser` (Windows) или `~/.config/AvParser` (Linux/macOS).