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