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:
co-authored by
Claude Opus 5
parent
fe62bcf53f
commit
f8744c930a
@@ -34,17 +34,28 @@ dotnet csharpier check .
|
||||
- **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь
|
||||
по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как
|
||||
только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или
|
||||
`IStorageProvider` внутри парсера.
|
||||
`IStorageProvider` внутри источника.
|
||||
- **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не
|
||||
подтягивая `Program.cs`, Serilog и контейнер.
|
||||
- **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает.
|
||||
|
||||
## Добавить парсер
|
||||
## Добавить источник
|
||||
|
||||
1. Реализовать `ITextParser` в `Core/Parsing/`.
|
||||
2. Одна строка в `CoreServiceCollectionExtensions.AddAvParserCore()`.
|
||||
1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в
|
||||
`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.
|
||||
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
|
||||
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт парсера, поэтому
|
||||
- **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому
|
||||
«доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться
|
||||
от одного лишь истечения окна.
|
||||
- **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует
|
||||
@@ -115,7 +126,7 @@ dotnet csharpier check .
|
||||
- **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном
|
||||
`WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул
|
||||
отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит
|
||||
никого, а гейт парсера откроется по данным недельной давности. Ловит
|
||||
никого, а гейт сбора откроется по данным недельной давности. Ловит
|
||||
`A_remembered_proxy_is_not_reported_live_until_it_answers_again`.
|
||||
- **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти
|
||||
сутки; перенос окна сажал бы прокси за то, что давно истекло.
|
||||
@@ -125,19 +136,48 @@ dotnet csharpier check .
|
||||
большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы
|
||||
накопленный список за пару запусков.
|
||||
|
||||
## Гейт парсера
|
||||
## Гейт сбора
|
||||
|
||||
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление парсера
|
||||
остаётся однострочным. Парсер, который куда-то ходит, **обязан** её переопределить, иначе поедет
|
||||
напрямую в обход настройки.
|
||||
`IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника
|
||||
остаётся однострочным.
|
||||
|
||||
Гейт живёт в `ParseViewModel.RefreshProxyGate()` и складывается из трёх условий: парсер сетевой,
|
||||
Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой,
|
||||
`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-теста.
|
||||
|
||||
## Инварианты хранилища медиа
|
||||
|
||||
- **В `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
|
||||
|
||||
1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт
|
||||
@@ -152,8 +192,8 @@ throttle 250 мс — пул дёргается на каждый исход л
|
||||
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
|
||||
выбор не слетал.
|
||||
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
|
||||
`Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же:
|
||||
`Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка»
|
||||
`Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же:
|
||||
`Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка»
|
||||
остаётся в силе.
|
||||
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
|
||||
`base`. Без этого заголовок страницы останется на прежнем языке.
|
||||
@@ -201,8 +241,16 @@ throttle 250 мс — пул дёргается на каждый исход л
|
||||
свежая CVE не роняла сборку кода, который никто не трогал.
|
||||
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
|
||||
|
||||
## Что осталось абстрактным
|
||||
## Границы, выбранные намеренно
|
||||
|
||||
Домен — заглушка. `IParser<TInput, TOutput>` + `DelimitedTextParser` + `KeyValueTextParser`
|
||||
существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика,
|
||||
демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`.
|
||||
- **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им
|
||||
вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением
|
||||
третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.**
|
||||
- **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам
|
||||
публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов
|
||||
и доступности, а не ради их обхода.
|
||||
- **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в
|
||||
хранилище дороже, чем повторное скачивание.
|
||||
- **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE.
|
||||
BMP/ICO/HEIC/JPEG-XL просто отложены.
|
||||
- **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# AvParser
|
||||
|
||||
Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх
|
||||
Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
|
||||
Desktop-приложение на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх Semi.Avalonia,
|
||||
единым DI-контейнером и тремя уровнями тестов.
|
||||
|
||||
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
|
||||
`IParser<TInput, TOutput>` и два демо-парсера, чтобы каркас был запускаемым и проверяемым
|
||||
end-to-end до появления настоящей логики.
|
||||
Собирает изображения и гифки в локальное хранилище: источник перечисляет адреса, загрузчик их
|
||||
скачивает и проверяет, хранилище дедуплицирует по содержимому и помнит, что уже видело. Запросы
|
||||
идут через пул прокси с ротацией и проверкой живости.
|
||||
|
||||
Источников два: **список ссылок**, который вы вставляете сами, и **свой сервис** — листинг-эндпоинт
|
||||
сервиса, который вы держите сами. Перебора идентификаторов чужих хостов нет; см.
|
||||
[«Границы, выбранные намеренно»](CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -54,15 +58,18 @@ dotnet csharpier check .
|
||||
|
||||
```
|
||||
src/
|
||||
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры
|
||||
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
|
||||
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog
|
||||
AvParser.Core домен: IMediaSource и каталог, модели медиа, контракт хранилища,
|
||||
прокси-пул и стратегии. Ноль зависимостей кроме DI.Abstractions —
|
||||
ни Avalonia, ни HTTP, ни SQLite
|
||||
AvParser.Infrastructure загрузчик (сниффинг, редиректы, троттл), SQLite-индекс, blob-хранилище,
|
||||
витрина, источники прокси, AppPaths, JSON-настройки, Serilog
|
||||
AvParser.UI Avalonia class library: App-независимые View, ViewModel,
|
||||
ResponsiveLayout, дизайн-токены, навигация
|
||||
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
|
||||
tests/
|
||||
AvParser.Core.Tests парсеры, реестр, отмена, прогресс, пул прокси и стратегии
|
||||
AvParser.Infrastructure.Tests разбор фида прокси, локальный список, маппинг на WebProxy
|
||||
AvParser.Core.Tests источники, каталог, модели, пул прокси и стратегии
|
||||
AvParser.Infrastructure.Tests сигнатуры и анимация, загрузчик против «плохого» сервера,
|
||||
хранилище и дедуп, фид прокси, настройки
|
||||
AvParser.UI.Tests ViewModel'и без Avalonia
|
||||
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
|
||||
```
|
||||
@@ -129,7 +136,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один
|
||||
раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков
|
||||
рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.
|
||||
рабочих обычно единицы процентов, поэтому без проверки сборщик будет в основном ждать таймауты.
|
||||
|
||||
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется
|
||||
навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
|
||||
@@ -143,7 +150,7 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
Запомненное — это подсказка, а не зачёт: восстановленная прокси идёт первой в очередь на проверку,
|
||||
но живой не считается, пока не ответит в этом запуске. Иначе запуск через неделю открывал бы гейт
|
||||
парсера по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
|
||||
сбора по недельной давности данным, а прогрев пропускал бы ровно те прокси, ради которых он есть.
|
||||
|
||||
Состояние лежит в `proxies.state.json` рядом с настройками и пишется после прогрева и на выходе.
|
||||
Сохраняются **только те прокси, что когда-либо отвечали**: мёртвых в фиде тысячи, они переиздаются
|
||||
@@ -152,11 +159,15 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
|
||||
|
||||
### Гейт «без прокси не работаем»
|
||||
|
||||
Парсер, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси:
|
||||
кнопка «Разобрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
|
||||
Гейт снимается настройкой **«Разрешить сетевым парсерам работать без прокси»**.
|
||||
Источник, который объявил `RequiresNetwork`, не запустится, пока в пуле нет ни одной живой прокси:
|
||||
кнопка «Собрать» гаснет, а на странице появляется баннер с переходом на страницу Proxies.
|
||||
Гейт снимается настройкой **«Разрешить сетевым источникам работать без прокси»**.
|
||||
|
||||
Парсеры, читающие вставленный пользователем текст, не блокируются никогда — им нечего
|
||||
То же правило продублировано в загрузчике: он бросает `ProxyUnavailableException` вместо тихого
|
||||
прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил
|
||||
этого не делать.
|
||||
|
||||
Источники, читающие вставленный пользователем текст, не блокируются никогда — им нечего
|
||||
маршрутизировать, и блокировка делала бы приложение бесполезным всякий раз, когда публичные списки
|
||||
лежат.
|
||||
|
||||
@@ -178,6 +189,48 @@ using (lease)
|
||||
SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что
|
||||
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>`: идентичность — значение перечисления
|
||||
(выбор не слетает), подпись следует за локализатором.
|
||||
- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение
|
||||
**и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами
|
||||
парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает
|
||||
- **Текст из домена.** `AvParser.Core` о языках не знает. Домен отдаёт английское сообщение
|
||||
**и код**, а UI переводит `Collect.Error.{Code}` с откатом на сообщение. Так же и с именами
|
||||
источников: `Source.{id}.Name` с откатом на `DisplayName`, поэтому новый источник работает
|
||||
непереведённым, а не показывает `!ключ!`.
|
||||
|
||||
Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
|
||||
@@ -242,8 +295,14 @@ Semi.Avalonia даёт темы контролов; токены — это се
|
||||
1. Потянуть окно по ширине — сайдбар проходит путь
|
||||
`полный → только иконки → выезжающий drawer`, без мигания на границах.
|
||||
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,
|
||||
а DevTools вынесли в отдельный инструмент со своей установкой
|
||||
(`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`). Поэтому `F12` здесь ничего не
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Parsing.Samples;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AvParser.Core.DependencyInjection;
|
||||
@@ -10,20 +8,17 @@ namespace AvParser.Core.DependencyInjection;
|
||||
public static class CoreServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers every parser plus the catalog that indexes them.
|
||||
/// Registers every media source the domain itself provides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Adding a parser is a one-line change here — that is the whole point of the
|
||||
/// <see cref="ITextParser"/> / <see cref="IParserCatalog"/> split.
|
||||
/// Adding a source is a one-line change here — that is the whole point of the
|
||||
/// <see cref="IMediaSource"/> / <see cref="IMediaSourceCatalog"/> split. Sources that need a
|
||||
/// network live in the infrastructure layer and register themselves there.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddAvParserCore(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<ITextParser, DelimitedTextParser>();
|
||||
services.AddSingleton<ITextParser, KeyValueTextParser>();
|
||||
services.AddSingleton<IParserCatalog, ParserCatalog>();
|
||||
|
||||
services.AddSingleton<IMediaSource, UrlListSource>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<T></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>;
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <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>
|
||||
public sealed record ParseError(int LineNumber, string Message)
|
||||
public sealed record ParseError(int Index, string Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// 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 —
|
||||
/// and moving the strings themselves into the domain would drag localisation down there.
|
||||
/// </remarks>
|
||||
@@ -21,9 +21,9 @@ public sealed record ParseError(int LineNumber, string Message)
|
||||
public IReadOnlyList<object?> Arguments { get; init; } = [];
|
||||
|
||||
/// <summary>Creates an error carrying a translation code.</summary>
|
||||
public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) =>
|
||||
new(lineNumber, message) { Code = code, Arguments = arguments };
|
||||
public static ParseError Create(int index, string code, string message, params object?[] arguments) =>
|
||||
new(index, message) { Code = code, Arguments = arguments };
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"Line {LineNumber}: {Message}";
|
||||
public override string ToString() => $"#{Index}: {Message}";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// <param name="Theme">Chosen theme variant.</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="WindowHeight">Last main-window height in device-independent pixels.</param>
|
||||
/// <param name="WindowMaximized">Whether the main window was maximised on exit.</param>
|
||||
@@ -65,7 +64,6 @@ public enum AppTheme
|
||||
public sealed record AppSettings(
|
||||
AppTheme Theme = AppTheme.System,
|
||||
AppLanguage Language = AppLanguage.System,
|
||||
string? LastParserId = null,
|
||||
double WindowWidth = 1280,
|
||||
double WindowHeight = 800,
|
||||
bool WindowMaximized = false,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Proxies;
|
||||
@@ -32,13 +31,6 @@ public static class UiServiceCollectionExtensions
|
||||
services.AddSingleton<INavigationService, NavigationService>();
|
||||
|
||||
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(
|
||||
sp.GetRequiredService<ISettingsService>(),
|
||||
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.
|
||||
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<ProxiesViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
|
||||
|
||||
@@ -70,9 +70,6 @@
|
||||
<data name="Page.Dashboard" xml:space="preserve">
|
||||
<value>Dashboard</value>
|
||||
</data>
|
||||
<data name="Page.Parse" xml:space="preserve">
|
||||
<value>Parse</value>
|
||||
</data>
|
||||
<data name="Page.Proxies" xml:space="preserve">
|
||||
<value>Proxies</value>
|
||||
</data>
|
||||
@@ -85,90 +82,12 @@
|
||||
<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>
|
||||
</data>
|
||||
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
|
||||
<value>Registered parsers</value>
|
||||
</data>
|
||||
<data name="Dashboard.GetStarted" xml:space="preserve">
|
||||
<value>Get started</value>
|
||||
</data>
|
||||
<data name="Dashboard.OpenParser" xml:space="preserve">
|
||||
<value>Open the parser</value>
|
||||
</data>
|
||||
<data name="Dashboard.DataDirectory" xml:space="preserve">
|
||||
<value>DATA DIRECTORY</value>
|
||||
</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">
|
||||
<value>POOL</value>
|
||||
</data>
|
||||
@@ -463,44 +382,14 @@
|
||||
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
|
||||
<value>Unchecked</value>
|
||||
</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">
|
||||
<value>{0} in the pool, {1} live.</value>
|
||||
</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">
|
||||
<value>Allow network parsers to run without a proxy</value>
|
||||
<value>Allow network sources to run without a proxy</value>
|
||||
</data>
|
||||
<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 name="Settings.MinimumLive" xml:space="preserve">
|
||||
<value>LIVE PROXIES TO FIND AT STARTUP</value>
|
||||
@@ -616,64 +505,64 @@
|
||||
<data name="Source.own-service.Description" xml:space="preserve">
|
||||
<value>Reads the listing endpoint of a service you run.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotAnAddress" xml:space="preserve">
|
||||
<data name="Collect.Error.NotAnAddress" xml:space="preserve">
|
||||
<value>'{0}' is not an http or https address.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoEndpoint" xml:space="preserve">
|
||||
<data name="Collect.Error.NoEndpoint" xml:space="preserve">
|
||||
<value>No listing endpoint was given.</value>
|
||||
</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>
|
||||
</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>
|
||||
</data>
|
||||
<data name="Parse.Error.SourceFailed" xml:space="preserve">
|
||||
<data name="Collect.Error.SourceFailed" xml:space="preserve">
|
||||
<value>The source stopped: {0}</value>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</data>
|
||||
<data name="Parse.Error.TooManyRedirects" xml:space="preserve">
|
||||
<data name="Collect.Error.TooManyRedirects" xml:space="preserve">
|
||||
<value>Too many redirects.</value>
|
||||
</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>
|
||||
</data>
|
||||
<data name="Parse.Error.TooLarge" xml:space="preserve">
|
||||
<data name="Collect.Error.TooLarge" xml:space="preserve">
|
||||
<value>Larger than the size limit.</value>
|
||||
</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>
|
||||
</data>
|
||||
<data name="Parse.Error.Truncated" xml:space="preserve">
|
||||
<data name="Collect.Error.Truncated" xml:space="preserve">
|
||||
<value>The transfer ended early.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Stalled" xml:space="preserve">
|
||||
<data name="Collect.Error.Stalled" xml:space="preserve">
|
||||
<value>Timed out.</value>
|
||||
</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>
|
||||
</data>
|
||||
<data name="Parse.Error.Placeholder" xml:space="preserve">
|
||||
<data name="Collect.Error.Placeholder" xml:space="preserve">
|
||||
<value>A known dead-link placeholder.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.UnsupportedType" xml:space="preserve">
|
||||
<data name="Collect.Error.UnsupportedType" xml:space="preserve">
|
||||
<value>That format is switched off.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.HttpStatus" xml:space="preserve">
|
||||
<data name="Collect.Error.HttpStatus" xml:space="preserve">
|
||||
<value>The site answered {0}.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RequestFailed" xml:space="preserve">
|
||||
<data name="Collect.Error.RequestFailed" xml:space="preserve">
|
||||
<value>The request failed: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Failed" xml:space="preserve">
|
||||
<data name="Collect.Error.Failed" xml:space="preserve">
|
||||
<value>Could not be collected.</value>
|
||||
</data>
|
||||
<data name="Enum.ShowcaseMode.None" xml:space="preserve">
|
||||
@@ -739,4 +628,10 @@
|
||||
<data name="Collect.Count.Files.Many" xml:space="preserve">
|
||||
<value>{0} files</value>
|
||||
</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>
|
||||
|
||||
@@ -70,9 +70,6 @@
|
||||
<data name="Page.Dashboard" xml:space="preserve">
|
||||
<value>Обзор</value>
|
||||
</data>
|
||||
<data name="Page.Parse" xml:space="preserve">
|
||||
<value>Разбор</value>
|
||||
</data>
|
||||
<data name="Page.Proxies" xml:space="preserve">
|
||||
<value>Прокси</value>
|
||||
</data>
|
||||
@@ -83,92 +80,14 @@
|
||||
<value>О программе</value>
|
||||
</data>
|
||||
<data name="Dashboard.Tagline" xml:space="preserve">
|
||||
<value>Оболочка парсера с адаптивной раскладкой. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель.</value>
|
||||
</data>
|
||||
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
|
||||
<value>Доступные парсеры</value>
|
||||
<value>Собирает изображения и гифки в локальное хранилище. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель.</value>
|
||||
</data>
|
||||
<data name="Dashboard.GetStarted" xml:space="preserve">
|
||||
<value>С чего начать</value>
|
||||
</data>
|
||||
<data name="Dashboard.OpenParser" xml:space="preserve">
|
||||
<value>Открыть парсер</value>
|
||||
</data>
|
||||
<data name="Dashboard.DataDirectory" xml:space="preserve">
|
||||
<value>КАТАЛОГ ДАННЫХ</value>
|
||||
</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">
|
||||
<value>ПУЛ</value>
|
||||
</data>
|
||||
@@ -463,44 +382,14 @@
|
||||
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
|
||||
<value>Не проверены</value>
|
||||
</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">
|
||||
<value>В пуле {0}, живых {1}.</value>
|
||||
</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">
|
||||
<value>Разрешить сетевым парсерам работать без прокси</value>
|
||||
<value>Разрешить сетевым источникам работать без прокси</value>
|
||||
</data>
|
||||
<data name="Settings.AllowDirectHint" xml:space="preserve">
|
||||
<value>Когда выключено, парсер, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Парсеры, читающие вставленный текст, не блокируются никогда.</value>
|
||||
<value>Когда выключено, источник, который куда-то ходит, не запустится, пока не ответит хотя бы одна прокси. Источники, читающие вставленный текст, не блокируются никогда.</value>
|
||||
</data>
|
||||
<data name="Settings.MinimumLive" xml:space="preserve">
|
||||
<value>СКОЛЬКО ЖИВЫХ ИСКАТЬ ПРИ СТАРТЕ</value>
|
||||
@@ -616,64 +505,64 @@
|
||||
<data name="Source.own-service.Description" xml:space="preserve">
|
||||
<value>Читает листинг сервиса, который вы держите сами.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotAnAddress" xml:space="preserve">
|
||||
<data name="Collect.Error.NotAnAddress" xml:space="preserve">
|
||||
<value>«{0}» — не http- и не https-адрес.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoEndpoint" xml:space="preserve">
|
||||
<data name="Collect.Error.NoEndpoint" xml:space="preserve">
|
||||
<value>Не указан адрес листинга.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingFailed" xml:space="preserve">
|
||||
<data name="Collect.Error.ListingFailed" xml:space="preserve">
|
||||
<value>Не удалось прочитать листинг: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingMalformed" xml:space="preserve">
|
||||
<data name="Collect.Error.ListingMalformed" xml:space="preserve">
|
||||
<value>Листинг не удалось разобрать: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.SourceFailed" xml:space="preserve">
|
||||
<data name="Collect.Error.SourceFailed" xml:space="preserve">
|
||||
<value>Источник остановился: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoProxy" xml:space="preserve">
|
||||
<data name="Collect.Error.NoProxy" xml:space="preserve">
|
||||
<value>Живых прокси нет, а прямое подключение выключено.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.BadRedirect" xml:space="preserve">
|
||||
<data name="Collect.Error.BadRedirect" xml:space="preserve">
|
||||
<value>Перенаправление на адрес, который не является http.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RedirectLoop" xml:space="preserve">
|
||||
<data name="Collect.Error.RedirectLoop" xml:space="preserve">
|
||||
<value>Перенаправления зациклены.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooManyRedirects" xml:space="preserve">
|
||||
<data name="Collect.Error.TooManyRedirects" xml:space="preserve">
|
||||
<value>Слишком много перенаправлений.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotMedia" xml:space="preserve">
|
||||
<data name="Collect.Error.NotMedia" xml:space="preserve">
|
||||
<value>В ответе не изображение и не видео.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooLarge" xml:space="preserve">
|
||||
<data name="Collect.Error.TooLarge" xml:space="preserve">
|
||||
<value>Больше предельного размера.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooSmall" xml:space="preserve">
|
||||
<data name="Collect.Error.TooSmall" xml:space="preserve">
|
||||
<value>Меньше нижнего порога; вероятно, трекинг-пиксель.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Truncated" xml:space="preserve">
|
||||
<data name="Collect.Error.Truncated" xml:space="preserve">
|
||||
<value>Передача оборвалась.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Stalled" xml:space="preserve">
|
||||
<data name="Collect.Error.Stalled" xml:space="preserve">
|
||||
<value>Истекло время ожидания.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RateLimited" xml:space="preserve">
|
||||
<data name="Collect.Error.RateLimited" xml:space="preserve">
|
||||
<value>Сайт попросил снизить темп.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Placeholder" xml:space="preserve">
|
||||
<data name="Collect.Error.Placeholder" xml:space="preserve">
|
||||
<value>Известная заглушка мёртвой ссылки.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.UnsupportedType" xml:space="preserve">
|
||||
<data name="Collect.Error.UnsupportedType" xml:space="preserve">
|
||||
<value>Этот формат отключён.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.HttpStatus" xml:space="preserve">
|
||||
<data name="Collect.Error.HttpStatus" xml:space="preserve">
|
||||
<value>Сайт ответил {0}.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RequestFailed" xml:space="preserve">
|
||||
<data name="Collect.Error.RequestFailed" xml:space="preserve">
|
||||
<value>Запрос не удался: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Failed" xml:space="preserve">
|
||||
<data name="Collect.Error.Failed" xml:space="preserve">
|
||||
<value>Не удалось собрать.</value>
|
||||
</data>
|
||||
<data name="Enum.ShowcaseMode.None" xml:space="preserve">
|
||||
@@ -739,4 +628,10 @@
|
||||
<data name="Collect.Count.Files.Many" xml:space="preserve">
|
||||
<value>{0} файлов</value>
|
||||
</data>
|
||||
<data name="Dashboard.RegisteredSources" xml:space="preserve">
|
||||
<value>Зарегистрированные источники</value>
|
||||
</data>
|
||||
<data name="Dashboard.OpenCollector" xml:space="preserve">
|
||||
<value>Открыть сбор</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
+7
-7
@@ -4,19 +4,19 @@ using ReactiveUI;
|
||||
|
||||
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>
|
||||
/// Parsers 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
|
||||
/// Sources and the fetcher are part of the domain and produce English text plus a code. This resolves
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject
|
||||
public sealed class CollectErrorViewModel(ParseError error) : ReactiveObject
|
||||
{
|
||||
/// <summary>The underlying error.</summary>
|
||||
public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error));
|
||||
|
||||
/// <summary>1-based position of the offending record.</summary>
|
||||
public int LineNumber => Error.LineNumber;
|
||||
/// <summary>1-based position of the offending item: a pasted line, or a place in a listing.</summary>
|
||||
public int Index => Error.Index;
|
||||
|
||||
/// <summary>Translated message.</summary>
|
||||
public string Text
|
||||
@@ -28,7 +28,7 @@ public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject
|
||||
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
|
||||
? template
|
||||
: string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]);
|
||||
@@ -206,7 +206,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
public ObservableCollection<CollectedItemViewModel> Items { get; } = [];
|
||||
|
||||
/// <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>
|
||||
public bool IsBusy => _isBusy.Value;
|
||||
@@ -485,7 +485,7 @@ public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
|
||||
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.UI.Navigation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -13,24 +13,25 @@ public sealed class DashboardViewModel : PageViewModel
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
/// <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="services">
|
||||
/// 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
|
||||
/// page, so a page cannot also depend on it up front.
|
||||
/// </param>
|
||||
public DashboardViewModel(IParserCatalog catalog, IAppPaths paths, IServiceProvider services)
|
||||
public DashboardViewModel(IMediaSourceCatalog catalog, IAppPaths paths, IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
_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;
|
||||
MediaDirectory = paths.MediaDirectory;
|
||||
|
||||
GoToParseCommand = ReactiveCommand.Create(() => Navigate<ParseViewModel>());
|
||||
GoToCollectCommand = ReactiveCommand.Create(() => Navigate<CollectViewModel>());
|
||||
GoToSettingsCommand = ReactiveCommand.Create(() => Navigate<SettingsViewModel>());
|
||||
}
|
||||
|
||||
@@ -40,14 +41,17 @@ public sealed class DashboardViewModel : PageViewModel
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconHome";
|
||||
|
||||
/// <summary>Registered parsers, shown as cards.</summary>
|
||||
public IReadOnlyList<ParserViewModel> Parsers { get; }
|
||||
/// <summary>Registered media sources, shown as cards.</summary>
|
||||
public IReadOnlyList<MediaSourceViewModel> Sources { get; }
|
||||
|
||||
/// <summary>Where settings and logs are written.</summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>Jumps to the Parse page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToParseCommand { get; }
|
||||
/// <summary>Where collected media is written.</summary>
|
||||
public string MediaDirectory { get; }
|
||||
|
||||
/// <summary>Jumps to the Collect page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToCollectCommand { get; }
|
||||
|
||||
/// <summary>Jumps to the Settings page.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -284,10 +284,10 @@
|
||||
|
||||
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ParseErrorViewModel">
|
||||
<DataTemplate x:DataType="vm:CollectErrorViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<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>
|
||||
<TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="{l:Loc Dashboard.RegisteredParsers}" />
|
||||
<ItemsControl ItemsSource="{Binding Parsers}">
|
||||
<TextBlock Classes="subtitle" Text="{l:Loc Dashboard.RegisteredSources}" />
|
||||
<ItemsControl ItemsSource="{Binding Sources}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ParserViewModel">
|
||||
<DataTemplate x:DataType="vm:MediaSourceViewModel">
|
||||
<Border Classes="card interactive" Width="320" Margin="0,0,12,12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="subtitle" Text="{Binding Name}" />
|
||||
@@ -40,10 +40,10 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="{l:Loc Dashboard.GetStarted}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Button Classes="primary" Command="{Binding GoToParseCommand}">
|
||||
<Button Classes="primary" Command="{Binding GoToCollectCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
|
||||
<TextBlock Text="{l:Loc Dashboard.OpenParser}" />
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconDownload}" />
|
||||
<TextBlock Text="{l:Loc Dashboard.OpenCollector}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Command="{Binding GoToSettingsCommand}">
|
||||
@@ -56,9 +56,16 @@
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Dashboard.DataDirectory}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="6">
|
||||
<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>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
"lastParserId": "delimited",
|
||||
"windowWidth": 1280,
|
||||
"windowHeight": 800,
|
||||
"windowMaximized": false,
|
||||
@@ -76,7 +75,6 @@ public sealed class JsonSettingsServiceTests : IDisposable
|
||||
settings.Language.ShouldBe(AppLanguage.System);
|
||||
|
||||
// And the values the file did carry must survive.
|
||||
settings.LastParserId.ShouldBe("delimited");
|
||||
settings.MinimumLogLevel.ShouldBe("Information");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class LocalizationViewTests : IDisposable
|
||||
|
||||
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 view = new ShellView { DataContext = viewModel };
|
||||
|
||||
@@ -49,14 +49,14 @@ public sealed class LocalizationViewTests : IDisposable
|
||||
var (view, _) = ShowShell();
|
||||
|
||||
var english = RailLabels(view);
|
||||
english.ShouldBe(["Dashboard", "Parse"]);
|
||||
english.ShouldBe(["Dashboard", "Collect"]);
|
||||
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
// 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.
|
||||
RailLabels(view).ShouldBe(["Обзор", "Разбор"]);
|
||||
RailLabels(view).ShouldBe(["Обзор", "Сбор"]);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,9 @@ public sealed class LocalizationTests : IDisposable
|
||||
{
|
||||
Localizer.Instance.SetLanguage(AppLanguage.Russian);
|
||||
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 1).ShouldBe("1 запись");
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 3).ShouldBe("3 записи");
|
||||
Localizer.Instance.Plural("Parse.Count.Records", 7).ShouldBe("7 записей");
|
||||
Localizer.Instance.Plural("Collect.Count.Images", 1).ShouldBe("1 изображение");
|
||||
Localizer.Instance.Plural("Collect.Count.Images", 3).ShouldBe("3 изображения");
|
||||
Localizer.Instance.Plural("Collect.Count.Images", 7).ShouldBe("7 изображений");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -195,7 +195,7 @@ public sealed class LocalizationTests : IDisposable
|
||||
|
||||
[Fact]
|
||||
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]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user