Remove the demo text-parsing domain

The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.

Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.

Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.

Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 22:23:52 +03:00
co-authored by Claude Opus 5
parent fe62bcf53f
commit f8744c930a
33 changed files with 327 additions and 2109 deletions
+68 -20
View File
@@ -34,17 +34,28 @@ dotnet csharpier check .
- **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь - **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь
по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как
только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или
`IStorageProvider` внутри парсера. `IStorageProvider` внутри источника.
- **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не - **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не
подтягивая `Program.cs`, Serilog и контейнер. подтягивая `Program.cs`, Serilog и контейнер.
- **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает. - **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает.
## Добавить парсер ## Добавить источник
1. Реализовать `ITextParser` в `Core/Parsing/`. 1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в
2. Одна строка в `CoreServiceCollectionExtensions.AddAvParserCore()`. `Infrastructure/Collecting/`.
2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для
сетевого.
3. Два ключа в оба resx: `Source.{id}.Name` и `Source.{id}.Description` (без них источник
отрисуется английским текстом из самого класса, а не сломается).
Всё. `IParserCatalog`, страница Parse и выпадающий список подхватят его сами. Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами.
**Источник ищет, а не качает.** Он отдаёт `MediaCandidate` — адрес плюс метаданные. Скачиванием,
редиректами, тайм-аутами, сниффингом и троттлингом занимается `MediaFetcher`, один на всех. Источник,
который сам лезет за байтами, дублирует всё это и почти наверняка неправильно.
**Сетевой источник обязан переопределить `RequiresNetwork => true`** — иначе он не попадёт под гейт
и поедет напрямую в обход настройки.
## Добавить страницу ## Добавить страницу
@@ -106,7 +117,7 @@ dotnet csharpier check .
- **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом - **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом
значит карантинить здоровые прокси на каждый Cancel. значит карантинить здоровые прокси на каждый Cancel.
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`. - **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт парсера, поэтому - **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому
«доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться
от одного лишь истечения окна. от одного лишь истечения окна.
- **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует - **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует
@@ -115,7 +126,7 @@ dotnet csharpier check .
- **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном - **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном
`WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул `WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул
отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит
никого, а гейт парсера откроется по данным недельной давности. Ловит никого, а гейт сбора откроется по данным недельной давности. Ловит
`A_remembered_proxy_is_not_reported_live_until_it_answers_again`. `A_remembered_proxy_is_not_reported_live_until_it_answers_again`.
- **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти - **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти
сутки; перенос окна сажал бы прокси за то, что давно истекло. сутки; перенос окна сажал бы прокси за то, что давно истекло.
@@ -125,19 +136,48 @@ dotnet csharpier check .
большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы
накопленный список за пару запусков. накопленный список за пару запусков.
## Гейт парсера ## Гейт сбора
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление парсера `IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника
остаётся однострочным. Парсер, который куда-то ходит, **обязан** её переопределить, иначе поедет остаётся однострочным.
напрямую в обход настройки.
Гейт живёт в `ParseViewModel.RefreshProxyGate()` и складывается из трёх условий: парсер сетевой, Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой,
`AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с `AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с
throttle 250 мс — пул дёргается на каждый исход лизы), при смене парсера и при смене настроек. throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек.
`ParseView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `ParseViewTests` рендерит его Второй экземпляр того же правила — в `FetchOptions.RequireProxy`: гейт закрывает кнопку, а фетчер
бросает `ProxyUnavailableException` вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы
с адреса пользователя ровно тогда, когда он просил этого не делать.
`CollectView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `CollectViewTests` рендерит его
по-настоящему, потому что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста. по-настоящему, потому что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста.
## Инварианты хранилища медиа
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
том же томе и продвигается переименованием. Обрыв оставляет `.part`, который подметает следующий
старт, а не обрезанную картинку, неотличимую от настоящей навсегда.
- **Тип — по сигнатуре, никогда по URL, расширению или `Content-Type`.** Два из трёх выбирает тот,
кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера.
- **`GIF89a` не доказывает анимацию**, и APNG не определяется по фиксированному префиксу: нужен обход
блоков (второй Image Descriptor) и чанков (`acTL` раньше первого `IDAT`). Тихая ошибка, поэтому
обходчики изолированы за `internal static` швами и проверяются на массивах байтов.
- **`ref_count` денормализован и пересчитывается, а не инкрементится.** Апсерт `item` может заменить
строку, указывавшую на другой blob, и слепой `+1` уехал бы навсегда. Есть
`VerifyReferenceCountsAsync`, и он часть замысла, а не отладка.
- **Журнал `seen_url` переживает чистку.** Иначе следующий прогон скачает заново ровно то, что
пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает
момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте.
- **Вердикт лизы — про транспорт, а не про ресурс.** 404 и 429 — это успех прокси. Иначе пул
карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на
лимит отвечал бы сменой прокси, то есть обходом лимита.
- **Троттл поднимается только сигналами хоста** (429/503 с `Retry-After`) и никогда не приводит к
ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно.
- **Жёсткая ссылка — привилегия ФС, а не гарантия.** Откат на копию удваивает расход диска, поэтому
достигнутый режим пишется в `showcase_mode` и виден в UI.
- **Имя из `SuggestedName` враждебно.** Остаётся только последний сегмент, разделители не
переживают, устройства Windows отодвигаются, расширение берётся из типа.
## Добавить строку в UI ## Добавить строку в UI
1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт 1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт
@@ -152,8 +192,8 @@ throttle 250 мс — пул дёргается на каждый исход л
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
выбор не слетал. выбор не слетал.
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и - **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
`Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же: `Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же:
`Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка» `Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка»
остаётся в силе. остаётся в силе.
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт - **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
`base`. Без этого заголовок страницы останется на прежнем языке. `base`. Без этого заголовок страницы останется на прежнем языке.
@@ -201,8 +241,16 @@ throttle 250 мс — пул дёргается на каждый исход л
свежая CVE не роняла сборку кода, который никто не трогал. свежая CVE не роняла сборку кода, который никто не трогал.
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах. - Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
## Что осталось абстрактным ## Границы, выбранные намеренно
Домен — заглушка. `IParser<TInput, TOutput>` + `DelimitedTextParser` + `KeyValueTextParser` - **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им
существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика, вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением
демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`. третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.**
- **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам
публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов
и доступности, а не ради их обхода.
- **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в
хранилище дороже, чем повторное скачивание.
- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE.
BMP/ICO/HEIC/JPEG-XL просто отложены.
- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.
+80 -21
View File
@@ -1,11 +1,15 @@
# AvParser # AvParser
Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Desktop-приложение на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia,
Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов. единым DI-контейнером и тремя уровнями тестов.
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их
`IParser<TInput, TOutput>` и два демо-парсера, чтобы каркас был запускаемым и проверяемым скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы
end-to-end до появления настоящей логики. идут через пул прокси с ротацией и проверкой живости.
Источников два: **список ссылок**, который вы вставляете сами, и **свой сервис** — листинг-эндпоинт
сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см.
[«Границы, выбранные намеренно»](CLAUDE.md).
--- ---
@@ -54,15 +58,18 @@ dotnet csharpier check .
``` ```
src/ src/
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры AvParser.Core домен: IMediaSource и каталог, модели медиа, контракт хранилища,
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO прокси-пул и стратегии. Ноль зависимостей кроме DI.Abstractions —
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog ни Avalonia, ни HTTP, ни SQLite
AvParser.Infrastructure загрузчик (сниффинг, редиректы, троттл), SQLite-индекс, blob-хранилище,
витрина, источники прокси, AppPaths, JSON-настройки, Serilog
AvParser.UI Avalonia class library: App-независимые View, ViewModel, AvParser.UI Avalonia class library: App-независимые View, ViewModel,
ResponsiveLayout, дизайн-токены, навигация ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/ tests/
AvParser.Core.Tests парсеры, реестр, отмена, прогресс, пул прокси и стратегии AvParser.Core.Tests источники, каталог, модели, пул прокси и стратегии
AvParser.Infrastructure.Tests разбор фида прокси, локальный список, маппинг на WebProxy AvParser.Infrastructure.Tests сигнатуры и анимация, загрузчик против «плохого» сервера,
хранилище и дедуп, фид прокси, настройки
AvParser.UI.Tests ViewModel'и без Avalonia AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact] AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
``` ```
@@ -129,7 +136,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один
раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков
рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты. рабочих обычно единицы процентов, поэтому без проверки сборщик будет в основном ждать таймауты.
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется
навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно. навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
@@ -143,7 +150,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку, Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку,
но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт
парсера по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть. сбора по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
Состояние лежит в `proxies.state.json` рядом с настройками и пишется после прогрева и на выходе. Состояние лежит в `proxies.state.json` рядом с настройками и пишется после прогрева и на выходе.
Сохраняются **только те прокси, что когда-либо отвечали**: мёртвых в фиде тысячи, они переиздаются Сохраняются **только те прокси, что когда-либо отвечали**: мёртвых в фиде тысячи, они переиздаются
@@ -152,11 +159,15 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
### Гейт «без прокси не работаем» ### Гейт «без прокси не работаем»
Парсер, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси: Источник, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси:
кнопка «Разобрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies. кнопка «Собрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
Гейт снимается настройкой **«Разрешить сетевым парсерам работать без прокси»**. Гейт снимается настройкой **«Разрешить сетевым источникам работать без прокси»**.
Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего То же правило продублировано в загрузчике: он бросает `ProxyUnavailableException` вместо тихого
прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил
этого не делать.
Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего
маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки
лежат. лежат.
@@ -178,6 +189,48 @@ using (lease)
SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что
proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`. proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`.
## Сбор
Страница **Сбор**: выбрать источник, дать ему работу, запустить.
- **Список ссылок** — вставьте адреса, по одному в строке. Пустые строки и строки с `#`
игнорируются, непонятые называются в списке ошибок, а не проглатываются.
- **Свой сервис** — адрес листинг-эндпоинта. Принимается либо `{"items":[…],"next":"…"}`, либо
голый массив адресов; элемент может быть строкой или объектом с `url`, `id`, `name`,
`published`, `size`, `tags`. Постранично, пока есть `next`.
Что происходит с каждым найденным адресом:
1. **Журнал.** Если прошлый прогон уже закрыл этот адрес — пропуск без единого запроса. Отказы и
тайм-ауты закрытыми не считаются: они описывают момент, а не ресурс. Флажок «Скачать всё
заново» игнорирует журнал.
2. **Загрузка.** Редиректы разбираются вручную (лимит прыжков, отлов петли, отказ на не-http).
Три отдельных тайм-аута: соединение, заголовки и простой между чтениями — один общий был бы либо
слишком мал для тридцати мегабайт, либо бесполезен как признак зависания.
3. **Проверка.** Тип определяется по сигнатуре файла, а не по URL, расширению или `Content-Type`.
Ловятся: страница-ошибка за кодом 200, тело короче заявленного, превышение лимита размера,
трекинг-пиксели, известные заглушки мёртвых ссылок.
4. **Хранилище.** Файл кладётся по SHA-256 содержимого — один и тот же снимок, перезалитый по
десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую
прокси) пишется отдельно.
### Витрина
`blobs/ab/cd/<sha256>.png` не годится для просмотра глазами, поэтому рядом строится
`showcase/<источник>/<год>/<месяц>/<день>/0001-имя.png` — жёсткими ссылками, то есть без второй
копии байтов.
Жёсткая ссылка — это **второе имя того же файла**: правка витрины меняет оригинал, а удаление из
витрины ничего не освобождает, пока не исчезнет последнее имя. На FAT32, сетевых шарах и между
томами жёстких ссылок нет — тогда происходит откат на копию, расход диска удваивается, и
действующий режим виден в настройках.
### Чистка
Кнопка на странице сбора удаляет то, что собрал выбранный источник. Файл, на который ссылается и
другой источник, остаётся — ровно за этим в индексе счётчик ссылок. Журнал переживает чистку, иначе
следующий прогон скачал бы заново только что удалённое; забыть и его — отдельный флажок.
## Локализация ## Локализация
Русский и английский, переключение **без перезапуска** — язык выбирается в настройках Русский и английский, переключение **без перезапуска** — язык выбирается в настройках
@@ -204,9 +257,9 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP
- **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка, - **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка,
поэтому в списках лежат обёртки `LocalizedOption<T>`: идентичность — значение перечисления поэтому в списках лежат обёртки `LocalizedOption<T>`: идентичность — значение перечисления
(выбор не слетает), подпись следует за локализатором. (выбор не слетает), подпись следует за локализатором.
- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение - **Текст из домена.** `AvParser.Core` о языках не знает. Домен отдаёт английское сообщение
**и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами **и код**, а UI переводит `Collect.Error.{Code}` с откатом на сообщение. Так же и с именами
парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает источников: `Source.{id}.Name` с откатом на `DisplayName`, поэтому новый источник работает
непереведённым, а не показывает `!ключ!`. непереведённым, а не показывает `!ключ!`.
Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
@@ -242,8 +295,14 @@ Semi.Avalonia даёт темы контролов; токены — это се
1. Потянуть окно по ширине — сайдбар проходит путь 1. Потянуть окно по ширине — сайдбар проходит путь
`полный → только иконки → выезжающий drawer`, без мигания на границах. `полный → только иконки → выезжающий drawer`, без мигания на границах.
2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился. 2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
3. На странице Parse нажать **50k rows**, затем **Parse** — виден прогресс; **Cancel** 3. На странице **Сбор** вставить десяток адресов и нажать **Собрать**: список наполняется,
останавливает на середине и пишет, сколько успело разобраться. **Остановить** обрывает на середине и пишет, сколько успело собраться.
4. Запустить тот же список повторно — все строки должны прийти как «пропущено», без единого
сетевого запроса. Это журнал.
5. Заглянуть в `media/showcase` — файлы разложены по датам; сверить, что это жёсткие ссылки
(`fsutil hardlink list` в Windows, `ls -li` в Linux), а не копии.
6. Нажать чистку — файлы, на которые ссылается только этот источник, исчезают; общие остаются.
В Avalonia 12 инспектора «из коробки» больше нет: `Avalonia.Diagnostics` остановился на 11.3.x, В Avalonia 12 инспектора «из коробки» больше нет: `Avalonia.Diagnostics` остановился на 11.3.x,
а DevTools вынесли в отдельный инструмент со своей установкой а DevTools вынесли в отдельный инструмент со своей установкой
(`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`). Поэтому `F12` здесь ничего не (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`). Поэтому `F12` здесь ничего не
@@ -1,7 +1,5 @@
using AvParser.Core.Collecting; using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources; using AvParser.Core.Collecting.Sources;
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace AvParser.Core.DependencyInjection; namespace AvParser.Core.DependencyInjection;
@@ -10,20 +8,17 @@ namespace AvParser.Core.DependencyInjection;
public static class CoreServiceCollectionExtensions public static class CoreServiceCollectionExtensions
{ {
/// <summary> /// <summary>
/// Registers every parser plus the catalog that indexes them. /// Registers every media source the domain itself provides.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Adding a parser is a one-line change here — that is the whole point of the /// Adding a source is a one-line change here — that is the whole point of the
/// <see cref="ITextParser"/> / <see cref="IParserCatalog"/> split. /// <see cref="IMediaSource"/> / <see cref="IMediaSourceCatalog"/> split. Sources that need a
/// network live in the infrastructure layer and register themselves there.
/// </remarks> /// </remarks>
public static IServiceCollection AddAvParserCore(this IServiceCollection services) public static IServiceCollection AddAvParserCore(this IServiceCollection services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ITextParser, DelimitedTextParser>();
services.AddSingleton<ITextParser, KeyValueTextParser>();
services.AddSingleton<IParserCatalog, ParserCatalog>();
services.AddSingleton<IMediaSource, UrlListSource>(); services.AddSingleton<IMediaSource, UrlListSource>();
return services; return services;
@@ -1,17 +0,0 @@
namespace AvParser.Core.Parsing;
/// <summary>Read-only view over every registered text parser.</summary>
public interface IParserCatalog
{
/// <summary>All registered parsers, ordered by <see cref="IParser{TInput,TOutput}.DisplayName"/>.</summary>
IReadOnlyList<ITextParser> Parsers { get; }
/// <summary>The parser used when nothing has been chosen yet.</summary>
ITextParser DefaultParser { get; }
/// <summary>Finds a parser by its stable id; <see langword="null"/> when unknown.</summary>
ITextParser? Find(string? id);
/// <summary>Finds a parser by id, falling back to <see cref="DefaultParser"/>.</summary>
ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser;
}
-10
View File
@@ -1,10 +0,0 @@
namespace AvParser.Core.Parsing;
/// <summary>
/// Closed, non-generic facade over <see cref="IParser{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// Open generic interfaces cannot be resolved as <c>IEnumerable&lt;T&gt;</c> by the DI container,
/// so every text-shaped parser implements this closed interface and gets registered under it.
/// </remarks>
public interface ITextParser : IParser<string, ParsedRecord>;
+6 -6
View File
@@ -1,9 +1,9 @@
namespace AvParser.Core.Parsing; namespace AvParser.Core.Parsing;
/// <summary>A recoverable problem with a single record. Parsing continues after one of these.</summary> /// <summary>A recoverable problem with a single record. Parsing continues after one of these.</summary>
/// <param name="LineNumber">1-based position of the offending record in the input.</param> /// <param name="Index">1-based position of the offending item: a line, or a place in a listing.</param>
/// <param name="Message">What went wrong, phrased for a user rather than a developer.</param> /// <param name="Message">What went wrong, phrased for a user rather than a developer.</param>
public sealed record ParseError(int LineNumber, string Message) public sealed record ParseError(int Index, string Message)
{ {
/// <summary> /// <summary>
/// Stable identifier for the kind of failure, or <see langword="null"/> when the message is /// Stable identifier for the kind of failure, or <see langword="null"/> when the message is
@@ -11,7 +11,7 @@ public sealed record ParseError(int LineNumber, string Message)
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The domain stays language-free: it produces an English message plus a code, and the UI /// The domain stays language-free: it produces an English message plus a code, and the UI
/// translates <c>Parse.Error.{Code}</c> with <see cref="Arguments"/>, falling back to /// translates <c>Collect.Error.{Code}</c> with <see cref="Arguments"/>, falling back to
/// <see cref="Message"/>. Without this, a Russian UI would still print English error text — /// <see cref="Message"/>. Without this, a Russian UI would still print English error text —
/// and moving the strings themselves into the domain would drag localisation down there. /// and moving the strings themselves into the domain would drag localisation down there.
/// </remarks> /// </remarks>
@@ -21,9 +21,9 @@ public sealed record ParseError(int LineNumber, string Message)
public IReadOnlyList<object?> Arguments { get; init; } = []; public IReadOnlyList<object?> Arguments { get; init; } = [];
/// <summary>Creates an error carrying a translation code.</summary> /// <summary>Creates an error carrying a translation code.</summary>
public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) => public static ParseError Create(int index, string code, string message, params object?[] arguments) =>
new(lineNumber, message) { Code = code, Arguments = arguments }; new(index, message) { Code = code, Arguments = arguments };
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() => $"Line {LineNumber}: {Message}"; public override string ToString() => $"#{Index}: {Message}";
} }
-36
View File
@@ -1,36 +0,0 @@
namespace AvParser.Core.Parsing;
/// <summary>One named field of a <see cref="ParsedRecord"/>.</summary>
/// <param name="Name">Column name, or the positional index rendered as text.</param>
/// <param name="Value">Raw field value, already trimmed of surrounding whitespace.</param>
public readonly record struct ParsedField(string Name, string Value)
{
/// <inheritdoc />
public override string ToString() => $"{Name}={Value}";
}
/// <summary>A single successfully parsed record.</summary>
/// <param name="LineNumber">1-based position of the record in the source input.</param>
/// <param name="Fields">The record's fields, in source order.</param>
public sealed record ParsedRecord(int LineNumber, IReadOnlyList<ParsedField> Fields)
{
/// <summary>Flattened <c>key=value</c> rendering, used by the results list.</summary>
public string Summary => string.Join(" ", Fields);
/// <summary>Looks a field up by name; <see langword="null"/> when absent.</summary>
public string? this[string name]
{
get
{
foreach (var field in Fields)
{
if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase))
{
return field.Value;
}
}
return null;
}
}
}
@@ -1,39 +0,0 @@
namespace AvParser.Core.Parsing;
/// <inheritdoc cref="IParserCatalog" />
public sealed class ParserCatalog : IParserCatalog
{
private readonly Dictionary<string, ITextParser> _byId;
/// <summary>Builds a catalog from every parser the container resolved.</summary>
/// <exception cref="ArgumentException">No parsers were registered, or two share an id.</exception>
public ParserCatalog(IEnumerable<ITextParser> parsers)
{
ArgumentNullException.ThrowIfNull(parsers);
Parsers = parsers.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray();
if (Parsers.Count == 0)
{
throw new ArgumentException("At least one parser must be registered.", nameof(parsers));
}
_byId = new Dictionary<string, ITextParser>(StringComparer.OrdinalIgnoreCase);
foreach (var parser in Parsers)
{
if (!_byId.TryAdd(parser.Id, parser))
{
throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers));
}
}
}
/// <inheritdoc />
public IReadOnlyList<ITextParser> Parsers { get; }
/// <inheritdoc />
public ITextParser DefaultParser => Parsers[0];
/// <inheritdoc />
public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null;
}
@@ -1,142 +0,0 @@
using System.Runtime.CompilerServices;
using AvParser.Core.Collecting;
namespace AvParser.Core.Parsing.Samples;
/// <summary>
/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header.
/// </summary>
/// <remarks>
/// Intentionally simple — no quoting, no escapes. It exists to exercise the
/// <see cref="IParser{TInput,TOutput}"/> contract end to end, not to replace a CSV library.
/// </remarks>
public sealed class DelimitedTextParser : ITextParser
{
private static readonly char[] Candidates = [',', ';', '\t', '|'];
/// <inheritdoc />
public string Id => "delimited";
/// <inheritdoc />
public string DisplayName => "Delimited text";
/// <inheritdoc />
public string Description =>
"First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).";
/// <inheritdoc />
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0;
/// <inheritdoc />
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(input);
var lines = TextLines.Split(input);
var total = lines.Length;
string[]? header = null;
char separator = ',';
var processed = 0;
for (var i = 0; i < lines.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var line = lines[i];
var lineNumber = i + 1;
processed++;
if (TextLines.IsSkippable(line))
{
ReportEvery(progress, processed, total);
continue;
}
if (header is null)
{
separator = DetectSeparator(line);
header = SplitTrimmed(line, separator);
ReportEvery(progress, processed, total);
continue;
}
var values = SplitTrimmed(line, separator);
if (values.Length != header.Length)
{
yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
"FieldCount",
$"Expected {header.Length} field(s) but found {values.Length}.",
header.Length,
values.Length
);
}
else
{
var fields = new ParsedField[values.Length];
for (var f = 0; f < values.Length; f++)
{
fields[f] = new ParsedField(header[f], values[f]);
}
yield return ParseOutcome<ParsedRecord>.Success(new ParsedRecord(lineNumber, fields));
}
ReportEvery(progress, processed, total);
if (processed % TextLines.YieldInterval == 0)
{
await Task.Yield();
}
}
if (header is null)
{
yield return ParseOutcome<ParsedRecord>.Failure(1, "NoHeader", "Input contains no header row.");
}
progress?.Report(new ParseProgress(total, total));
}
private static void ReportEvery(IProgress<ParseProgress>? progress, int processed, int total)
{
if (progress is not null && processed % TextLines.ProgressInterval == 0)
{
progress.Report(new ParseProgress(processed, total));
}
}
private static char DetectSeparator(string headerLine)
{
var best = Candidates[0];
var bestCount = 0;
foreach (var candidate in Candidates)
{
var count = headerLine.Count(c => c == candidate);
if (count > bestCount)
{
bestCount = count;
best = candidate;
}
}
return best;
}
private static string[] SplitTrimmed(string line, char separator)
{
var parts = line.Split(separator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = parts[i].Trim();
}
return parts;
}
}
@@ -1,87 +0,0 @@
using System.Runtime.CompilerServices;
using AvParser.Core.Collecting;
namespace AvParser.Core.Parsing.Samples;
/// <summary>
/// Sample parser: <c>key=value</c> / <c>key: value</c> lines, ini/env style.
/// </summary>
/// <remarks>
/// A second sample with a different input shape, so the abstraction is proven against more
/// than one implementation before the real domain arrives.
/// </remarks>
public sealed class KeyValueTextParser : ITextParser
{
private static readonly char[] Separators = ['=', ':'];
/// <inheritdoc />
public string Id => "key-value";
/// <inheritdoc />
public string DisplayName => "Key / value pairs";
/// <inheritdoc />
public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.";
/// <inheritdoc />
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0;
/// <inheritdoc />
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(input);
var lines = TextLines.Split(input);
var total = lines.Length;
for (var i = 0; i < lines.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var line = lines[i];
var lineNumber = i + 1;
var processed = i + 1;
if (!TextLines.IsSkippable(line))
{
var separatorIndex = line.IndexOfAny(Separators);
if (separatorIndex <= 0)
{
yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
"NoSeparator",
"No '=' or ':' separator found."
);
}
else
{
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
yield return key.Length == 0
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "EmptyKey", "Key is empty.")
: ParseOutcome<ParsedRecord>.Success(
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
);
}
}
if (progress is not null && processed % TextLines.ProgressInterval == 0)
{
progress.Report(new ParseProgress(processed, total));
}
if (processed % TextLines.YieldInterval == 0)
{
await Task.Yield();
}
}
progress?.Report(new ParseProgress(total, total));
}
}
@@ -34,7 +34,6 @@ public enum AppTheme
/// </remarks> /// </remarks>
/// <param name="Theme">Chosen theme variant.</param> /// <param name="Theme">Chosen theme variant.</param>
/// <param name="Language">Chosen UI language.</param> /// <param name="Language">Chosen UI language.</param>
/// <param name="LastParserId">Id of the parser selected last time; resolved leniently on load.</param>
/// <param name="WindowWidth">Last main-window width in device-independent pixels.</param> /// <param name="WindowWidth">Last main-window width in device-independent pixels.</param>
/// <param name="WindowHeight">Last main-window height in device-independent pixels.</param> /// <param name="WindowHeight">Last main-window height in device-independent pixels.</param>
/// <param name="WindowMaximized">Whether the main window was maximised on exit.</param> /// <param name="WindowMaximized">Whether the main window was maximised on exit.</param>
@@ -65,7 +64,6 @@ public enum AppTheme
public sealed record AppSettings( public sealed record AppSettings(
AppTheme Theme = AppTheme.System, AppTheme Theme = AppTheme.System,
AppLanguage Language = AppLanguage.System, AppLanguage Language = AppLanguage.System,
string? LastParserId = null,
double WindowWidth = 1280, double WindowWidth = 1280,
double WindowHeight = 800, double WindowHeight = 800,
bool WindowMaximized = false, bool WindowMaximized = false,
@@ -1,5 +1,4 @@
using AvParser.Core.Collecting; using AvParser.Core.Collecting;
using AvParser.Core.Parsing;
using AvParser.Core.Proxies; using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.Infrastructure.Proxies; using AvParser.Infrastructure.Proxies;
@@ -32,13 +31,6 @@ public static class UiServiceCollectionExtensions
services.AddSingleton<INavigationService, NavigationService>(); services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<DashboardViewModel>(); services.AddSingleton<DashboardViewModel>();
services.AddSingleton<ParseViewModel>(static sp => new ParseViewModel(
sp.GetRequiredService<IParserCatalog>(),
sp.GetRequiredService<ISettingsService>(),
sp.GetRequiredService<IProxyPool>(),
sp,
sp.GetRequiredService<ILogger<ParseViewModel>>()
));
services.AddSingleton<SettingsViewModel>(static sp => new SettingsViewModel( services.AddSingleton<SettingsViewModel>(static sp => new SettingsViewModel(
sp.GetRequiredService<ISettingsService>(), sp.GetRequiredService<ISettingsService>(),
sp.GetRequiredService<IThemeService>(), sp.GetRequiredService<IThemeService>(),
@@ -66,7 +58,6 @@ public static class UiServiceCollectionExtensions
// Order here is the order of the navigation rail; the first entry is the landing page. // Order here is the order of the navigation rail; the first entry is the landing page.
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<CollectViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<CollectViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
+28 -133
View File
@@ -70,9 +70,6 @@
<data name="Page.Dashboard" xml:space="preserve"> <data name="Page.Dashboard" xml:space="preserve">
<value>Dashboard</value> <value>Dashboard</value>
</data> </data>
<data name="Page.Parse" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Page.Proxies" xml:space="preserve"> <data name="Page.Proxies" xml:space="preserve">
<value>Proxies</value> <value>Proxies</value>
</data> </data>
@@ -85,90 +82,12 @@
<data name="Dashboard.Tagline" xml:space="preserve"> <data name="Dashboard.Tagline" xml:space="preserve">
<value>A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer.</value> <value>A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer.</value>
</data> </data>
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
<value>Registered parsers</value>
</data>
<data name="Dashboard.GetStarted" xml:space="preserve"> <data name="Dashboard.GetStarted" xml:space="preserve">
<value>Get started</value> <value>Get started</value>
</data> </data>
<data name="Dashboard.OpenParser" xml:space="preserve">
<value>Open the parser</value>
</data>
<data name="Dashboard.DataDirectory" xml:space="preserve"> <data name="Dashboard.DataDirectory" xml:space="preserve">
<value>DATA DIRECTORY</value> <value>DATA DIRECTORY</value>
</data> </data>
<data name="Parse.Parser" xml:space="preserve">
<value>PARSER</value>
</data>
<data name="Parse.Actions" xml:space="preserve">
<value>ACTIONS</value>
</data>
<data name="Parse.Run" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Parse.Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Parse.Sample" xml:space="preserve">
<value>Sample</value>
</data>
<data name="Parse.SampleTip" xml:space="preserve">
<value>Fill the input with a small example</value>
</data>
<data name="Parse.LargeSample" xml:space="preserve">
<value>50k rows</value>
</data>
<data name="Parse.LargeSampleTip" xml:space="preserve">
<value>Generate 50 000 rows, so progress and cancellation are observable</value>
</data>
<data name="Parse.ClearTip" xml:space="preserve">
<value>Clear input and results</value>
</data>
<data name="Parse.Input" xml:space="preserve">
<value>INPUT</value>
</data>
<data name="Parse.InputPlaceholder" xml:space="preserve">
<value>Paste text here, or press Sample</value>
</data>
<data name="Parse.Records" xml:space="preserve">
<value>RECORDS</value>
</data>
<data name="Parse.Errors" xml:space="preserve">
<value>ERRORS</value>
</data>
<data name="Parse.Count.Records.One" xml:space="preserve">
<value>{0} record</value>
</data>
<data name="Parse.Count.Records.Few" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Records.Many" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Errors.One" xml:space="preserve">
<value>{0} error</value>
</data>
<data name="Parse.Count.Errors.Few" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Count.Errors.Many" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Status.Done" xml:space="preserve">
<value>Parsed {0} in {1} ms.</value>
</data>
<data name="Parse.Status.Cancelled" xml:space="preserve">
<value>Cancelled after {0} in {1} ms.</value>
</data>
<data name="Parse.Status.WithErrors" xml:space="preserve">
<value>{0}, {1}.</value>
</data>
<data name="Parse.Status.Truncated" xml:space="preserve">
<value>Showing the first {0} only.</value>
</data>
<data name="Parse.Status.Failed" xml:space="preserve">
<value>Parse failed: {0}</value>
</data>
<data name="Proxies.Pool" xml:space="preserve"> <data name="Proxies.Pool" xml:space="preserve">
<value>POOL</value> <value>POOL</value>
</data> </data>
@@ -463,44 +382,14 @@
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve"> <data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
<value>Unchecked</value> <value>Unchecked</value>
</data> </data>
<data name="Parser.delimited.Name" xml:space="preserve">
<value>Delimited text</value>
</data>
<data name="Parser.delimited.Description" xml:space="preserve">
<value>First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).</value>
</data>
<data name="Parser.key-value.Name" xml:space="preserve">
<value>Key / value pairs</value>
</data>
<data name="Parser.key-value.Description" xml:space="preserve">
<value>One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.</value>
</data>
<data name="Parse.Error.FieldCount" xml:space="preserve">
<value>Expected {0} field(s) but found {1}.</value>
</data>
<data name="Parse.Error.NoHeader" xml:space="preserve">
<value>Input contains no header row.</value>
</data>
<data name="Parse.Error.NoSeparator" xml:space="preserve">
<value>No '=' or ':' separator found.</value>
</data>
<data name="Parse.Error.EmptyKey" xml:space="preserve">
<value>Key is empty.</value>
</data>
<data name="Proxies.Status.Ready" xml:space="preserve"> <data name="Proxies.Status.Ready" xml:space="preserve">
<value>{0} in the pool, {1} live.</value> <value>{0} in the pool, {1} live.</value>
</data> </data>
<data name="Parse.ProxyRequired" xml:space="preserve">
<value>This parser needs a working proxy, and none is available. Check the proxy list, or allow direct connections in settings.</value>
</data>
<data name="Parse.GoToProxies" xml:space="preserve">
<value>Open proxies</value>
</data>
<data name="Settings.AllowDirect" xml:space="preserve"> <data name="Settings.AllowDirect" xml:space="preserve">
<value>Allow network parsers to run without a proxy</value> <value>Allow network sources to run without a proxy</value>
</data> </data>
<data name="Settings.AllowDirectHint" xml:space="preserve"> <data name="Settings.AllowDirectHint" xml:space="preserve">
<value>When off, a parser that fetches anything refuses to start until at least one proxy answers. Parsers that only read pasted text are never blocked.</value> <value>When off, a source that fetches anything refuses to start until at least one proxy answers. Sources that only read pasted text are never blocked.</value>
</data> </data>
<data name="Settings.MinimumLive" xml:space="preserve"> <data name="Settings.MinimumLive" xml:space="preserve">
<value>LIVE PROXIES TO FIND AT STARTUP</value> <value>LIVE PROXIES TO FIND AT STARTUP</value>
@@ -616,64 +505,64 @@
<data name="Source.own-service.Description" xml:space="preserve"> <data name="Source.own-service.Description" xml:space="preserve">
<value>Reads the listing endpoint of a service you run.</value> <value>Reads the listing endpoint of a service you run.</value>
</data> </data>
<data name="Parse.Error.NotAnAddress" xml:space="preserve"> <data name="Collect.Error.NotAnAddress" xml:space="preserve">
<value>&apos;{0}&apos; is not an http or https address.</value> <value>&apos;{0}&apos; is not an http or https address.</value>
</data> </data>
<data name="Parse.Error.NoEndpoint" xml:space="preserve"> <data name="Collect.Error.NoEndpoint" xml:space="preserve">
<value>No listing endpoint was given.</value> <value>No listing endpoint was given.</value>
</data> </data>
<data name="Parse.Error.ListingFailed" xml:space="preserve"> <data name="Collect.Error.ListingFailed" xml:space="preserve">
<value>Could not read the listing: {0}</value> <value>Could not read the listing: {0}</value>
</data> </data>
<data name="Parse.Error.ListingMalformed" xml:space="preserve"> <data name="Collect.Error.ListingMalformed" xml:space="preserve">
<value>The listing could not be understood: {0}</value> <value>The listing could not be understood: {0}</value>
</data> </data>
<data name="Parse.Error.SourceFailed" xml:space="preserve"> <data name="Collect.Error.SourceFailed" xml:space="preserve">
<value>The source stopped: {0}</value> <value>The source stopped: {0}</value>
</data> </data>
<data name="Parse.Error.NoProxy" xml:space="preserve"> <data name="Collect.Error.NoProxy" xml:space="preserve">
<value>No live proxy, and direct connections are switched off.</value> <value>No live proxy, and direct connections are switched off.</value>
</data> </data>
<data name="Parse.Error.BadRedirect" xml:space="preserve"> <data name="Collect.Error.BadRedirect" xml:space="preserve">
<value>Redirected somewhere that is not an http address.</value> <value>Redirected somewhere that is not an http address.</value>
</data> </data>
<data name="Parse.Error.RedirectLoop" xml:space="preserve"> <data name="Collect.Error.RedirectLoop" xml:space="preserve">
<value>The redirects loop back on themselves.</value> <value>The redirects loop back on themselves.</value>
</data> </data>
<data name="Parse.Error.TooManyRedirects" xml:space="preserve"> <data name="Collect.Error.TooManyRedirects" xml:space="preserve">
<value>Too many redirects.</value> <value>Too many redirects.</value>
</data> </data>
<data name="Parse.Error.NotMedia" xml:space="preserve"> <data name="Collect.Error.NotMedia" xml:space="preserve">
<value>The response was not an image or a video.</value> <value>The response was not an image or a video.</value>
</data> </data>
<data name="Parse.Error.TooLarge" xml:space="preserve"> <data name="Collect.Error.TooLarge" xml:space="preserve">
<value>Larger than the size limit.</value> <value>Larger than the size limit.</value>
</data> </data>
<data name="Parse.Error.TooSmall" xml:space="preserve"> <data name="Collect.Error.TooSmall" xml:space="preserve">
<value>Smaller than the size floor; probably a tracking pixel.</value> <value>Smaller than the size floor; probably a tracking pixel.</value>
</data> </data>
<data name="Parse.Error.Truncated" xml:space="preserve"> <data name="Collect.Error.Truncated" xml:space="preserve">
<value>The transfer ended early.</value> <value>The transfer ended early.</value>
</data> </data>
<data name="Parse.Error.Stalled" xml:space="preserve"> <data name="Collect.Error.Stalled" xml:space="preserve">
<value>Timed out.</value> <value>Timed out.</value>
</data> </data>
<data name="Parse.Error.RateLimited" xml:space="preserve"> <data name="Collect.Error.RateLimited" xml:space="preserve">
<value>The site asked to slow down.</value> <value>The site asked to slow down.</value>
</data> </data>
<data name="Parse.Error.Placeholder" xml:space="preserve"> <data name="Collect.Error.Placeholder" xml:space="preserve">
<value>A known dead-link placeholder.</value> <value>A known dead-link placeholder.</value>
</data> </data>
<data name="Parse.Error.UnsupportedType" xml:space="preserve"> <data name="Collect.Error.UnsupportedType" xml:space="preserve">
<value>That format is switched off.</value> <value>That format is switched off.</value>
</data> </data>
<data name="Parse.Error.HttpStatus" xml:space="preserve"> <data name="Collect.Error.HttpStatus" xml:space="preserve">
<value>The site answered {0}.</value> <value>The site answered {0}.</value>
</data> </data>
<data name="Parse.Error.RequestFailed" xml:space="preserve"> <data name="Collect.Error.RequestFailed" xml:space="preserve">
<value>The request failed: {0}</value> <value>The request failed: {0}</value>
</data> </data>
<data name="Parse.Error.Failed" xml:space="preserve"> <data name="Collect.Error.Failed" xml:space="preserve">
<value>Could not be collected.</value> <value>Could not be collected.</value>
</data> </data>
<data name="Enum.ShowcaseMode.None" xml:space="preserve"> <data name="Enum.ShowcaseMode.None" xml:space="preserve">
@@ -739,4 +628,10 @@
<data name="Collect.Count.Files.Many" xml:space="preserve"> <data name="Collect.Count.Files.Many" xml:space="preserve">
<value>{0} files</value> <value>{0} files</value>
</data> </data>
<data name="Dashboard.RegisteredSources" xml:space="preserve">
<value>Registered sources</value>
</data>
<data name="Dashboard.OpenCollector" xml:space="preserve">
<value>Open the collector</value>
</data>
</root> </root>
+29 -134
View File
@@ -70,9 +70,6 @@
<data name="Page.Dashboard" xml:space="preserve"> <data name="Page.Dashboard" xml:space="preserve">
<value>Обзор</value> <value>Обзор</value>
</data> </data>
<data name="Page.Parse" xml:space="preserve">
<value>Разбор</value>
</data>
<data name="Page.Proxies" xml:space="preserve"> <data name="Page.Proxies" xml:space="preserve">
<value>Прокси</value> <value>Прокси</value>
</data> </data>
@@ -83,92 +80,14 @@
<value>О программе</value> <value>О программе</value>
</data> </data>
<data name="Dashboard.Tagline" xml:space="preserve"> <data name="Dashboard.Tagline" xml:space="preserve">
<value>Оболочка парсера с адаптивной раскладкой. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель.</value> <value>Собирает изображения и гифки в локальное хранилище. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель.</value>
</data>
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
<value>Доступные парсеры</value>
</data> </data>
<data name="Dashboard.GetStarted" xml:space="preserve"> <data name="Dashboard.GetStarted" xml:space="preserve">
<value>С чего начать</value> <value>С чего начать</value>
</data> </data>
<data name="Dashboard.OpenParser" xml:space="preserve">
<value>Открыть парсер</value>
</data>
<data name="Dashboard.DataDirectory" xml:space="preserve"> <data name="Dashboard.DataDirectory" xml:space="preserve">
<value>КАТАЛОГ ДАННЫХ</value> <value>КАТАЛОГ ДАННЫХ</value>
</data> </data>
<data name="Parse.Parser" xml:space="preserve">
<value>ПАРСЕР</value>
</data>
<data name="Parse.Actions" xml:space="preserve">
<value>ДЕЙСТВИЯ</value>
</data>
<data name="Parse.Run" xml:space="preserve">
<value>Разобрать</value>
</data>
<data name="Parse.Cancel" xml:space="preserve">
<value>Отменить</value>
</data>
<data name="Parse.Sample" xml:space="preserve">
<value>Пример</value>
</data>
<data name="Parse.SampleTip" xml:space="preserve">
<value>Подставить небольшой пример</value>
</data>
<data name="Parse.LargeSample" xml:space="preserve">
<value>50k строк</value>
</data>
<data name="Parse.LargeSampleTip" xml:space="preserve">
<value>Сгенерировать 50 000 строк, чтобы были видны прогресс и отмена</value>
</data>
<data name="Parse.ClearTip" xml:space="preserve">
<value>Очистить ввод и результаты</value>
</data>
<data name="Parse.Input" xml:space="preserve">
<value>ВХОД</value>
</data>
<data name="Parse.InputPlaceholder" xml:space="preserve">
<value>Вставьте текст или нажмите «Пример»</value>
</data>
<data name="Parse.Records" xml:space="preserve">
<value>ЗАПИСИ</value>
</data>
<data name="Parse.Errors" xml:space="preserve">
<value>ОШИБКИ</value>
</data>
<data name="Parse.Count.Records.One" xml:space="preserve">
<value>{0} запись</value>
</data>
<data name="Parse.Count.Records.Few" xml:space="preserve">
<value>{0} записи</value>
</data>
<data name="Parse.Count.Records.Many" xml:space="preserve">
<value>{0} записей</value>
</data>
<data name="Parse.Count.Errors.One" xml:space="preserve">
<value>{0} ошибка</value>
</data>
<data name="Parse.Count.Errors.Few" xml:space="preserve">
<value>{0} ошибки</value>
</data>
<data name="Parse.Count.Errors.Many" xml:space="preserve">
<value>{0} ошибок</value>
</data>
<data name="Parse.Status.Done" xml:space="preserve">
<value>Разобрано: {0} за {1} мс.</value>
</data>
<data name="Parse.Status.Cancelled" xml:space="preserve">
<value>Отменено: {0} за {1} мс.</value>
</data>
<data name="Parse.Status.WithErrors" xml:space="preserve">
<value>{0}, {1}.</value>
</data>
<data name="Parse.Status.Truncated" xml:space="preserve">
<value>Показаны только первые {0}.</value>
</data>
<data name="Parse.Status.Failed" xml:space="preserve">
<value>Разбор не удался: {0}</value>
</data>
<data name="Proxies.Pool" xml:space="preserve"> <data name="Proxies.Pool" xml:space="preserve">
<value>ПУЛ</value> <value>ПУЛ</value>
</data> </data>
@@ -463,44 +382,14 @@
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve"> <data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
<value>Не проверены</value> <value>Не проверены</value>
</data> </data>
<data name="Parser.delimited.Name" xml:space="preserve">
<value>Текст с разделителями</value>
</data>
<data name="Parser.delimited.Description" xml:space="preserve">
<value>Первая непустая строка — заголовок. Строки режутся по разделителю, преобладающему в нём (, ; таб |).</value>
</data>
<data name="Parser.key-value.Name" xml:space="preserve">
<value>Пары ключ / значение</value>
</data>
<data name="Parser.key-value.Description" xml:space="preserve">
<value>По паре в строке, разделитель «=» или «:». Строки с «#» — комментарии.</value>
</data>
<data name="Parse.Error.FieldCount" xml:space="preserve">
<value>Ожидалось полей: {0}, найдено: {1}.</value>
</data>
<data name="Parse.Error.NoHeader" xml:space="preserve">
<value>Во входных данных нет строки заголовка.</value>
</data>
<data name="Parse.Error.NoSeparator" xml:space="preserve">
<value>Не найден разделитель «=» или «:».</value>
</data>
<data name="Parse.Error.EmptyKey" xml:space="preserve">
<value>Пустой ключ.</value>
</data>
<data name="Proxies.Status.Ready" xml:space="preserve"> <data name="Proxies.Status.Ready" xml:space="preserve">
<value>В пуле {0}, живых {1}.</value> <value>В пуле {0}, живых {1}.</value>
</data> </data>
<data name="Parse.ProxyRequired" xml:space="preserve">
<value>Этому парсеру нужна рабочая прокси, а живых нет. Проверьте список прокси или разрешите прямое подключение в настройках.</value>
</data>
<data name="Parse.GoToProxies" xml:space="preserve">
<value>Открыть прокси</value>
</data>
<data name="Settings.AllowDirect" xml:space="preserve"> <data name="Settings.AllowDirect" xml:space="preserve">
<value>Разрешить сетевым парсерам работать без прокси</value> <value>Разрешить сетевым источникам работать без прокси</value>
</data> </data>
<data name="Settings.AllowDirectHint" xml:space="preserve"> <data name="Settings.AllowDirectHint" xml:space="preserve">
<value>Когда выключено, парсер, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Парсеры, читающие вставленный текст, не блокируются никогда.</value> <value>Когда выключено, источник, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Источники, читающие вставленный текст, не блокируются никогда.</value>
</data> </data>
<data name="Settings.MinimumLive" xml:space="preserve"> <data name="Settings.MinimumLive" xml:space="preserve">
<value>СКОЛЬКО ЖИВЫХ ИСКАТЬ ПРИ СТАРТЕ</value> <value>СКОЛЬКО ЖИВЫХ ИСКАТЬ ПРИ СТАРТЕ</value>
@@ -616,64 +505,64 @@
<data name="Source.own-service.Description" xml:space="preserve"> <data name="Source.own-service.Description" xml:space="preserve">
<value>Читает листинг сервиса, который вы держите сами.</value> <value>Читает листинг сервиса, который вы держите сами.</value>
</data> </data>
<data name="Parse.Error.NotAnAddress" xml:space="preserve"> <data name="Collect.Error.NotAnAddress" xml:space="preserve">
<value>«{0}» — не http- и не https-адрес.</value> <value>«{0}» — не http- и не https-адрес.</value>
</data> </data>
<data name="Parse.Error.NoEndpoint" xml:space="preserve"> <data name="Collect.Error.NoEndpoint" xml:space="preserve">
<value>Не указан адрес листинга.</value> <value>Не указан адрес листинга.</value>
</data> </data>
<data name="Parse.Error.ListingFailed" xml:space="preserve"> <data name="Collect.Error.ListingFailed" xml:space="preserve">
<value>Не удалось прочитать листинг: {0}</value> <value>Не удалось прочитать листинг: {0}</value>
</data> </data>
<data name="Parse.Error.ListingMalformed" xml:space="preserve"> <data name="Collect.Error.ListingMalformed" xml:space="preserve">
<value>Листинг не удалось разобрать: {0}</value> <value>Листинг не удалось разобрать: {0}</value>
</data> </data>
<data name="Parse.Error.SourceFailed" xml:space="preserve"> <data name="Collect.Error.SourceFailed" xml:space="preserve">
<value>Источник остановился: {0}</value> <value>Источник остановился: {0}</value>
</data> </data>
<data name="Parse.Error.NoProxy" xml:space="preserve"> <data name="Collect.Error.NoProxy" xml:space="preserve">
<value>Живых прокси нет, а прямое подключение выключено.</value> <value>Живых прокси нет, а прямое подключение выключено.</value>
</data> </data>
<data name="Parse.Error.BadRedirect" xml:space="preserve"> <data name="Collect.Error.BadRedirect" xml:space="preserve">
<value>Перенаправление на адрес, который не является http.</value> <value>Перенаправление на адрес, который не является http.</value>
</data> </data>
<data name="Parse.Error.RedirectLoop" xml:space="preserve"> <data name="Collect.Error.RedirectLoop" xml:space="preserve">
<value>Перенаправления зациклены.</value> <value>Перенаправления зациклены.</value>
</data> </data>
<data name="Parse.Error.TooManyRedirects" xml:space="preserve"> <data name="Collect.Error.TooManyRedirects" xml:space="preserve">
<value>Слишком много перенаправлений.</value> <value>Слишком много перенаправлений.</value>
</data> </data>
<data name="Parse.Error.NotMedia" xml:space="preserve"> <data name="Collect.Error.NotMedia" xml:space="preserve">
<value>В ответе не изображение и не видео.</value> <value>В ответе не изображение и не видео.</value>
</data> </data>
<data name="Parse.Error.TooLarge" xml:space="preserve"> <data name="Collect.Error.TooLarge" xml:space="preserve">
<value>Больше предельного размера.</value> <value>Больше предельного размера.</value>
</data> </data>
<data name="Parse.Error.TooSmall" xml:space="preserve"> <data name="Collect.Error.TooSmall" xml:space="preserve">
<value>Меньше нижнего порога; вероятно, трекинг-пиксель.</value> <value>Меньше нижнего порога; вероятно, трекинг-пиксель.</value>
</data> </data>
<data name="Parse.Error.Truncated" xml:space="preserve"> <data name="Collect.Error.Truncated" xml:space="preserve">
<value>Передача оборвалась.</value> <value>Передача оборвалась.</value>
</data> </data>
<data name="Parse.Error.Stalled" xml:space="preserve"> <data name="Collect.Error.Stalled" xml:space="preserve">
<value>Истекло время ожидания.</value> <value>Истекло время ожидания.</value>
</data> </data>
<data name="Parse.Error.RateLimited" xml:space="preserve"> <data name="Collect.Error.RateLimited" xml:space="preserve">
<value>Сайт попросил снизить темп.</value> <value>Сайт попросил снизить темп.</value>
</data> </data>
<data name="Parse.Error.Placeholder" xml:space="preserve"> <data name="Collect.Error.Placeholder" xml:space="preserve">
<value>Известная заглушка мёртвой ссылки.</value> <value>Известная заглушка мёртвой ссылки.</value>
</data> </data>
<data name="Parse.Error.UnsupportedType" xml:space="preserve"> <data name="Collect.Error.UnsupportedType" xml:space="preserve">
<value>Этот формат отключён.</value> <value>Этот формат отключён.</value>
</data> </data>
<data name="Parse.Error.HttpStatus" xml:space="preserve"> <data name="Collect.Error.HttpStatus" xml:space="preserve">
<value>Сайт ответил {0}.</value> <value>Сайт ответил {0}.</value>
</data> </data>
<data name="Parse.Error.RequestFailed" xml:space="preserve"> <data name="Collect.Error.RequestFailed" xml:space="preserve">
<value>Запрос не удался: {0}</value> <value>Запрос не удался: {0}</value>
</data> </data>
<data name="Parse.Error.Failed" xml:space="preserve"> <data name="Collect.Error.Failed" xml:space="preserve">
<value>Не удалось собрать.</value> <value>Не удалось собрать.</value>
</data> </data>
<data name="Enum.ShowcaseMode.None" xml:space="preserve"> <data name="Enum.ShowcaseMode.None" xml:space="preserve">
@@ -739,4 +628,10 @@
<data name="Collect.Count.Files.Many" xml:space="preserve"> <data name="Collect.Count.Files.Many" xml:space="preserve">
<value>{0} файлов</value> <value>{0} файлов</value>
</data> </data>
<data name="Dashboard.RegisteredSources" xml:space="preserve">
<value>Зарегистрированные источники</value>
</data>
<data name="Dashboard.OpenCollector" xml:space="preserve">
<value>Открыть сбор</value>
</data>
</root> </root>
@@ -4,19 +4,19 @@ using ReactiveUI;
namespace AvParser.UI.ViewModels; namespace AvParser.UI.ViewModels;
/// <summary>One row of the parse error list, with its message translated.</summary> /// <summary>One row of the collect error list, with its message translated.</summary>
/// <remarks> /// <remarks>
/// Parsers are part of the domain and produce English text plus a code. This resolves /// Sources and the fetcher are part of the domain and produce English text plus a code. This resolves
/// <c>Parse.Error.{Code}</c> and falls back to the parser's own wording, so a parser that has not /// <c>Collect.Error.{Code}</c> and falls back to the domain's own wording, so a code that has not
/// been translated yet still says something useful instead of showing a missing-key marker. /// been translated yet still says something useful instead of showing a missing-key marker.
/// </remarks> /// </remarks>
public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject public sealed class CollectErrorViewModel(ParseError error) : ReactiveObject
{ {
/// <summary>The underlying error.</summary> /// <summary>The underlying error.</summary>
public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error)); public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error));
/// <summary>1-based position of the offending record.</summary> /// <summary>1-based position of the offending item: a pasted line, or a place in a listing.</summary>
public int LineNumber => Error.LineNumber; public int Index => Error.Index;
/// <summary>Translated message.</summary> /// <summary>Translated message.</summary>
public string Text public string Text
@@ -28,7 +28,7 @@ public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject
return Error.Message; return Error.Message;
} }
var template = Localizer.Instance.GetOrDefault($"Parse.Error.{code}", Error.Message); var template = Localizer.Instance.GetOrDefault($"Collect.Error.{code}", Error.Message);
return Error.Arguments.Count == 0 return Error.Arguments.Count == 0
? template ? template
: string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]); : string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]);
@@ -206,7 +206,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
public ObservableCollection<CollectedItemViewModel> Items { get; } = []; public ObservableCollection<CollectedItemViewModel> Items { get; } = [];
/// <summary>Per-item failures. A failure never aborts the run.</summary> /// <summary>Per-item failures. A failure never aborts the run.</summary>
public ObservableCollection<ParseErrorViewModel> Errors { get; } = []; public ObservableCollection<CollectErrorViewModel> Errors { get; } = [];
/// <summary>Whether a collection is currently running.</summary> /// <summary>Whether a collection is currently running.</summary>
public bool IsBusy => _isBusy.Value; public bool IsBusy => _isBusy.Value;
@@ -485,7 +485,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
foreach (var error in errorBatch) foreach (var error in errorBatch)
{ {
Errors.Add(new ParseErrorViewModel(error)); Errors.Add(new CollectErrorViewModel(error));
} }
}); });
} }
@@ -1,4 +1,4 @@
using AvParser.Core.Parsing; using AvParser.Core.Collecting;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
using AvParser.UI.Navigation; using AvParser.UI.Navigation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -13,24 +13,25 @@ public sealed class DashboardViewModel : PageViewModel
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
/// <summary>Creates the dashboard.</summary> /// <summary>Creates the dashboard.</summary>
/// <param name="catalog">Registered parsers, shown as cards.</param> /// <param name="catalog">Registered media sources, shown as cards.</param>
/// <param name="paths">Where the app writes settings and logs.</param> /// <param name="paths">Where the app writes settings and logs.</param>
/// <param name="services"> /// <param name="services">
/// Used to resolve <see cref="INavigationService"/> at click time rather than at construction /// Used to resolve <see cref="INavigationService"/> at click time rather than at construction
/// time. Injecting it directly would be a cycle: the navigation service is built from every /// time. Injecting it directly would be a cycle: the navigation service is built from every
/// page, so a page cannot also depend on it up front. /// page, so a page cannot also depend on it up front.
/// </param> /// </param>
public DashboardViewModel(IParserCatalog catalog, IAppPaths paths, IServiceProvider services) public DashboardViewModel(IMediaSourceCatalog catalog, IAppPaths paths, IServiceProvider services)
{ {
ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))]; Sources = [.. catalog.Sources.Select(source => new MediaSourceViewModel(source))];
DataDirectory = paths.DataDirectory; DataDirectory = paths.DataDirectory;
MediaDirectory = paths.MediaDirectory;
GoToParseCommand = ReactiveCommand.Create(() => Navigate<ParseViewModel>()); GoToCollectCommand = ReactiveCommand.Create(() => Navigate<CollectViewModel>());
GoToSettingsCommand = ReactiveCommand.Create(() => Navigate<SettingsViewModel>()); GoToSettingsCommand = ReactiveCommand.Create(() => Navigate<SettingsViewModel>());
} }
@@ -40,14 +41,17 @@ public sealed class DashboardViewModel : PageViewModel
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconHome"; public override string IconKey => "IconHome";
/// <summary>Registered parsers, shown as cards.</summary> /// <summary>Registered media sources, shown as cards.</summary>
public IReadOnlyList<ParserViewModel> Parsers { get; } public IReadOnlyList<MediaSourceViewModel> Sources { get; }
/// <summary>Where settings and logs are written.</summary> /// <summary>Where settings and logs are written.</summary>
public string DataDirectory { get; } public string DataDirectory { get; }
/// <summary>Jumps to the Parse page.</summary> /// <summary>Where collected media is written.</summary>
public ReactiveCommand<RxVoid, RxVoid> GoToParseCommand { get; } public string MediaDirectory { get; }
/// <summary>Jumps to the Collect page.</summary>
public ReactiveCommand<RxVoid, RxVoid> GoToCollectCommand { get; }
/// <summary>Jumps to the Settings page.</summary> /// <summary>Jumps to the Settings page.</summary>
public ReactiveCommand<RxVoid, RxVoid> GoToSettingsCommand { get; } public ReactiveCommand<RxVoid, RxVoid> GoToSettingsCommand { get; }
@@ -1,438 +0,0 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AvParser.Core.Parsing;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Signals;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// <summary>Runs a parser over pasted text and streams the results into the UI.</summary>
/// <remarks>
/// This page exists to exercise the whole <see cref="IParser{TInput,TOutput}"/> contract —
/// streaming, progress and cancellation — rather than to be a finished feature.
/// </remarks>
public partial class ParseViewModel : PageViewModel, IDisposable
{
/// <summary>Records buffered before being pushed to the UI collection in one go.</summary>
private const int BatchSize = 512;
/// <summary>
/// Upper bound on rows shown. Beyond this the parse still completes and the count stays
/// accurate, but the list stops growing — truncation is reported, never silent.
/// </summary>
private const int MaxDisplayedRecords = 20_000;
private readonly IParserCatalog _catalog;
private readonly ISettingsService _settings;
private readonly IProxyPool _proxyPool;
private readonly IServiceProvider _services;
private readonly ILogger<ParseViewModel> _logger;
private readonly ISequencer _mainThread;
private readonly ObservableAsPropertyHelper<bool> _isBusy;
private readonly Signal<RxVoid> _proxyChanged = new();
private CancellationTokenSource? _cancellation;
/// <summary>Text to parse.</summary>
[Reactive]
public partial string InputText { get; set; }
/// <summary>Parser applied by <see cref="ParseCommand"/>.</summary>
[Reactive]
public partial ParserViewModel SelectedParser { get; set; }
/// <summary>Completion of the running parse, 0.0 to 1.0.</summary>
[Reactive]
public partial double Progress { get; set; }
/// <summary>Outcome summary shown under the toolbar; <see langword="null"/> when idle.</summary>
[Reactive]
public partial string? StatusMessage { get; set; }
/// <summary>
/// Whether the selected parser needs the network but has no working proxy to use.
/// </summary>
/// <remarks>
/// Only network parsers are gated. A parser that works on text the user pasted has nothing to
/// route, and blocking it would make the app unusable whenever the public lists are down.
/// </remarks>
[Reactive]
public partial bool IsBlockedWithoutProxy { get; set; }
/// <summary>Creates the page.</summary>
/// <param name="catalog">Available parsers.</param>
/// <param name="settings">Used to remember the selected parser.</param>
/// <param name="proxyPool">Consulted for the live count that gates network parsers.</param>
/// <param name="services">Resolves the navigation service lazily, to keep pages acyclic.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">
/// Scheduler used to marshal collection and progress updates back to the UI thread. Tests
/// pass <see cref="ImmediateSequencer.Instance"/> to make everything synchronous.
/// </param>
public ParseViewModel(
IParserCatalog catalog,
ISettingsService settings,
IProxyPool proxyPool,
IServiceProvider services,
ILogger<ParseViewModel> logger,
ISequencer? mainThread = null
)
{
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
InputText = string.Empty;
Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))];
SelectedParser = Parsers.First(parser => parser.Id == catalog.FindOrDefault(settings.Current.LastParserId).Id);
// The pool changes on every lease outcome and on every probe, so coalesce before
// re-evaluating whether the parser is allowed to run.
_proxyPool.Changed += OnProxyPoolChanged;
_proxyChanged
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => RefreshProxyGate());
RefreshProxyGate();
var canParse = this.WhenAnyValue(
x => x.InputText,
x => x.IsBlockedWithoutProxy,
static (text, blocked) => (text, blocked)
)
.Select(static state => !string.IsNullOrWhiteSpace(state.text) && !state.blocked)
.DistinctUntilChanged();
ParseCommand = ReactiveCommand.CreateFromTask(RunParseAsync, canParse, _mainThread);
GoToProxiesCommand = ReactiveCommand.Create(
() => _services.GetRequiredService<INavigationService>().NavigateTo<ProxiesViewModel>(),
outputScheduler: _mainThread
);
_isBusy = ParseCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread);
CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), ParseCommand.IsExecuting, _mainThread);
ClearCommand = ReactiveCommand.Create(
() =>
{
InputText = string.Empty;
ClearResults();
StatusMessage = null;
Progress = 0d;
},
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
LoadSampleCommand = ReactiveCommand.Create(
() => InputText = SampleFor(SelectedParser.Id),
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
GenerateLargeSampleCommand = ReactiveCommand.Create(
() => InputText = LargeSampleFor(SelectedParser.Id),
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
// Remember the parser choice; the debounced settings service coalesces the writes.
// Switching parser can also change whether the gate applies, since only network parsers
// are gated.
this.WhenAnyValue(x => x.SelectedParser)
.Where(static parser => parser is not null)
.Subscribe(parser =>
{
_settings.Update(current => current with { LastParserId = parser.Id });
RefreshProxyGate();
});
_settings.Changes.Subscribe(_ => RefreshProxyGate());
// Errors surfacing from any command must not tear the process down.
ParseCommand.ThrownExceptions.Subscribe(OnCommandFailed);
}
/// <inheritdoc />
public override string TitleKey => "Page.Parse";
/// <inheritdoc />
public override string IconKey => "IconDocument";
/// <summary>Every registered parser, for the picker.</summary>
public IReadOnlyList<ParserViewModel> Parsers { get; }
/// <summary>Successfully parsed records, capped at <see cref="MaxDisplayedRecords"/>.</summary>
public ObservableCollection<ParsedRecord> Records { get; } = [];
/// <summary>Per-line failures. A failure never aborts the parse.</summary>
public ObservableCollection<ParseErrorViewModel> Errors { get; } = [];
/// <summary>Whether a parse is currently running.</summary>
public bool IsBusy => _isBusy.Value;
/// <summary>Runs <see cref="SelectedParser"/> over <see cref="InputText"/>.</summary>
public ReactiveCommand<RxVoid, RxVoid> ParseCommand { get; }
/// <summary>Cancels the running parse.</summary>
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
/// <summary>Clears the input and all results.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCommand { get; }
/// <summary>Fills the input with a small example for the selected parser.</summary>
public ReactiveCommand<RxVoid, string> LoadSampleCommand { get; }
/// <summary>Fills the input with 50 000 rows, so progress and cancellation are observable.</summary>
public ReactiveCommand<RxVoid, string> GenerateLargeSampleCommand { get; }
/// <summary>Takes the user to the page where the proxy problem can be fixed.</summary>
public ReactiveCommand<RxVoid, RxVoid> GoToProxiesCommand { get; }
/// <summary>Explains why parsing is blocked.</summary>
public string ProxyRequiredMessage => Localizer.Instance["Parse.ProxyRequired"];
/// <summary>Re-evaluates the proxy gate. Exposed so tests can drive it without waiting.</summary>
public void RefreshProxyGate()
{
var settings = _settings.Current;
IsBlockedWithoutProxy =
SelectedParser.Parser.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0;
}
private void OnProxyPoolChanged(object? sender, EventArgs e) => _proxyChanged.OnNext(RxVoid.Default);
private async Task RunParseAsync(CancellationToken commandToken)
{
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken);
_cancellation = cancellation;
var parser = SelectedParser.Parser;
var input = InputText;
var token = cancellation.Token;
ClearResults();
Progress = 0d;
StatusMessage = null;
var recordBuffer = new List<ParsedRecord>(BatchSize);
var errorBuffer = new List<ParseError>(16);
var progress = new Progress<ParseProgress>(value => OnUi(() => Progress = value.Fraction));
var stopwatch = Stopwatch.StartNew();
var succeeded = 0;
var failed = 0;
var truncated = false;
var cancelled = false;
try
{
await foreach (var outcome in parser.ParseAsync(input, progress, token).ConfigureAwait(false))
{
if (outcome.IsSuccess)
{
succeeded++;
if (succeeded <= MaxDisplayedRecords)
{
recordBuffer.Add(outcome.Value!);
}
else
{
truncated = true;
}
}
else
{
failed++;
errorBuffer.Add(outcome.Error);
}
if (recordBuffer.Count >= BatchSize)
{
FlushBuffers(recordBuffer, errorBuffer);
}
}
}
catch (OperationCanceledException)
{
cancelled = true;
}
finally
{
_cancellation = null;
FlushBuffers(recordBuffer, errorBuffer);
stopwatch.Stop();
}
var summary = BuildSummary(succeeded, failed, stopwatch.Elapsed, truncated, cancelled);
OnUi(() =>
{
StatusMessage = summary;
Progress = cancelled ? Progress : 1d;
});
_logger.LogInformation(
"Parsed with {Parser}: {Succeeded} record(s), {Failed} error(s) in {Elapsed}",
parser.Id,
succeeded,
failed,
stopwatch.Elapsed
);
}
/// <summary>
/// Builds the outcome line out of translated fragments.
/// </summary>
/// <remarks>
/// Assembled from plural-aware pieces rather than one format string per case: Russian needs
/// three forms for a counted noun, so "{0} records" with an English plural glued on cannot be
/// translated correctly.
/// </remarks>
internal static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled)
{
var loc = Localizer.Instance;
var records = loc.Plural("Parse.Count.Records", succeeded);
var milliseconds = elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture);
var text = loc.Format(cancelled ? "Parse.Status.Cancelled" : "Parse.Status.Done", records, milliseconds);
if (failed > 0)
{
text = loc.Format("Parse.Status.WithErrors", text.TrimEnd('.'), loc.Plural("Parse.Count.Errors", failed));
}
if (truncated)
{
text += " " + loc.Format("Parse.Status.Truncated", loc.Plural("Parse.Count.Records", MaxDisplayedRecords));
}
return text;
}
private void FlushBuffers(List<ParsedRecord> records, List<ParseError> errors)
{
if (records.Count == 0 && errors.Count == 0)
{
return;
}
// Copy before clearing: the scheduled callback may run after the loop has refilled these.
var recordBatch = records.ToArray();
var errorBatch = errors.ToArray();
records.Clear();
errors.Clear();
OnUi(() =>
{
foreach (var record in recordBatch)
{
Records.Add(record);
}
foreach (var error in errorBatch)
{
Errors.Add(new ParseErrorViewModel(error));
}
});
}
/// <inheritdoc />
/// <remarks>The pool is a singleton and would otherwise keep this page alive for the process.</remarks>
public void Dispose()
{
_proxyPool.Changed -= OnProxyPoolChanged;
_proxyChanged.Dispose();
GC.SuppressFinalize(this);
}
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
this.RaisePropertyChanged(nameof(ProxyRequiredMessage));
// The summary and the listed errors were both rendered in the previous language.
StatusMessage = null;
foreach (var error in Errors)
{
error.Refresh();
}
}
private void ClearResults()
{
Records.Clear();
Errors.Clear();
}
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Parse failed");
OnUi(() => StatusMessage = Localizer.Instance.Format("Parse.Status.Failed", exception.Message));
}
/// <summary>Marshals a mutation onto the UI thread; the parse loop runs on the thread pool.</summary>
private void OnUi(Action action) => _mainThread.Schedule(action);
private static string SampleFor(string parserId) =>
parserId switch
{
"key-value" => """
# Sample configuration
host = localhost
port: 8080
enabled = true
name = av-parser
""",
_ => """
id,name,role
1,Ada Lovelace,Analyst
2,Grace Hopper,Compiler
3,Alan Turing,Cryptanalyst
""",
};
private static string LargeSampleFor(string parserId)
{
const int rows = 50_000;
var text = new StringBuilder(rows * 24);
if (parserId == "key-value")
{
for (var i = 0; i < rows; i++)
{
text.Append("key").Append(i).Append(" = value").Append(i).Append('\n');
}
return text.ToString();
}
text.Append("id,name,score\n");
for (var i = 0; i < rows; i++)
{
text.Append(i).Append(",item-").Append(i).Append(',').Append(i % 100).Append('\n');
}
return text.ToString();
}
}
@@ -1,43 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.UI.Localization;
using ReactiveUI;
namespace AvParser.UI.ViewModels;
/// <summary>A parser paired with its translated name and description.</summary>
/// <remarks>
/// The domain deliberately knows nothing about languages, so <see cref="ITextParser"/> carries
/// English text. This looks the id up as <c>Parser.{id}.Name</c> and falls back to what the parser
/// itself says, which keeps the "add a parser = one registration line" promise intact: a new
/// parser works untranslated instead of rendering a missing-key marker.
/// </remarks>
public sealed class ParserViewModel : ReactiveObject
{
/// <summary>Wraps a parser.</summary>
public ParserViewModel(ITextParser parser)
{
Parser = parser ?? throw new ArgumentNullException(nameof(parser));
Localizer.Instance.LanguageChanged += OnLanguageChanged;
}
/// <summary>The parser itself.</summary>
public ITextParser Parser { get; }
/// <summary>Stable identifier.</summary>
public string Id => Parser.Id;
/// <summary>Translated name, or the parser's own when untranslated.</summary>
public string Name => Localizer.Instance.GetOrDefault($"Parser.{Id}.Name", Parser.DisplayName);
/// <summary>Translated description, or the parser's own when untranslated.</summary>
public string Description => Localizer.Instance.GetOrDefault($"Parser.{Id}.Description", Parser.Description);
/// <inheritdoc />
public override string ToString() => Name;
private void OnLanguageChanged(object? sender, EventArgs e)
{
this.RaisePropertyChanged(nameof(Name));
this.RaisePropertyChanged(nameof(Description));
}
}
+2 -2
View File
@@ -284,10 +284,10 @@
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0"> <ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ParseErrorViewModel"> <DataTemplate x:DataType="vm:CollectErrorViewModel">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Border Classes="chip danger" VerticalAlignment="Center" MinWidth="44"> <Border Classes="chip danger" VerticalAlignment="Center" MinWidth="44">
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" /> <TextBlock Classes="mono caption" Text="{Binding Index}" HorizontalAlignment="Center" />
</Border> </Border>
<TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap" /> <TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
+16 -9
View File
@@ -14,15 +14,15 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="{l:Loc Dashboard.RegisteredParsers}" /> <TextBlock Classes="subtitle" Text="{l:Loc Dashboard.RegisteredSources}" />
<ItemsControl ItemsSource="{Binding Parsers}"> <ItemsControl ItemsSource="{Binding Sources}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" /> <WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ParserViewModel"> <DataTemplate x:DataType="vm:MediaSourceViewModel">
<Border Classes="card interactive" Width="320" Margin="0,0,12,12"> <Border Classes="card interactive" Width="320" Margin="0,0,12,12">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Classes="subtitle" Text="{Binding Name}" /> <TextBlock Classes="subtitle" Text="{Binding Name}" />
@@ -40,10 +40,10 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="{l:Loc Dashboard.GetStarted}" /> <TextBlock Classes="subtitle" Text="{l:Loc Dashboard.GetStarted}" />
<StackPanel Orientation="Horizontal" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="12">
<Button Classes="primary" Command="{Binding GoToParseCommand}"> <Button Classes="primary" Command="{Binding GoToCollectCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconDownload}" />
<TextBlock Text="{l:Loc Dashboard.OpenParser}" /> <TextBlock Text="{l:Loc Dashboard.OpenCollector}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Command="{Binding GoToSettingsCommand}"> <Button Command="{Binding GoToSettingsCommand}">
@@ -56,9 +56,16 @@
</StackPanel> </StackPanel>
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="6"> <StackPanel Spacing="12">
<TextBlock Classes="caption" Text="{l:Loc Dashboard.DataDirectory}" /> <StackPanel Spacing="6">
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" /> <TextBlock Classes="caption" Text="{l:Loc Dashboard.DataDirectory}" />
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="{l:Loc Settings.MediaDirectory}" />
<SelectableTextBlock Classes="mono" Text="{Binding MediaDirectory}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
-251
View File
@@ -1,251 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters"
xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core"
x:Class="AvParser.UI.Views.ParseView"
x:DataType="vm:ParseViewModel"
>
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- ===== Toolbar ===== -->
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
<StackPanel Spacing="12">
<WrapPanel Orientation="Horizontal">
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="240">
<TextBlock Classes="caption" Text="{l:Loc Parse.Parser}" />
<ComboBox
ItemsSource="{Binding Parsers}"
SelectedItem="{Binding SelectedParser}"
IsEnabled="{Binding IsBusy, Converter={x:Static conv:AppConverters.Not}}"
HorizontalAlignment="Stretch"
>
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ParserViewModel">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Classes="caption" Text="{l:Loc Parse.Actions}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding ParseCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
<TextBlock Text="{l:Loc Parse.Run}" />
</StackPanel>
</Button>
<Button Classes="destructive" Command="{Binding CancelCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconStop}" />
<TextBlock Text="{l:Loc Parse.Cancel}" />
</StackPanel>
</Button>
<Button Command="{Binding LoadSampleCommand}" ToolTip.Tip="{l:Loc Parse.SampleTip}">
<TextBlock Text="{l:Loc Parse.Sample}" />
</Button>
<Button Command="{Binding GenerateLargeSampleCommand}" ToolTip.Tip="{l:Loc Parse.LargeSampleTip}">
<TextBlock Text="{l:Loc Parse.LargeSample}" />
</Button>
<Button Classes="icon" Command="{Binding ClearCommand}" ToolTip.Tip="{l:Loc Parse.ClearTip}">
<PathIcon Classes="glyph" Data="{DynamicResource IconBroom}" />
</Button>
</StackPanel>
</StackPanel>
</WrapPanel>
<TextBlock Classes="muted" Text="{Binding SelectedParser.Description}" />
</StackPanel>
</Border>
<!-- ===== Blocked without a proxy ===== -->
<Border
x:Name="ProxyGateBanner"
Grid.Row="1"
Classes="card"
Margin="0,0,0,12"
Background="{DynamicResource AppDangerSoftBrush}"
BorderBrush="{DynamicResource AppDangerBrush}"
IsVisible="{Binding IsBlockedWithoutProxy}"
>
<DockPanel LastChildFill="True">
<Button
DockPanel.Dock="Right"
Classes="primary"
Margin="16,0,0,0"
VerticalAlignment="Center"
Command="{Binding GoToProxiesCommand}"
>
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconShield}" />
<TextBlock Text="{l:Loc Parse.GoToProxies}" />
</StackPanel>
</Button>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<PathIcon
Classes="glyph"
Data="{DynamicResource IconAlert}"
Foreground="{DynamicResource AppDangerBrush}"
VerticalAlignment="Center"
/>
<TextBlock Text="{Binding ProxyRequiredMessage}" TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel>
</DockPanel>
</Border>
<!-- ===== Progress and status ===== -->
<StackPanel Grid.Row="2" Spacing="8" Margin="0,0,0,12">
<ProgressBar
Minimum="0"
Maximum="1"
Value="{Binding Progress}"
IsIndeterminate="False"
IsVisible="{Binding IsBusy}"
Height="4"
/>
<TextBlock
Classes="muted"
Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
/>
</StackPanel>
<!-- ===== Input and results ===== -->
<Grid Grid.Row="3" ColumnDefinitions="*,8,1.4*">
<Border Grid.Column="0" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<TextBlock Classes="caption" Text="{l:Loc Parse.Input}" />
</Border>
<TextBox
Text="{Binding InputText}"
AcceptsReturn="True"
VerticalContentAlignment="Top"
AcceptsTab="True"
TextWrapping="NoWrap"
PlaceholderText="{l:Loc Parse.InputPlaceholder}"
BorderThickness="0"
Background="Transparent"
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="{DynamicResource FontSizeBody}"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
/>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="Transparent" />
<Grid Grid.Column="2" RowDefinitions="*,Auto">
<Border Grid.Row="0" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="caption" Text="{l:Loc Parse.Records}" VerticalAlignment="Center" />
<Border Classes="chip">
<TextBlock Classes="mono caption" Text="{Binding Records.Count}" />
</Border>
</StackPanel>
</Border>
<ListBox
ItemsSource="{Binding Records}"
Background="Transparent"
BorderThickness="0"
SelectionMode="Single"
>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="parsing:ParsedRecord">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Classes="chip" VerticalAlignment="Center" MinWidth="44">
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" />
</Border>
<ItemsControl ItemsSource="{Binding Fields}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="parsing:ParsedField">
<Border Classes="chip accent" Margin="0,2,6,2">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="caption" Text="{Binding Name}" Opacity="0.7" />
<TextBlock Classes="mono caption" Text="{Binding Value}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<Border
Grid.Row="1"
Classes="card"
Margin="0,8,0,0"
Padding="0"
MaxHeight="180"
IsVisible="{Binding Errors.Count, Converter={x:Static conv:AppConverters.IsPositive}}"
>
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon
Classes="glyph"
Data="{DynamicResource IconAlert}"
Foreground="{DynamicResource AppDangerBrush}"
VerticalAlignment="Center"
/>
<TextBlock Classes="caption" Text="{l:Loc Parse.Errors}" VerticalAlignment="Center" />
<Border Classes="chip danger">
<TextBlock Classes="mono caption" Text="{Binding Errors.Count}" />
</Border>
</StackPanel>
</Border>
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ParseErrorViewModel">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Classes="chip danger" VerticalAlignment="Center" MinWidth="44">
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" />
</Border>
<TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</Grid>
</Grid>
</Grid>
</UserControl>
-13
View File
@@ -1,13 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace AvParser.UI.Views;
/// <summary>Input, toolbar and streamed parse results.</summary>
public partial class ParseView : UserControl
{
/// <summary>Creates the view.</summary>
public ParseView() => InitializeComponent();
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
@@ -1,114 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class DelimitedTextParserTests
{
private readonly DelimitedTextParser _parser = new();
[Fact]
public async Task Parses_header_and_rows()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1,Ada\n2,Grace");
errors.ShouldBeEmpty();
records.Count.ShouldBe(2);
records[0].Field("id").ShouldBe("1");
records[0].Field("name").ShouldBe("Ada");
records[1].LineNumber.ShouldBe(3);
}
[Theory]
[InlineData("a;b\n1;2")]
[InlineData("a\tb\n1\t2")]
[InlineData("a|b\n1|2")]
public async Task Detects_the_delimiter_from_the_header(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Fields.Count.ShouldBe(2);
}
[Fact]
public async Task Reports_a_field_count_mismatch_without_aborting()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1\n2,Grace");
// The bad line becomes an error; the good line after it still parses.
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
records.ShouldHaveSingleItem().Field("name").ShouldBe("Grace");
}
[Fact]
public async Task Skips_blank_lines_and_comments()
{
var (records, errors) = await _parser.CollectAsync("# a comment\nid,name\n\n1,Ada\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Trims_surrounding_whitespace()
{
var (records, _) = await _parser.CollectAsync("id , name\n 1 , Ada ");
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Reports_an_error_when_there_is_no_header()
{
var (records, errors) = await _parser.CollectAsync("\n\n");
records.ShouldBeEmpty();
errors.ShouldHaveSingleItem().Message.ShouldContain("header");
}
[Fact]
public async Task Reports_progress_reaching_completion()
{
var reports = new List<ParseProgress>();
// Not Progress<T>: it posts to the captured synchronization context, so the reports would
// arrive after the assertions. A direct IProgress<T> keeps the test deterministic.
await foreach (
var _ in _parser.ParseAsync(
ParserTestExtensions.DelimitedDocument(900),
new SynchronousProgress<ParseProgress>(reports.Add),
TestContext.Current.CancellationToken
)
) { }
reports.ShouldNotBeEmpty();
reports[^1].Fraction.ShouldBe(1d);
}
[Fact]
public async Task Honours_cancellation()
{
using var cancellation = new CancellationTokenSource();
var act = async () =>
{
await foreach (
var _ in _parser.ParseAsync(ParserTestExtensions.DelimitedDocument(20_000), null, cancellation.Token)
)
{
await cancellation.CancelAsync();
}
};
await act.ShouldThrowAsync<OperationCanceledException>();
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("no delimiters here", false)]
[InlineData("a,b", true)]
public void CanParse_checks_for_a_delimiter(string input, bool expected) =>
_parser.CanParse(input).ShouldBe(expected);
}
@@ -1,64 +0,0 @@
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class KeyValueTextParserTests
{
private readonly KeyValueTextParser _parser = new();
[Theory]
[InlineData("host = localhost")]
[InlineData("host: localhost")]
public async Task Accepts_both_separators(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
var record = records.ShouldHaveSingleItem();
record.Field("Key").ShouldBe("host");
record.Field("Value").ShouldBe("localhost");
}
[Fact]
public async Task Splits_on_the_first_separator_only()
{
var (records, _) = await _parser.CollectAsync("url = https://example.com:8080/path");
records.ShouldHaveSingleItem().Field("Value").ShouldBe("https://example.com:8080/path");
}
[Fact]
public async Task Reports_lines_without_a_separator()
{
var (records, errors) = await _parser.CollectAsync("host = localhost\ngarbage\nport = 80");
records.Count.ShouldBe(2);
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
}
[Fact]
public async Task Reports_an_empty_key()
{
var (_, errors) = await _parser.CollectAsync("= orphan");
errors.ShouldHaveSingleItem().Message.ShouldContain("separator");
}
[Fact]
public async Task Skips_comments_and_blank_lines()
{
var (records, errors) = await _parser.CollectAsync("# comment\n\nhost = localhost\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Key").ShouldBe("host");
}
[Fact]
public async Task Allows_an_empty_value()
{
var (records, errors) = await _parser.CollectAsync("host =");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Value").ShouldBe(string.Empty);
}
}
@@ -1,37 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class ParserCatalogTests
{
private static IParserCatalog Catalog() => new ParserCatalog([new KeyValueTextParser(), new DelimitedTextParser()]);
[Fact]
public void Orders_parsers_by_display_name_regardless_of_registration_order() =>
Catalog().Parsers.Select(p => p.Id).ShouldBe(["delimited", "key-value"]);
[Fact]
public void Finds_a_parser_by_id_ignoring_case() => Catalog().Find("KEY-VALUE")!.Id.ShouldBe("key-value");
[Fact]
public void Returns_null_for_an_unknown_id() => Catalog().Find("nope").ShouldBeNull();
[Fact]
public void Falls_back_to_the_default_for_an_unknown_id()
{
var catalog = Catalog();
catalog.FindOrDefault("nope").ShouldBeSameAs(catalog.DefaultParser);
catalog.FindOrDefault(null).ShouldBeSameAs(catalog.DefaultParser);
}
[Fact]
public void Rejects_an_empty_registration() => Should.Throw<ArgumentException>(() => new ParserCatalog([]));
[Fact]
public void Rejects_duplicate_ids() =>
Should
.Throw<ArgumentException>(() => new ParserCatalog([new DelimitedTextParser(), new DelimitedTextParser()]))
.Message.ShouldContain("Duplicate");
}
@@ -1,47 +0,0 @@
using AvParser.Core.Parsing;
namespace AvParser.Core.Tests;
/// <summary>Collection helpers so the tests read as assertions rather than as loops.</summary>
internal static class ParserTestExtensions
{
/// <summary>
/// Drains a parse into memory, using the ambient test cancellation token.
/// </summary>
/// <remarks>
/// Deliberately takes no <see cref="CancellationToken"/>: every call site would otherwise have
/// to pass <c>TestContext.Current.CancellationToken</c> to satisfy xUnit1051. Cancellation
/// behaviour is covered by driving <see cref="IParser{TInput,TOutput}.ParseAsync"/> directly.
/// </remarks>
internal static async Task<(List<ParsedRecord> Records, List<ParseError> Errors)> CollectAsync(
this ITextParser parser,
string input,
IProgress<ParseProgress>? progress = null
)
{
var records = new List<ParsedRecord>();
var errors = new List<ParseError>();
await foreach (var outcome in parser.ParseAsync(input, progress, TestContext.Current.CancellationToken))
{
if (outcome.IsSuccess)
{
records.Add(outcome.Value!);
}
else
{
errors.Add(outcome.Error);
}
}
return (records, errors);
}
/// <summary>Reads a field by name, failing the test if it is absent.</summary>
internal static string Field(this ParsedRecord record, string name) =>
record[name] ?? throw new InvalidOperationException($"Field '{name}' is missing.");
/// <summary>Builds a delimited document with a header plus <paramref name="rows"/> data rows.</summary>
internal static string DelimitedDocument(int rows) =>
string.Join('\n', Enumerable.Range(0, rows + 1).Select(i => i == 0 ? "id,name" : $"{i},row{i}"));
}
@@ -56,7 +56,6 @@ public sealed class JsonSettingsServiceTests : IDisposable
""" """
{ {
"theme": "System", "theme": "System",
"lastParserId": "delimited",
"windowWidth": 1280, "windowWidth": 1280,
"windowHeight": 800, "windowHeight": 800,
"windowMaximized": false, "windowMaximized": false,
@@ -76,7 +75,6 @@ public sealed class JsonSettingsServiceTests : IDisposable
settings.Language.ShouldBe(AppLanguage.System); settings.Language.ShouldBe(AppLanguage.System);
// And the values the file did carry must survive. // And the values the file did carry must survive.
settings.LastParserId.ShouldBe("delimited");
settings.MinimumLogLevel.ShouldBe("Information"); settings.MinimumLogLevel.ShouldBe("Information");
} }
@@ -19,7 +19,7 @@ public sealed class LocalizationViewTests : IDisposable
private static (ShellView View, ShellViewModel ViewModel) ShowShell() private static (ShellView View, ShellViewModel ViewModel) ShowShell()
{ {
var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]); var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Collect")]);
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance); var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
var view = new ShellView { DataContext = viewModel }; var view = new ShellView { DataContext = viewModel };
@@ -49,14 +49,14 @@ public sealed class LocalizationViewTests : IDisposable
var (view, _) = ShowShell(); var (view, _) = ShowShell();
var english = RailLabels(view); var english = RailLabels(view);
english.ShouldBe(["Dashboard", "Parse"]); english.ShouldBe(["Dashboard", "Collect"]);
Localizer.Instance.SetLanguage(AppLanguage.Russian); Localizer.Instance.SetLanguage(AppLanguage.Russian);
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
// The same controls, not a rebuilt tree: this is the whole point of binding through the // The same controls, not a rebuilt tree: this is the whole point of binding through the
// localizer's indexer rather than resolving strings once at load. // localizer's indexer rather than resolving strings once at load.
RailLabels(view).ShouldBe(["Обзор", "Разбор"]); RailLabels(view).ShouldBe(["Обзор", "Сбор"]);
} }
[AvaloniaFact] [AvaloniaFact]
@@ -1,121 +0,0 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class ParseViewTests
{
private static (ParseView View, ParseViewModel ViewModel, Window Window) ShowPage(params ITextParser[] extra)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extra]);
var lastParser = extra.Length > 0 ? extra[0].Id : null;
var viewModel = new ParseViewModel(
catalog,
new FakeSettingsService(new AppSettings { LastParserId = lastParser }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new EmptyServiceProvider(),
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
);
var view = new ParseView { DataContext = viewModel };
var window = new Window
{
Width = 1400,
Height = 900,
Content = view,
};
window.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel, window);
}
private static Border Banner(ParseView view) => view.FindControl<Border>("ProxyGateBanner").ShouldNotBeNull();
[AvaloniaFact]
public void No_banner_is_shown_for_a_parser_that_needs_no_network()
{
var (view, viewModel, _) = ShowPage();
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_network_parser_puts_the_banner_on_screen()
{
// Rendered rather than asserted on the view model: an IsVisible binding that never fires
// leaves the page silently unhelpful, which is exactly the failure this guards.
var (view, viewModel, _) = ShowPage(new NetworkParser());
Dispatcher.UIThread.RunJobs();
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
Banner(view).IsEffectivelyVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void The_banner_offers_a_way_to_the_proxies_page()
{
var (view, viewModel, _) = ShowPage(new NetworkParser());
Dispatcher.UIThread.RunJobs();
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
button.Command.ShouldBeSameAs(viewModel.GoToProxiesCommand);
}
[AvaloniaFact]
public void A_blocked_page_will_not_run_the_parser()
{
var (view, viewModel, _) = ShowPage(new NetworkParser());
viewModel.InputText = "anything";
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
.OfType<Button>()
.First(candidate => ReferenceEquals(candidate.Command, viewModel.ParseCommand));
run.IsEffectivelyEnabled.ShouldBeFalse();
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class NetworkParser : ITextParser
{
public string Id => "network";
public string DisplayName => "Network parser";
public string Description => "Fetches something";
public bool RequiresNetwork => true;
public bool CanParse(string input) => true;
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
}
@@ -0,0 +1,65 @@
using System.Reflection;
using System.Resources;
using System.Text.RegularExpressions;
using AvParser.UI.Localization;
namespace AvParser.UI.Tests;
/// <summary>
/// Every key the XAML asks for must actually exist.
/// </summary>
/// <remarks>
/// The parity test compares the two resource files against each other, so a key missing from
/// <b>both</b> passes it happily and then renders as <c>!Some.Key!</c> in the running app. That
/// happened during a rename: the XAML was repointed at new key names and the keys themselves were
/// never added. Only a screenshot caught it, which is too late and too manual.
/// </remarks>
public sealed partial class LocalizationCoverageTests
{
[GeneratedRegex(@"\{l:Loc\s+([A-Za-z0-9_.\-]+)\s*\}", RegexOptions.Compiled)]
private static partial Regex LocMarkup();
private static readonly ResourceManager Resources = new(
"AvParser.UI.Localization.Strings",
typeof(Localizer).GetTypeInfo().Assembly
);
/// <summary>Walks up to the repository root, which the test binary sits several levels below.</summary>
private static DirectoryInfo RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AvParser.slnx")))
{
directory = directory.Parent;
}
return directory ?? throw new InvalidOperationException("Could not find the repository root.");
}
[Fact]
public void Every_key_used_in_xaml_exists()
{
var views = Path.Combine(RepositoryRoot().FullName, "src", "AvParser.UI");
var files = Directory.GetFiles(views, "*.axaml", SearchOption.AllDirectories);
files.ShouldNotBeEmpty();
var missing = new SortedSet<string>(StringComparer.Ordinal);
foreach (var file in files)
{
foreach (Match match in LocMarkup().Matches(File.ReadAllText(file)))
{
var key = match.Groups[1].Value;
if (Resources.GetString(key, Localizer.Instance.Culture) is null)
{
missing.Add($"{key} ({Path.GetFileName(file)})");
}
}
}
missing.ShouldBeEmpty();
}
}
+4 -4
View File
@@ -128,9 +128,9 @@ public sealed class LocalizationTests : IDisposable
{ {
Localizer.Instance.SetLanguage(AppLanguage.Russian); Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance.Plural("Parse.Count.Records", 1).ShouldBe("1 запись"); Localizer.Instance.Plural("Collect.Count.Images", 1).ShouldBe("1 изображение");
Localizer.Instance.Plural("Parse.Count.Records", 3).ShouldBe("3 записи"); Localizer.Instance.Plural("Collect.Count.Images", 3).ShouldBe("3 изображения");
Localizer.Instance.Plural("Parse.Count.Records", 7).ShouldBe("7 записей"); Localizer.Instance.Plural("Collect.Count.Images", 7).ShouldBe("7 изображений");
} }
[Fact] [Fact]
@@ -195,7 +195,7 @@ public sealed class LocalizationTests : IDisposable
[Fact] [Fact]
public void A_fallback_is_used_when_a_key_is_absent() => public void A_fallback_is_used_when_a_key_is_absent() =>
Localizer.Instance.GetOrDefault("Parser.brand-new.Name", "Brand new").ShouldBe("Brand new"); Localizer.Instance.GetOrDefault("Source.brand-new.Name", "Brand new").ShouldBe("Brand new");
[Theory] [Theory]
[InlineData(AppLanguage.English, "en")] [InlineData(AppLanguage.English, "en")]
@@ -1,278 +0,0 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(
AppSettings? settings = null,
IProxyPool? proxyPool = null,
params ITextParser[] extraParsers
)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extraParsers]);
var settingsService = new FakeSettingsService(settings);
return (
new ParseViewModel(
catalog,
settingsService,
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new EmptyServiceProvider(),
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
),
settingsService
);
}
/// <summary>A container that resolves nothing; the page only reaches for navigation on click.</summary>
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
/// <summary>Stands in for the kind of parser the proxy gate exists for.</summary>
private sealed class NetworkParser : ITextParser
{
public string Id => "network";
public string DisplayName => "Network parser";
public string Description => "Fetches something";
public bool RequiresNetwork => true;
public bool CanParse(string input) => true;
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
yield break;
}
}
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
/// <summary>
/// Waits until a command's gate opens.
/// </summary>
/// <remarks>
/// <c>Execute()</c> completing and <c>IsExecuting</c> going false are not the same instant:
/// the latter is published on the output scheduler. Commands gated on another command's
/// IsExecuting therefore need the gate observed, not assumed — asserting straight after the
/// await failed intermittently under load.
/// </remarks>
private static Task WhenExecutable<TParam, TResult>(ReactiveUI.ReactiveCommand<TParam, TResult> command) =>
command.CanExecute.Where(static can => can).Take(1).ToTask(TestContext.Current.CancellationToken);
[Fact]
public void Restores_the_last_used_parser()
{
var (page, _) = Build(new AppSettings { LastParserId = "key-value" });
page.SelectedParser.Id.ShouldBe("key-value");
}
[Fact]
public void Falls_back_to_the_default_parser_for_an_unknown_id()
{
var (page, _) = Build(new AppSettings { LastParserId = "removed-in-a-past-version" });
page.SelectedParser.Id.ShouldBe("delimited");
}
[Fact]
public void Remembers_the_selected_parser()
{
var (page, settings) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
settings.Current.LastParserId.ShouldBe("key-value");
}
[Fact]
public void A_local_parser_runs_even_with_no_proxy_at_all()
{
// Only network parsers are gated. Blocking a parser that works on pasted text would make
// the app unusable whenever the public lists are down, for no benefit at all.
var (page, _) = Build();
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void A_network_parser_is_blocked_while_nothing_is_live()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network" },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.IsBlockedWithoutProxy.ShouldBeTrue();
page.InputText = "anything";
canExecute.ShouldBeFalse();
}
[Fact]
public async Task A_network_parser_runs_once_a_proxy_answers()
{
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080);
var pool = new ProxyPool(
[new FakeProxySource([endpoint])],
new FakeProxyProbe().Set(endpoint, alive: true),
new ProxyOptions()
);
var (page, _) = Build(new AppSettings { LastParserId = "network" }, pool, new NetworkParser());
page.IsBlockedWithoutProxy.ShouldBeTrue();
await pool.RefreshAsync(TestContext.Current.CancellationToken);
await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken);
page.RefreshProxyGate();
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void Allowing_direct_connections_lifts_the_gate()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network", AllowDirectConnection = true },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Fact]
public void Switching_away_from_a_network_parser_lifts_the_gate()
{
var (page, _) = Build(
new AppSettings { LastParserId = "network" },
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new NetworkParser()
);
page.SelectedParser = page.Parsers.Single(parser => parser.Id == "delimited");
page.IsBlockedWithoutProxy.ShouldBeFalse();
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("id,name\n1,Ada", true)]
public void Parsing_requires_non_blank_input(string input, bool expected)
{
var (page, _) = Build();
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.InputText = input;
canExecute.ShouldBe(expected);
}
[Fact]
public async Task Parsing_fills_the_records_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(2);
page.Errors.ShouldBeEmpty();
page.Progress.ShouldBe(1d);
page.StatusMessage!.ShouldContain("2 records");
}
[Fact]
public async Task Bad_lines_land_in_the_errors_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
page.Errors.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("1 error");
}
[Fact]
public async Task A_second_run_replaces_the_previous_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
}
[Fact]
public async Task Cancelling_stops_the_run_and_says_so()
{
var (page, _) = Build();
page.InputText = string.Join(
'\n',
Enumerable.Range(0, 200_000).Select(i => i == 0 ? "id,name" : $"{i},row{i}")
);
var run = RunAsync(page);
page.CancelCommand.Execute().Subscribe(_ => { });
await run;
page.StatusMessage!.ShouldStartWith("Cancelled");
}
[Fact]
public void Loading_the_sample_matches_the_selected_parser()
{
var (page, _) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
page.LoadSampleCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldContain("host = localhost");
}
[Fact]
public async Task Clearing_empties_the_input_and_the_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
await WhenExecutable(page.ClearCommand);
await page.ClearCommand.Execute().ToTask(TestContext.Current.CancellationToken);
page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty();
page.StatusMessage.ShouldBeNull();
page.Progress.ShouldBe(0d);
}
}