Localise the UI into Russian and English, switchable without a restart

Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.

Three places where this is more than a string swap:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 18:15:01 +03:00
co-authored by Claude Opus 5
parent 9bf2ea5532
commit 8b552470b7
42 changed files with 2216 additions and 226 deletions
+3
View File
@@ -0,0 +1,3 @@
# Generated from a single source table (see the localization section of CLAUDE.md); reformatting
# them by hand only creates diff noise.
**/Localization/Strings*.resx
+20
View File
@@ -107,6 +107,26 @@ dotnet csharpier check .
значит карантинить здоровые прокси на каждый Cancel. значит карантинить здоровые прокси на каждый Cancel.
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`. - **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
## Добавить строку в UI
1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт
`LocalizationTests.Russian_translates_every_english_key`).
2. В XAML — `{l:Loc Ключ}`, во ViewModel — `Localizer.Instance[...]` / `.Format(...)`.
Никакого хардкода в `Views/` кроме имени продукта и примеров адресов.
- **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`. У русского
три формы; «{0} records» с приклеенным окончанием непереводимо.
- **Перечисления в списках — `LocalizedOption<T>`**, не сырые значения. Конвертер разрешил бы
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
выбор не слетал.
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
`Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же:
`Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка»
остаётся в силе.
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
`base`. Без этого заголовок страницы останется на прежнем языке.
## Грабли, уже оплаченные ## Грабли, уже оплаченные
- **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView` - **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView`
+35
View File
@@ -152,6 +152,41 @@ 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://`.
## Локализация
Русский и английский, переключение **без перезапуска** — язык выбирается в настройках
(«Системный» берёт язык ОС, если для него есть перевод, иначе английский).
Строки лежат в `UI/Localization/Strings.resx` и `Strings.ru.resx`; русский собирается в
сателлитную сборку `ru/AvParser.UI.resources.dll`.
В XAML — разметочное расширение:
```xml
<TextBlock Text="{l:Loc Parse.Run}" />
```
Оно возвращает **биндинг** через индексатор `Localizer`, а не готовую строку: смена языка
поднимает `PropertyChanged` для индексатора, и все такие биндинги перечитываются разом. Строка,
разрешённая один раз при загрузке, потребовала бы перезапуска.
Три места, где локализация упирается в грамматику или в слои:
- **Множественные числа.** У русского три формы, поэтому счётчики собираются не из «{0} records»
с приклеенным окончанием, а из ключей `.One` / `.Few` / `.Many` через `Localizer.Plural`.
«1 запись», «3 записи», «7 записей».
- **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка,
поэтому в списках лежат обёртки `LocalizedOption<T>`: идентичность — значение перечисления
(выбор не слетает), подпись следует за локализатором.
- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение
**и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами
парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает
непереведённым, а не показывает `!ключ!`.
Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и
отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и
одинаковый набор плейсхолдеров `{0}`.
## Дизайн-токены ## Дизайн-токены
Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями
+19
View File
@@ -5,6 +5,25 @@ namespace AvParser.Core.Parsing;
/// <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 LineNumber, string Message)
{ {
/// <summary>
/// Stable identifier for the kind of failure, or <see langword="null"/> when the message is
/// the only thing on offer.
/// </summary>
/// <remarks>
/// The domain stays language-free: it produces an English message plus a code, and the UI
/// translates <c>Parse.Error.{Code}</c> with <see cref="Arguments"/>, falling back to
/// <see cref="Message"/>. Without this, a Russian UI would still print English error text —
/// and moving the strings themselves into the domain would drag localisation down there.
/// </remarks>
public string? Code { get; init; }
/// <summary>Values to substitute into the translated message.</summary>
public IReadOnlyList<object?> Arguments { get; init; } = [];
/// <summary>Creates an error carrying a translation code.</summary>
public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) =>
new(lineNumber, message) { Code = code, Arguments = arguments };
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() => $"Line {LineNumber}: {Message}"; public override string ToString() => $"Line {LineNumber}: {Message}";
} }
@@ -37,4 +37,8 @@ public readonly record struct ParseOutcome<T>
/// <summary>Creates a failed outcome from its parts.</summary> /// <summary>Creates a failed outcome from its parts.</summary>
public static ParseOutcome<T> Failure(int lineNumber, string message) => public static ParseOutcome<T> Failure(int lineNumber, string message) =>
Failure(new ParseError(lineNumber, message)); Failure(new ParseError(lineNumber, message));
/// <summary>Creates a failed outcome carrying a translation code.</summary>
public static ParseOutcome<T> Failure(int lineNumber, string code, string message, params object?[] arguments) =>
Failure(ParseError.Create(lineNumber, code, message, arguments));
} }
@@ -69,7 +69,10 @@ public sealed class DelimitedTextParser : ITextParser
{ {
yield return ParseOutcome<ParsedRecord>.Failure( yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber, lineNumber,
$"Expected {header.Length} field(s) but found {values.Length}." "FieldCount",
$"Expected {header.Length} field(s) but found {values.Length}.",
header.Length,
values.Length
); );
} }
else else
@@ -93,7 +96,7 @@ public sealed class DelimitedTextParser : ITextParser
if (header is null) if (header is null)
{ {
yield return ParseOutcome<ParsedRecord>.Failure(1, "Input contains no header row."); yield return ParseOutcome<ParsedRecord>.Failure(1, "NoHeader", "Input contains no header row.");
} }
progress?.Report(new ParseProgress(total, total)); progress?.Report(new ParseProgress(total, total));
@@ -51,7 +51,11 @@ public sealed class KeyValueTextParser : ITextParser
if (separatorIndex <= 0) if (separatorIndex <= 0)
{ {
yield return ParseOutcome<ParsedRecord>.Failure(lineNumber, "No '=' or ':' separator found."); yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
"NoSeparator",
"No '=' or ':' separator found."
);
} }
else else
{ {
@@ -59,7 +63,7 @@ public sealed class KeyValueTextParser : ITextParser
var value = line[(separatorIndex + 1)..].Trim(); var value = line[(separatorIndex + 1)..].Trim();
yield return key.Length == 0 yield return key.Length == 0
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "Key is empty.") ? ParseOutcome<ParsedRecord>.Failure(lineNumber, "EmptyKey", "Key is empty.")
: ParseOutcome<ParsedRecord>.Success( : ParseOutcome<ParsedRecord>.Success(
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)]) new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
); );
+45
View File
@@ -0,0 +1,45 @@
using System.Globalization;
namespace AvParser.Core.Settings;
/// <summary>UI language. <see cref="System"/> follows the operating system.</summary>
public enum AppLanguage
{
/// <summary>Follow the operating system, falling back to English.</summary>
System = 0,
/// <summary>English.</summary>
English = 1,
/// <summary>Russian.</summary>
Russian = 2,
}
/// <summary>Maps <see cref="AppLanguage"/> onto cultures.</summary>
public static class AppLanguages
{
/// <summary>Languages the app ships translations for.</summary>
public static IReadOnlyList<AppLanguage> All { get; } =
[AppLanguage.System, AppLanguage.English, AppLanguage.Russian];
/// <summary>
/// Resolves a language to the culture the resource lookup should use.
/// </summary>
/// <remarks>
/// <see cref="AppLanguage.System"/> resolves to Russian only when the OS is actually Russian;
/// anything else lands on English, because those are the two translations that exist and a
/// half-translated German would be worse than plain English.
/// </remarks>
public static CultureInfo ToCulture(AppLanguage language, CultureInfo? systemCulture = null) =>
language switch
{
AppLanguage.English => CultureInfo.GetCultureInfo("en"),
AppLanguage.Russian => CultureInfo.GetCultureInfo("ru"),
_ => FromSystem(systemCulture ?? CultureInfo.CurrentUICulture),
};
private static CultureInfo FromSystem(CultureInfo culture) =>
string.Equals(culture.TwoLetterISOLanguageName, "ru", StringComparison.OrdinalIgnoreCase)
? CultureInfo.GetCultureInfo("ru")
: CultureInfo.GetCultureInfo("en");
}
@@ -27,6 +27,9 @@ public sealed record AppSettings
/// <summary>Chosen theme variant.</summary> /// <summary>Chosen theme variant.</summary>
public AppTheme Theme { get; init; } = AppTheme.System; public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Chosen UI language.</summary>
public AppLanguage Language { get; init; } = AppLanguage.System;
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary> /// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
public string? LastParserId { get; init; } public string? LastParserId { get; init; }
+3 -1
View File
@@ -43,7 +43,9 @@ public partial class App : Application
DataTemplates.Add(_services.GetRequiredService<ViewLocator>()); DataTemplates.Add(_services.GetRequiredService<ViewLocator>());
// Resolving the theme service applies the persisted variant as a side effect of construction. // Both of these apply the persisted choice as a side effect of construction, so they have
// to be resolved before the first window is built rather than lazily on the Settings page.
_ = _services.GetRequiredService<ILocalizationService>();
_ = _services.GetRequiredService<IThemeService>(); _ = _services.GetRequiredService<IThemeService>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+8
View File
@@ -10,6 +10,14 @@
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- No designer class: strings are reached through Localizer's indexer so that bindings can
re-read them when the language changes. A generated static accessor would be resolved
once and would need an app restart to take effect. -->
<EmbeddedResource Update="Localization\Strings.resx" Generator="" />
<EmbeddedResource Update="Localization\Strings.ru.resx" Generator="" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" /> <ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\AvParser.Infrastructure\AvParser.Infrastructure.csproj" /> <ProjectReference Include="..\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
@@ -26,6 +26,7 @@ public static class UiServiceCollectionExtensions
services.AddSingleton<ViewLocator>(); services.AddSingleton<ViewLocator>();
services.AddSingleton<IThemeService, ThemeService>(); services.AddSingleton<IThemeService, ThemeService>();
services.AddSingleton<ILocalizationService, LocalizationService>();
services.AddSingleton<INavigationService, NavigationService>(); services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<DashboardViewModel>(); services.AddSingleton<DashboardViewModel>();
@@ -39,7 +40,8 @@ public static class UiServiceCollectionExtensions
sp.GetRequiredService<IThemeService>(), sp.GetRequiredService<IThemeService>(),
sp.GetRequiredService<IAppPaths>(), sp.GetRequiredService<IAppPaths>(),
sp.GetRequiredService<LoggingLevelSwitch>(), sp.GetRequiredService<LoggingLevelSwitch>(),
sp.GetRequiredService<IProxyPool>() sp.GetRequiredService<IProxyPool>(),
sp.GetRequiredService<ILocalizationService>()
)); ));
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel( services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
sp.GetRequiredService<IProxyPool>(), sp.GetRequiredService<IProxyPool>(),
@@ -0,0 +1,33 @@
using Avalonia.Data;
using Avalonia.Markup.Xaml;
namespace AvParser.UI.Localization;
/// <summary>
/// XAML shorthand for a translated string: <c>Text="{l:Loc Parse.Title}"</c>.
/// </summary>
/// <remarks>
/// Returns a <see cref="Binding"/> through <see cref="Localizer"/>'s indexer rather than the
/// string itself. A plain string would be resolved once at load and would never notice a language
/// change; the binding re-reads when the localizer invalidates its indexer.
/// </remarks>
public sealed class LocExtension : MarkupExtension
{
/// <summary>Creates the extension with no key. Used by XAML before the key is assigned.</summary>
public LocExtension() => Key = string.Empty;
/// <summary>Creates the extension for a resource key.</summary>
public LocExtension(string key) => Key = key;
/// <summary>Resource key to look up.</summary>
public string Key { get; set; }
/// <inheritdoc />
public override object ProvideValue(IServiceProvider serviceProvider) =>
new Binding
{
Mode = BindingMode.OneWay,
Source = Localizer.Instance,
Path = $"[{Key}]",
};
}
@@ -0,0 +1,66 @@
using ReactiveUI;
namespace AvParser.UI.Localization;
/// <summary>
/// An enum value paired with its translated label, for pickers.
/// </summary>
/// <remarks>
/// Combo boxes cannot bind a raw enum to a translated caption: a value converter would resolve the
/// text once and never notice a language change. Wrapping the value keeps the selection stable
/// (identity is the enum value) while the label follows the localizer.
/// </remarks>
public abstract class LocalizedOption : ReactiveObject
{
/// <summary>Translated caption.</summary>
/// <remarks>
/// Declared on a non-generic base so a <c>DataTemplate</c> can name a concrete
/// <c>x:DataType</c>: Avalonia 12 compiles bindings by default and cannot infer one for an
/// open generic.
/// </remarks>
public abstract string Label { get; }
}
/// <inheritdoc cref="LocalizedOption" />
public sealed class LocalizedOption<TValue> : LocalizedOption, IEquatable<LocalizedOption<TValue>>
where TValue : struct, Enum
{
private readonly string _key;
/// <summary>Creates an option for a value, reading its label from <c>Enum.{Type}.{Value}</c>.</summary>
public LocalizedOption(TValue value)
{
Value = value;
_key = $"Enum.{typeof(TValue).Name}.{value}";
Localizer.Instance.LanguageChanged += OnLanguageChanged;
}
/// <summary>The underlying value.</summary>
public TValue Value { get; }
/// <inheritdoc />
public override string Label => Localizer.Instance[_key];
/// <summary>Builds options for every value of the enum.</summary>
public static IReadOnlyList<LocalizedOption<TValue>> ForAll() =>
[.. Enum.GetValues<TValue>().Select(value => new LocalizedOption<TValue>(value))];
/// <summary>Builds options for a specific set of values, in that order.</summary>
public static IReadOnlyList<LocalizedOption<TValue>> For(params TValue[] values) =>
[.. values.Select(value => new LocalizedOption<TValue>(value))];
/// <inheritdoc />
public bool Equals(LocalizedOption<TValue>? other) => other is not null && Value.Equals(other.Value);
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as LocalizedOption<TValue>);
/// <inheritdoc />
public override int GetHashCode() => Value.GetHashCode();
/// <inheritdoc />
public override string ToString() => Label;
private void OnLanguageChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(Label));
}
+137
View File
@@ -0,0 +1,137 @@
using System.ComponentModel;
using System.Globalization;
using System.Resources;
using AvParser.Core.Settings;
namespace AvParser.UI.Localization;
/// <summary>
/// The single source of translated strings, and the thing bindings listen to.
/// </summary>
/// <remarks>
/// <para>
/// Exposed as an indexer so XAML can bind through it: changing the language raises
/// <c>PropertyChanged</c> for the indexer, which makes every
/// <c>{l:Loc Key}</c> binding in the application re-read at once. That is what buys language
/// switching without a restart — the alternative, resolving strings once at load, would need one.
/// </para>
/// <para>
/// A singleton rather than an injected service because XAML markup extensions have no container
/// to ask, and a second instance would simply be a second set of stale bindings.
/// </para>
/// </remarks>
public sealed class Localizer : INotifyPropertyChanged
{
/// <summary>WPF/Avalonia convention: this property name invalidates every indexer binding.</summary>
private const string IndexerName = "Item[]";
private static readonly ResourceManager Resources = new(
"AvParser.UI.Localization.Strings",
typeof(Localizer).Assembly
);
private CultureInfo _culture = CultureInfo.GetCultureInfo("en");
private Localizer() { }
/// <summary>The instance every binding goes through.</summary>
public static Localizer Instance { get; } = new();
/// <summary>Culture currently used for lookups.</summary>
public CultureInfo Culture => _culture;
/// <summary>The language setting currently applied.</summary>
public AppLanguage Language { get; private set; } = AppLanguage.System;
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>Raised after the language changes, for consumers that cache derived text.</summary>
public event EventHandler? LanguageChanged;
/// <summary>Looks a key up. Unknown keys come back as <c>!key!</c> rather than blank.</summary>
/// <remarks>
/// A visible marker beats an empty label: a missing translation should be obvious in a
/// screenshot, not silently render as nothing.
/// </remarks>
public string this[string key] => Get(key);
/// <summary>Looks a key up.</summary>
public string Get(string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
return Resources.GetString(key, _culture) ?? $"!{key}!";
}
/// <summary>Looks a key up, returning <paramref name="fallback"/> when it is not translated.</summary>
/// <remarks>
/// Used for text that plugs into the app from outside the resource files — a newly added
/// parser should show its own English name rather than <c>!Parser.x.Name!</c> until someone
/// gets round to translating it.
/// </remarks>
public string GetOrDefault(string key, string fallback) => Resources.GetString(key, _culture) ?? fallback;
/// <summary>Looks a key up and formats it with <paramref name="arguments"/>.</summary>
public string Format(string key, params object?[] arguments) => string.Format(_culture, Get(key), arguments);
/// <summary>
/// Picks the plural form of <paramref name="key"/> for <paramref name="count"/> and formats it.
/// </summary>
/// <remarks>
/// Resource keys are suffixed <c>.One</c> / <c>.Few</c> / <c>.Many</c>. English only ever
/// needs One and Many; Russian needs all three, which is why the counted messages cannot just
/// be "{0} records" with an English-shaped plural glued on.
/// </remarks>
public string Plural(string key, int count) =>
string.Format(_culture, Get($"{key}.{SuffixFor(count, _culture)}"), count);
/// <summary>Applies a language and notifies every binding.</summary>
public void SetLanguage(AppLanguage language, CultureInfo? systemCulture = null)
{
var culture = AppLanguages.ToCulture(language, systemCulture);
var changed = !Equals(culture, _culture) || language != Language;
_culture = culture;
Language = language;
if (!changed)
{
return;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(IndexerName));
LanguageChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Chooses the plural suffix for a count under a culture.</summary>
/// <remarks>Public so the rules can be tested directly rather than through resource lookups.</remarks>
public static string SuffixFor(int count, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(culture);
if (!string.Equals(culture.TwoLetterISOLanguageName, "ru", StringComparison.OrdinalIgnoreCase))
{
return count == 1 ? "One" : "Many";
}
var absolute = Math.Abs(count);
var lastTwo = absolute % 100;
var last = absolute % 10;
if (lastTwo is >= 11 and <= 14)
{
return "Many";
}
return last switch
{
1 => "One",
2 or 3 or 4 => "Few",
_ => "Many",
};
}
}
+490
View File
@@ -0,0 +1,490 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Shell.ToggleNavigation" xml:space="preserve">
<value>Toggle navigation</value>
</data>
<data name="Shell.Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Shell.ToggleTheme" xml:space="preserve">
<value>Switch light / dark</value>
</data>
<data name="Page.Dashboard" xml:space="preserve">
<value>Dashboard</value>
</data>
<data name="Page.Parse" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Page.Proxies" xml:space="preserve">
<value>Proxies</value>
</data>
<data name="Page.Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Page.About" xml:space="preserve">
<value>About</value>
</data>
<data name="Dashboard.Tagline" xml:space="preserve">
<value>A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer.</value>
</data>
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
<value>Registered parsers</value>
</data>
<data name="Dashboard.GetStarted" xml:space="preserve">
<value>Get started</value>
</data>
<data name="Dashboard.OpenParser" xml:space="preserve">
<value>Open the parser</value>
</data>
<data name="Dashboard.DataDirectory" xml:space="preserve">
<value>DATA DIRECTORY</value>
</data>
<data name="Parse.Parser" xml:space="preserve">
<value>PARSER</value>
</data>
<data name="Parse.Actions" xml:space="preserve">
<value>ACTIONS</value>
</data>
<data name="Parse.Run" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Parse.Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Parse.Sample" xml:space="preserve">
<value>Sample</value>
</data>
<data name="Parse.SampleTip" xml:space="preserve">
<value>Fill the input with a small example</value>
</data>
<data name="Parse.LargeSample" xml:space="preserve">
<value>50k rows</value>
</data>
<data name="Parse.LargeSampleTip" xml:space="preserve">
<value>Generate 50 000 rows, so progress and cancellation are observable</value>
</data>
<data name="Parse.ClearTip" xml:space="preserve">
<value>Clear input and results</value>
</data>
<data name="Parse.Input" xml:space="preserve">
<value>INPUT</value>
</data>
<data name="Parse.InputPlaceholder" xml:space="preserve">
<value>Paste text here, or press Sample</value>
</data>
<data name="Parse.Records" xml:space="preserve">
<value>RECORDS</value>
</data>
<data name="Parse.Errors" xml:space="preserve">
<value>ERRORS</value>
</data>
<data name="Parse.Count.Records.One" xml:space="preserve">
<value>{0} record</value>
</data>
<data name="Parse.Count.Records.Few" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Records.Many" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Errors.One" xml:space="preserve">
<value>{0} error</value>
</data>
<data name="Parse.Count.Errors.Few" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Count.Errors.Many" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Status.Done" xml:space="preserve">
<value>Parsed {0} in {1} ms.</value>
</data>
<data name="Parse.Status.Cancelled" xml:space="preserve">
<value>Cancelled after {0} in {1} ms.</value>
</data>
<data name="Parse.Status.WithErrors" xml:space="preserve">
<value>{0}, {1}.</value>
</data>
<data name="Parse.Status.Truncated" xml:space="preserve">
<value>Showing the first {0} only.</value>
</data>
<data name="Parse.Status.Failed" xml:space="preserve">
<value>Parse failed: {0}</value>
</data>
<data name="Proxies.Pool" xml:space="preserve">
<value>POOL</value>
</data>
<data name="Proxies.Refresh" xml:space="preserve">
<value>Refresh</value>
</data>
<data name="Proxies.RefreshTip" xml:space="preserve">
<value>Reload every source</value>
</data>
<data name="Proxies.CheckAll" xml:space="preserve">
<value>Check all</value>
</data>
<data name="Proxies.CheckAllTip" xml:space="preserve">
<value>Probe every proxy in the pool</value>
</data>
<data name="Proxies.Search" xml:space="preserve">
<value>SEARCH</value>
</data>
<data name="Proxies.SearchPlaceholder" xml:space="preserve">
<value>address or country</value>
</data>
<data name="Proxies.Protocol" xml:space="preserve">
<value>PROTOCOL</value>
</data>
<data name="Proxies.StatusColumn" xml:space="preserve">
<value>STATUS</value>
</data>
<data name="Proxies.Alive" xml:space="preserve">
<value>alive</value>
</data>
<data name="Proxies.Total" xml:space="preserve">
<value>total</value>
</data>
<data name="Proxies.Matched" xml:space="preserve">
<value>matched</value>
</data>
<data name="Proxies.ListHeader" xml:space="preserve">
<value>PROXIES</value>
</data>
<data name="Proxies.CustomHeader" xml:space="preserve">
<value>CUSTOM PROXIES</value>
</data>
<data name="Proxies.Add" xml:space="preserve">
<value>Add</value>
</data>
<data name="Proxies.Remove" xml:space="preserve">
<value>Remove</value>
</data>
<data name="Proxies.RemoveTip" xml:space="preserve">
<value>Remove the selected custom proxy</value>
</data>
<data name="Proxies.ClearTip" xml:space="preserve">
<value>Clear the custom list</value>
</data>
<data name="Proxies.CustomHint" xml:space="preserve">
<value>One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported.</value>
</data>
<data name="Proxies.Health.Alive" xml:space="preserve">
<value>alive</value>
</data>
<data name="Proxies.Health.Dead" xml:space="preserve">
<value>dead</value>
</data>
<data name="Proxies.Health.Unchecked" xml:space="preserve">
<value>unchecked</value>
</data>
<data name="Proxies.Source.Feed" xml:space="preserve">
<value>feed</value>
</data>
<data name="Proxies.Source.Custom" xml:space="preserve">
<value>custom</value>
</data>
<data name="Proxies.Count.Proxies.One" xml:space="preserve">
<value>{0} proxy</value>
</data>
<data name="Proxies.Count.Proxies.Few" xml:space="preserve">
<value>{0} proxies</value>
</data>
<data name="Proxies.Count.Proxies.Many" xml:space="preserve">
<value>{0} proxies</value>
</data>
<data name="Proxies.Status.PoolHolds" xml:space="preserve">
<value>Pool holds {0}.</value>
</data>
<data name="Proxies.Status.Nothing" xml:space="preserve">
<value>Nothing to add.</value>
</data>
<data name="Proxies.Status.Added" xml:space="preserve">
<value>Added {0} of {1}.</value>
</data>
<data name="Proxies.Status.Rejected" xml:space="preserve">
<value>Could not parse: {0}</value>
</data>
<data name="Proxies.Status.RejectedMore" xml:space="preserve">
<value>and {0} more</value>
</data>
<data name="Proxies.Status.Answered" xml:space="preserve">
<value>{0} of {1} answered.</value>
</data>
<data name="Proxies.Status.CheckCancelled" xml:space="preserve">
<value>Check cancelled.</value>
</data>
<data name="Proxies.Status.Removed" xml:space="preserve">
<value>Removed {0}.</value>
</data>
<data name="Proxies.Status.Cleared" xml:space="preserve">
<value>Custom list cleared.</value>
</data>
<data name="Proxies.Status.Failed" xml:space="preserve">
<value>Failed: {0}</value>
</data>
<data name="Settings.Appearance" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="Settings.Theme" xml:space="preserve">
<value>THEME</value>
</data>
<data name="Settings.ThemeHint" xml:space="preserve">
<value>System follows the operating system's light/dark setting.</value>
</data>
<data name="Settings.Language" xml:space="preserve">
<value>LANGUAGE</value>
</data>
<data name="Settings.LanguageHint" xml:space="preserve">
<value>Applies immediately — no restart needed.</value>
</data>
<data name="Settings.Diagnostics" xml:space="preserve">
<value>Diagnostics</value>
</data>
<data name="Settings.LogLevel" xml:space="preserve">
<value>MINIMUM LOG LEVEL</value>
</data>
<data name="Settings.LogLevelHint" xml:space="preserve">
<value>Applies immediately — no restart needed.</value>
</data>
<data name="Settings.SettingsFile" xml:space="preserve">
<value>SETTINGS FILE</value>
</data>
<data name="Settings.LogDirectory" xml:space="preserve">
<value>LOG DIRECTORY</value>
</data>
<data name="Settings.Proxies" xml:space="preserve">
<value>Proxies</value>
</data>
<data name="Settings.Rotation" xml:space="preserve">
<value>ROTATION</value>
</data>
<data name="Settings.LivenessCheck" xml:space="preserve">
<value>LIVENESS CHECK</value>
</data>
<data name="Settings.UseFeed" xml:space="preserve">
<value>Use the public proxifly feed</value>
</data>
<data name="Settings.ProbeUrl" xml:space="preserve">
<value>PROBE URL</value>
</data>
<data name="Settings.ProbeUrlHint" xml:space="preserve">
<value>Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones.</value>
</data>
<data name="Settings.ProbeTimeout" xml:space="preserve">
<value>PROBE TIMEOUT (SEC)</value>
</data>
<data name="Settings.ProbeConcurrency" xml:space="preserve">
<value>PARALLEL PROBES</value>
</data>
<data name="Settings.Rotation.Sticky" xml:space="preserve">
<value>One proxy per session, replaced only when it fails. Keeps site sessions intact.</value>
</data>
<data name="Settings.Rotation.RoundRobin" xml:space="preserve">
<value>A different proxy on every request. Spreads rate limits, but breaks session cookies.</value>
</data>
<data name="Settings.Rotation.WeightedRandom" xml:space="preserve">
<value>Random, weighted by feed score and how often the proxy has actually worked here.</value>
</data>
<data name="Settings.HealthCheck.Pool" xml:space="preserve">
<value>Probe the whole pool up front, in parallel. One sweep, then no per-request delay.</value>
</data>
<data name="Settings.HealthCheck.Lazy" xml:space="preserve">
<value>Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip.</value>
</data>
<data name="Settings.Breakpoints" xml:space="preserve">
<value>Layout breakpoints</value>
</data>
<data name="Settings.BreakpointsHint" xml:space="preserve">
<value>Window widths at which the navigation changes shape. Resize the window to see it happen.</value>
</data>
<data name="Settings.Breakpoint.Compact" xml:space="preserve">
<value>COMPACT</value>
</data>
<data name="Settings.Breakpoint.Medium" xml:space="preserve">
<value>MEDIUM</value>
</data>
<data name="Settings.Breakpoint.Expanded" xml:space="preserve">
<value>EXPANDED</value>
</data>
<data name="Settings.Breakpoint.CompactHint" xml:space="preserve">
<value>below {0} px — overlay drawer</value>
</data>
<data name="Settings.Breakpoint.MediumHint" xml:space="preserve">
<value>{0} {1} px — icon rail</value>
</data>
<data name="Settings.Breakpoint.ExpandedHint" xml:space="preserve">
<value>from {0} px — full sidebar</value>
</data>
<data name="About.BuiltWith" xml:space="preserve">
<value>Built with</value>
</data>
<data name="About.OnDisk" xml:space="preserve">
<value>On disk</value>
</data>
<data name="About.Data" xml:space="preserve">
<value>DATA</value>
</data>
<data name="About.Logs" xml:space="preserve">
<value>LOGS</value>
</data>
<data name="About.Component.Dotnet" xml:space="preserve">
<value>.NET</value>
</data>
<data name="About.Component.Os" xml:space="preserve">
<value>Operating system</value>
</data>
<data name="About.Component.Avalonia" xml:space="preserve">
<value>Avalonia</value>
</data>
<data name="About.Component.ReactiveUI" xml:space="preserve">
<value>ReactiveUI</value>
</data>
<data name="About.Component.Semi" xml:space="preserve">
<value>Semi.Avalonia</value>
</data>
<data name="About.Component.NotLoaded" xml:space="preserve">
<value>not loaded</value>
</data>
<data name="Enum.AppTheme.System" xml:space="preserve">
<value>System</value>
</data>
<data name="Enum.AppTheme.Light" xml:space="preserve">
<value>Light</value>
</data>
<data name="Enum.AppTheme.Dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="Enum.AppLanguage.System" xml:space="preserve">
<value>System</value>
</data>
<data name="Enum.AppLanguage.English" xml:space="preserve">
<value>English</value>
</data>
<data name="Enum.AppLanguage.Russian" xml:space="preserve">
<value>Русский</value>
</data>
<data name="Enum.ProxyRotation.Sticky" xml:space="preserve">
<value>Sticky</value>
</data>
<data name="Enum.ProxyRotation.RoundRobin" xml:space="preserve">
<value>Round-robin</value>
</data>
<data name="Enum.ProxyRotation.WeightedRandom" xml:space="preserve">
<value>Weighted random</value>
</data>
<data name="Enum.ProxyHealthCheck.Pool" xml:space="preserve">
<value>Whole pool</value>
</data>
<data name="Enum.ProxyHealthCheck.Lazy" xml:space="preserve">
<value>On hand-out</value>
</data>
<data name="Enum.ProxyProtocolFilter.All" xml:space="preserve">
<value>All</value>
</data>
<data name="Enum.ProxyProtocolFilter.Http" xml:space="preserve">
<value>HTTP</value>
</data>
<data name="Enum.ProxyProtocolFilter.Https" xml:space="preserve">
<value>HTTPS</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks4" xml:space="preserve">
<value>SOCKS4</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks5" xml:space="preserve">
<value>SOCKS5</value>
</data>
<data name="Enum.ProxyHealthFilter.All" xml:space="preserve">
<value>All</value>
</data>
<data name="Enum.ProxyHealthFilter.Alive" xml:space="preserve">
<value>Alive</value>
</data>
<data name="Enum.ProxyHealthFilter.Dead" xml:space="preserve">
<value>Dead</value>
</data>
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
<value>Unchecked</value>
</data>
<data name="Parser.delimited.Name" xml:space="preserve">
<value>Delimited text</value>
</data>
<data name="Parser.delimited.Description" xml:space="preserve">
<value>First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).</value>
</data>
<data name="Parser.key-value.Name" xml:space="preserve">
<value>Key / value pairs</value>
</data>
<data name="Parser.key-value.Description" xml:space="preserve">
<value>One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.</value>
</data>
<data name="Parse.Error.FieldCount" xml:space="preserve">
<value>Expected {0} field(s) but found {1}.</value>
</data>
<data name="Parse.Error.NoHeader" xml:space="preserve">
<value>Input contains no header row.</value>
</data>
<data name="Parse.Error.NoSeparator" xml:space="preserve">
<value>No '=' or ':' separator found.</value>
</data>
<data name="Parse.Error.EmptyKey" xml:space="preserve">
<value>Key is empty.</value>
</data>
</root>
@@ -0,0 +1,490 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Shell.ToggleNavigation" xml:space="preserve">
<value>Показать или скрыть навигацию</value>
</data>
<data name="Shell.Back" xml:space="preserve">
<value>Назад</value>
</data>
<data name="Shell.ToggleTheme" xml:space="preserve">
<value>Переключить светлую и тёмную тему</value>
</data>
<data name="Page.Dashboard" xml:space="preserve">
<value>Обзор</value>
</data>
<data name="Page.Parse" xml:space="preserve">
<value>Разбор</value>
</data>
<data name="Page.Proxies" xml:space="preserve">
<value>Прокси</value>
</data>
<data name="Page.Settings" xml:space="preserve">
<value>Настройки</value>
</data>
<data name="Page.About" xml:space="preserve">
<value>О программе</value>
</data>
<data name="Dashboard.Tagline" xml:space="preserve">
<value>Оболочка парсера с адаптивной раскладкой. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель.</value>
</data>
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
<value>Доступные парсеры</value>
</data>
<data name="Dashboard.GetStarted" xml:space="preserve">
<value>С чего начать</value>
</data>
<data name="Dashboard.OpenParser" xml:space="preserve">
<value>Открыть парсер</value>
</data>
<data name="Dashboard.DataDirectory" xml:space="preserve">
<value>КАТАЛОГ ДАННЫХ</value>
</data>
<data name="Parse.Parser" xml:space="preserve">
<value>ПАРСЕР</value>
</data>
<data name="Parse.Actions" xml:space="preserve">
<value>ДЕЙСТВИЯ</value>
</data>
<data name="Parse.Run" xml:space="preserve">
<value>Разобрать</value>
</data>
<data name="Parse.Cancel" xml:space="preserve">
<value>Отменить</value>
</data>
<data name="Parse.Sample" xml:space="preserve">
<value>Пример</value>
</data>
<data name="Parse.SampleTip" xml:space="preserve">
<value>Подставить небольшой пример</value>
</data>
<data name="Parse.LargeSample" xml:space="preserve">
<value>50k строк</value>
</data>
<data name="Parse.LargeSampleTip" xml:space="preserve">
<value>Сгенерировать 50 000 строк, чтобы были видны прогресс и отмена</value>
</data>
<data name="Parse.ClearTip" xml:space="preserve">
<value>Очистить ввод и результаты</value>
</data>
<data name="Parse.Input" xml:space="preserve">
<value>ВХОД</value>
</data>
<data name="Parse.InputPlaceholder" xml:space="preserve">
<value>Вставьте текст или нажмите «Пример»</value>
</data>
<data name="Parse.Records" xml:space="preserve">
<value>ЗАПИСИ</value>
</data>
<data name="Parse.Errors" xml:space="preserve">
<value>ОШИБКИ</value>
</data>
<data name="Parse.Count.Records.One" xml:space="preserve">
<value>{0} запись</value>
</data>
<data name="Parse.Count.Records.Few" xml:space="preserve">
<value>{0} записи</value>
</data>
<data name="Parse.Count.Records.Many" xml:space="preserve">
<value>{0} записей</value>
</data>
<data name="Parse.Count.Errors.One" xml:space="preserve">
<value>{0} ошибка</value>
</data>
<data name="Parse.Count.Errors.Few" xml:space="preserve">
<value>{0} ошибки</value>
</data>
<data name="Parse.Count.Errors.Many" xml:space="preserve">
<value>{0} ошибок</value>
</data>
<data name="Parse.Status.Done" xml:space="preserve">
<value>Разобрано: {0} за {1} мс.</value>
</data>
<data name="Parse.Status.Cancelled" xml:space="preserve">
<value>Отменено: {0} за {1} мс.</value>
</data>
<data name="Parse.Status.WithErrors" xml:space="preserve">
<value>{0}, {1}.</value>
</data>
<data name="Parse.Status.Truncated" xml:space="preserve">
<value>Показаны только первые {0}.</value>
</data>
<data name="Parse.Status.Failed" xml:space="preserve">
<value>Разбор не удался: {0}</value>
</data>
<data name="Proxies.Pool" xml:space="preserve">
<value>ПУЛ</value>
</data>
<data name="Proxies.Refresh" xml:space="preserve">
<value>Обновить</value>
</data>
<data name="Proxies.RefreshTip" xml:space="preserve">
<value>Перечитать все источники</value>
</data>
<data name="Proxies.CheckAll" xml:space="preserve">
<value>Проверить все</value>
</data>
<data name="Proxies.CheckAllTip" xml:space="preserve">
<value>Проверить каждую прокси в пуле</value>
</data>
<data name="Proxies.Search" xml:space="preserve">
<value>ПОИСК</value>
</data>
<data name="Proxies.SearchPlaceholder" xml:space="preserve">
<value>адрес или страна</value>
</data>
<data name="Proxies.Protocol" xml:space="preserve">
<value>ПРОТОКОЛ</value>
</data>
<data name="Proxies.StatusColumn" xml:space="preserve">
<value>СОСТОЯНИЕ</value>
</data>
<data name="Proxies.Alive" xml:space="preserve">
<value>живых</value>
</data>
<data name="Proxies.Total" xml:space="preserve">
<value>всего</value>
</data>
<data name="Proxies.Matched" xml:space="preserve">
<value>найдено</value>
</data>
<data name="Proxies.ListHeader" xml:space="preserve">
<value>ПРОКСИ</value>
</data>
<data name="Proxies.CustomHeader" xml:space="preserve">
<value>СВОИ ПРОКСИ</value>
</data>
<data name="Proxies.Add" xml:space="preserve">
<value>Добавить</value>
</data>
<data name="Proxies.Remove" xml:space="preserve">
<value>Удалить</value>
</data>
<data name="Proxies.RemoveTip" xml:space="preserve">
<value>Удалить выбранную свою прокси</value>
</data>
<data name="Proxies.ClearTip" xml:space="preserve">
<value>Очистить свой список</value>
</data>
<data name="Proxies.CustomHint" xml:space="preserve">
<value>По одной в строке. scheme://host:port или host:port для обычного HTTP. Поддерживается user:pass@.</value>
</data>
<data name="Proxies.Health.Alive" xml:space="preserve">
<value>живая</value>
</data>
<data name="Proxies.Health.Dead" xml:space="preserve">
<value>мёртвая</value>
</data>
<data name="Proxies.Health.Unchecked" xml:space="preserve">
<value>не проверена</value>
</data>
<data name="Proxies.Source.Feed" xml:space="preserve">
<value>фид</value>
</data>
<data name="Proxies.Source.Custom" xml:space="preserve">
<value>своя</value>
</data>
<data name="Proxies.Count.Proxies.One" xml:space="preserve">
<value>{0} прокси</value>
</data>
<data name="Proxies.Count.Proxies.Few" xml:space="preserve">
<value>{0} прокси</value>
</data>
<data name="Proxies.Count.Proxies.Many" xml:space="preserve">
<value>{0} прокси</value>
</data>
<data name="Proxies.Status.PoolHolds" xml:space="preserve">
<value>В пуле {0}.</value>
</data>
<data name="Proxies.Status.Nothing" xml:space="preserve">
<value>Нечего добавлять.</value>
</data>
<data name="Proxies.Status.Added" xml:space="preserve">
<value>Добавлено {0} из {1}.</value>
</data>
<data name="Proxies.Status.Rejected" xml:space="preserve">
<value>Не удалось разобрать: {0}</value>
</data>
<data name="Proxies.Status.RejectedMore" xml:space="preserve">
<value>и ещё {0}</value>
</data>
<data name="Proxies.Status.Answered" xml:space="preserve">
<value>Ответили {0} из {1}.</value>
</data>
<data name="Proxies.Status.CheckCancelled" xml:space="preserve">
<value>Проверка отменена.</value>
</data>
<data name="Proxies.Status.Removed" xml:space="preserve">
<value>Удалено: {0}.</value>
</data>
<data name="Proxies.Status.Cleared" xml:space="preserve">
<value>Свой список очищен.</value>
</data>
<data name="Proxies.Status.Failed" xml:space="preserve">
<value>Ошибка: {0}</value>
</data>
<data name="Settings.Appearance" xml:space="preserve">
<value>Внешний вид</value>
</data>
<data name="Settings.Theme" xml:space="preserve">
<value>ТЕМА</value>
</data>
<data name="Settings.ThemeHint" xml:space="preserve">
<value>«Системная» следует настройке светлой или тёмной темы в ОС.</value>
</data>
<data name="Settings.Language" xml:space="preserve">
<value>ЯЗЫК</value>
</data>
<data name="Settings.LanguageHint" xml:space="preserve">
<value>Применяется сразу, перезапуск не нужен.</value>
</data>
<data name="Settings.Diagnostics" xml:space="preserve">
<value>Диагностика</value>
</data>
<data name="Settings.LogLevel" xml:space="preserve">
<value>МИНИМАЛЬНЫЙ УРОВЕНЬ ЛОГА</value>
</data>
<data name="Settings.LogLevelHint" xml:space="preserve">
<value>Применяется сразу, перезапуск не нужен.</value>
</data>
<data name="Settings.SettingsFile" xml:space="preserve">
<value>ФАЙЛ НАСТРОЕК</value>
</data>
<data name="Settings.LogDirectory" xml:space="preserve">
<value>КАТАЛОГ ЛОГОВ</value>
</data>
<data name="Settings.Proxies" xml:space="preserve">
<value>Прокси</value>
</data>
<data name="Settings.Rotation" xml:space="preserve">
<value>РОТАЦИЯ</value>
</data>
<data name="Settings.LivenessCheck" xml:space="preserve">
<value>ПРОВЕРКА ЖИВОСТИ</value>
</data>
<data name="Settings.UseFeed" xml:space="preserve">
<value>Использовать публичный фид proxifly</value>
</data>
<data name="Settings.ProbeUrl" xml:space="preserve">
<value>URL ДЛЯ ПРОВЕРКИ</value>
</data>
<data name="Settings.ProbeUrlHint" xml:space="preserve">
<value>По умолчанию обычный HTTP: требование TLS отбраковало бы все прокси без CONNECT, а не только нерабочие.</value>
</data>
<data name="Settings.ProbeTimeout" xml:space="preserve">
<value>ТАЙМАУТ ПРОВЕРКИ (СЕК)</value>
</data>
<data name="Settings.ProbeConcurrency" xml:space="preserve">
<value>ПРОВЕРОК ПАРАЛЛЕЛЬНО</value>
</data>
<data name="Settings.Rotation.Sticky" xml:space="preserve">
<value>Одна прокси на сессию, смена только при отказе. Не рвёт сессии сайтов.</value>
</data>
<data name="Settings.Rotation.RoundRobin" xml:space="preserve">
<value>Новая прокси на каждый запрос. Размазывает рейт-лимиты, но ломает сессионные cookie.</value>
</data>
<data name="Settings.Rotation.WeightedRandom" xml:space="preserve">
<value>Случайно, с весом по оценке из фида и по доле реально удачных запросов.</value>
</data>
<data name="Settings.HealthCheck.Pool" xml:space="preserve">
<value>Проверить весь пул заранее и параллельно. Один прогон — и дальше без задержек.</value>
</data>
<data name="Settings.HealthCheck.Lazy" xml:space="preserve">
<value>Проверять прокси в момент выдачи. Без прогона, но каждая выдача стоит одного запроса.</value>
</data>
<data name="Settings.Breakpoints" xml:space="preserve">
<value>Точки перелома раскладки</value>
</data>
<data name="Settings.BreakpointsHint" xml:space="preserve">
<value>Ширины окна, на которых меняется форма навигации. Измените размер окна, чтобы увидеть.</value>
</data>
<data name="Settings.Breakpoint.Compact" xml:space="preserve">
<value>КОМПАКТНАЯ</value>
</data>
<data name="Settings.Breakpoint.Medium" xml:space="preserve">
<value>СРЕДНЯЯ</value>
</data>
<data name="Settings.Breakpoint.Expanded" xml:space="preserve">
<value>ШИРОКАЯ</value>
</data>
<data name="Settings.Breakpoint.CompactHint" xml:space="preserve">
<value>меньше {0} px — выезжающая панель</value>
</data>
<data name="Settings.Breakpoint.MediumHint" xml:space="preserve">
<value>{0} {1} px — рельс из иконок</value>
</data>
<data name="Settings.Breakpoint.ExpandedHint" xml:space="preserve">
<value>от {0} px — полная боковая панель</value>
</data>
<data name="About.BuiltWith" xml:space="preserve">
<value>Собрано на</value>
</data>
<data name="About.OnDisk" xml:space="preserve">
<value>На диске</value>
</data>
<data name="About.Data" xml:space="preserve">
<value>ДАННЫЕ</value>
</data>
<data name="About.Logs" xml:space="preserve">
<value>ЛОГИ</value>
</data>
<data name="About.Component.Dotnet" xml:space="preserve">
<value>.NET</value>
</data>
<data name="About.Component.Os" xml:space="preserve">
<value>Операционная система</value>
</data>
<data name="About.Component.Avalonia" xml:space="preserve">
<value>Avalonia</value>
</data>
<data name="About.Component.ReactiveUI" xml:space="preserve">
<value>ReactiveUI</value>
</data>
<data name="About.Component.Semi" xml:space="preserve">
<value>Semi.Avalonia</value>
</data>
<data name="About.Component.NotLoaded" xml:space="preserve">
<value>не загружено</value>
</data>
<data name="Enum.AppTheme.System" xml:space="preserve">
<value>Системная</value>
</data>
<data name="Enum.AppTheme.Light" xml:space="preserve">
<value>Светлая</value>
</data>
<data name="Enum.AppTheme.Dark" xml:space="preserve">
<value>Тёмная</value>
</data>
<data name="Enum.AppLanguage.System" xml:space="preserve">
<value>Системный</value>
</data>
<data name="Enum.AppLanguage.English" xml:space="preserve">
<value>English</value>
</data>
<data name="Enum.AppLanguage.Russian" xml:space="preserve">
<value>Русский</value>
</data>
<data name="Enum.ProxyRotation.Sticky" xml:space="preserve">
<value>Липкая</value>
</data>
<data name="Enum.ProxyRotation.RoundRobin" xml:space="preserve">
<value>По кругу</value>
</data>
<data name="Enum.ProxyRotation.WeightedRandom" xml:space="preserve">
<value>Случайная с весами</value>
</data>
<data name="Enum.ProxyHealthCheck.Pool" xml:space="preserve">
<value>Весь пул</value>
</data>
<data name="Enum.ProxyHealthCheck.Lazy" xml:space="preserve">
<value>При выдаче</value>
</data>
<data name="Enum.ProxyProtocolFilter.All" xml:space="preserve">
<value>Все</value>
</data>
<data name="Enum.ProxyProtocolFilter.Http" xml:space="preserve">
<value>HTTP</value>
</data>
<data name="Enum.ProxyProtocolFilter.Https" xml:space="preserve">
<value>HTTPS</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks4" xml:space="preserve">
<value>SOCKS4</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks5" xml:space="preserve">
<value>SOCKS5</value>
</data>
<data name="Enum.ProxyHealthFilter.All" xml:space="preserve">
<value>Все</value>
</data>
<data name="Enum.ProxyHealthFilter.Alive" xml:space="preserve">
<value>Живые</value>
</data>
<data name="Enum.ProxyHealthFilter.Dead" xml:space="preserve">
<value>Мёртвые</value>
</data>
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
<value>Не проверены</value>
</data>
<data name="Parser.delimited.Name" xml:space="preserve">
<value>Текст с разделителями</value>
</data>
<data name="Parser.delimited.Description" xml:space="preserve">
<value>Первая непустая строка — заголовок. Строки режутся по разделителю, преобладающему в нём (, ; таб |).</value>
</data>
<data name="Parser.key-value.Name" xml:space="preserve">
<value>Пары ключ / значение</value>
</data>
<data name="Parser.key-value.Description" xml:space="preserve">
<value>По паре в строке, разделитель «=» или «:». Строки с «#» — комментарии.</value>
</data>
<data name="Parse.Error.FieldCount" xml:space="preserve">
<value>Ожидалось полей: {0}, найдено: {1}.</value>
</data>
<data name="Parse.Error.NoHeader" xml:space="preserve">
<value>Во входных данных нет строки заголовка.</value>
</data>
<data name="Parse.Error.NoSeparator" xml:space="preserve">
<value>Не найден разделитель «=» или «:».</value>
</data>
<data name="Parse.Error.EmptyKey" xml:space="preserve">
<value>Пустой ключ.</value>
</data>
</root>
@@ -0,0 +1,16 @@
using AvParser.Core.Settings;
namespace AvParser.UI.Services;
/// <summary>Applies and persists the UI language.</summary>
public interface ILocalizationService
{
/// <summary>The language currently in effect.</summary>
AppLanguage Current { get; }
/// <summary>Emits the language, starting with the present value.</summary>
IObservable<AppLanguage> Changes { get; }
/// <summary>Applies a language to the running application and persists the choice.</summary>
void Apply(AppLanguage language);
}
@@ -0,0 +1,67 @@
using System.Globalization;
using Avalonia;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
using ReactiveUI.Primitives.Signals;
using Semi.Avalonia;
namespace AvParser.UI.Services;
/// <inheritdoc cref="ILocalizationService" />
public sealed class LocalizationService : ILocalizationService, IDisposable
{
private readonly ISettingsService _settings;
private readonly BehaviorSignal<AppLanguage> _current;
/// <summary>Restores the persisted language and applies it immediately.</summary>
public LocalizationService(ISettingsService settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_current = new BehaviorSignal<AppLanguage>(settings.Current.Language);
ApplyToApplication(settings.Current.Language);
}
/// <inheritdoc />
public AppLanguage Current => _current.Value;
/// <inheritdoc />
public IObservable<AppLanguage> Changes => _current;
/// <inheritdoc />
public void Apply(AppLanguage language)
{
if (language == _current.Value)
{
return;
}
ApplyToApplication(language);
_current.OnNext(language);
_settings.Update(current => current with { Language = language });
}
/// <inheritdoc />
public void Dispose() => _current.Dispose();
private static void ApplyToApplication(AppLanguage language)
{
var culture = AppLanguages.ToCulture(language);
Localizer.Instance.SetLanguage(language);
// Number and date formatting has to move with the language, or a Russian UI would still
// print "50,000" with an English group separator.
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
// Semi ships its own strings for built-in controls; without this its dialogs and pickers
// stay English while everything around them changes.
if (Application.Current is { } app)
{
SemiTheme.OverrideLocaleResources(app, culture);
}
}
}
+28 -8
View File
@@ -1,5 +1,7 @@
using System.Reflection; using System.Reflection;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
using AvParser.UI.Localization;
using ReactiveUI;
namespace AvParser.UI.ViewModels; namespace AvParser.UI.ViewModels;
@@ -11,6 +13,8 @@ public sealed record ComponentInfo(string Name, string Detail);
/// <summary>Version, runtime and stack information.</summary> /// <summary>Version, runtime and stack information.</summary>
public sealed class AboutViewModel : PageViewModel public sealed class AboutViewModel : PageViewModel
{ {
private readonly IReadOnlyList<(string Key, string Detail)> _components;
/// <summary>Creates the page.</summary> /// <summary>Creates the page.</summary>
public AboutViewModel(IAppPaths paths) public AboutViewModel(IAppPaths paths)
{ {
@@ -33,18 +37,20 @@ public sealed class AboutViewModel : PageViewModel
DataDirectory = paths.DataDirectory; DataDirectory = paths.DataDirectory;
LogDirectory = paths.LogDirectory; LogDirectory = paths.LogDirectory;
Components = _components =
[ [
new ComponentInfo(".NET", Environment.Version.ToString()), ("About.Component.Dotnet", Environment.Version.ToString()),
new ComponentInfo("Operating system", Environment.OSVersion.ToString()), ("About.Component.Os", Environment.OSVersion.ToString()),
new ComponentInfo("Avalonia", VersionOf("Avalonia.Base")), ("About.Component.Avalonia", VersionOf("Avalonia.Base")),
new ComponentInfo("ReactiveUI", VersionOf("ReactiveUI")), ("About.Component.ReactiveUI", VersionOf("ReactiveUI")),
new ComponentInfo("Semi.Avalonia", VersionOf("Semi.Avalonia")), ("About.Component.Semi", VersionOf("Semi.Avalonia")),
]; ];
Components = Build(_components);
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Title => "About"; public override string TitleKey => "Page.About";
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconInfo"; public override string IconKey => "IconInfo";
@@ -59,7 +65,21 @@ public sealed class AboutViewModel : PageViewModel
public string LogDirectory { get; } public string LogDirectory { get; }
/// <summary>The stack this build is running on.</summary> /// <summary>The stack this build is running on.</summary>
public IReadOnlyList<ComponentInfo> Components { get; } public IReadOnlyList<ComponentInfo> Components { get; private set; }
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// Component names are translated but their values are not, so rebuild the pairs rather
// than making ComponentInfo itself language-aware.
Components = Build(_components);
this.RaisePropertyChanged(nameof(Components));
}
private static IReadOnlyList<ComponentInfo> Build(IReadOnlyList<(string Key, string Detail)> components) =>
[.. components.Select(component => new ComponentInfo(Localizer.Instance[component.Key], component.Detail))];
private static string VersionOf(string assemblyName) private static string VersionOf(string assemblyName)
{ {
@@ -27,7 +27,7 @@ public sealed class DashboardViewModel : PageViewModel
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
Parsers = catalog.Parsers; Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))];
DataDirectory = paths.DataDirectory; DataDirectory = paths.DataDirectory;
GoToParseCommand = ReactiveCommand.Create(() => Navigate<ParseViewModel>()); GoToParseCommand = ReactiveCommand.Create(() => Navigate<ParseViewModel>());
@@ -35,13 +35,13 @@ public sealed class DashboardViewModel : PageViewModel
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Title => "Dashboard"; public override string TitleKey => "Page.Dashboard";
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconHome"; public override string IconKey => "IconHome";
/// <summary>Registered parsers, shown as cards.</summary> /// <summary>Registered parsers, shown as cards.</summary>
public IReadOnlyList<ITextParser> Parsers { get; } public IReadOnlyList<ParserViewModel> Parsers { 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; }
@@ -0,0 +1,40 @@
using AvParser.Core.Parsing;
using AvParser.UI.Localization;
using ReactiveUI;
namespace AvParser.UI.ViewModels;
/// <summary>One row of the parse error list, with its message translated.</summary>
/// <remarks>
/// Parsers are part of the domain and produce English text plus a code. This resolves
/// <c>Parse.Error.{Code}</c> and falls back to the parser's own wording, so a parser that has not
/// been translated yet still says something useful instead of showing a missing-key marker.
/// </remarks>
public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject
{
/// <summary>The underlying error.</summary>
public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error));
/// <summary>1-based position of the offending record.</summary>
public int LineNumber => Error.LineNumber;
/// <summary>Translated message.</summary>
public string Text
{
get
{
if (Error.Code is not { Length: > 0 } code)
{
return Error.Message;
}
var template = Localizer.Instance.GetOrDefault($"Parse.Error.{code}", Error.Message);
return Error.Arguments.Count == 0
? template
: string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]);
}
}
/// <summary>Re-reads the translated message.</summary>
public void Refresh() => this.RaisePropertyChanged(nameof(Text));
}
+41 -21
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Text; using System.Text;
using AvParser.Core.Parsing; using AvParser.Core.Parsing;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.UI.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.Primitives; using ReactiveUI.Primitives;
@@ -42,7 +43,7 @@ public partial class ParseViewModel : PageViewModel
/// <summary>Parser applied by <see cref="ParseCommand"/>.</summary> /// <summary>Parser applied by <see cref="ParseCommand"/>.</summary>
[Reactive] [Reactive]
public partial ITextParser SelectedParser { get; set; } public partial ParserViewModel SelectedParser { get; set; }
/// <summary>Completion of the running parse, 0.0 to 1.0.</summary> /// <summary>Completion of the running parse, 0.0 to 1.0.</summary>
[Reactive] [Reactive]
@@ -73,7 +74,8 @@ public partial class ParseViewModel : PageViewModel
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
InputText = string.Empty; InputText = string.Empty;
SelectedParser = catalog.FindOrDefault(settings.Current.LastParserId); Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))];
SelectedParser = Parsers.First(parser => parser.Id == catalog.FindOrDefault(settings.Current.LastParserId).Id);
var canParse = this.WhenAnyValue(x => x.InputText) var canParse = this.WhenAnyValue(x => x.InputText)
.Select(static text => !string.IsNullOrWhiteSpace(text)) .Select(static text => !string.IsNullOrWhiteSpace(text))
@@ -118,19 +120,19 @@ public partial class ParseViewModel : PageViewModel
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Title => "Parse"; public override string TitleKey => "Page.Parse";
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconDocument"; public override string IconKey => "IconDocument";
/// <summary>Every registered parser, for the picker.</summary> /// <summary>Every registered parser, for the picker.</summary>
public IReadOnlyList<ITextParser> Parsers => _catalog.Parsers; public IReadOnlyList<ParserViewModel> Parsers { get; }
/// <summary>Successfully parsed records, capped at <see cref="MaxDisplayedRecords"/>.</summary> /// <summary>Successfully parsed records, capped at <see cref="MaxDisplayedRecords"/>.</summary>
public ObservableCollection<ParsedRecord> Records { get; } = []; public ObservableCollection<ParsedRecord> Records { get; } = [];
/// <summary>Per-line failures. A failure never aborts the parse.</summary> /// <summary>Per-line failures. A failure never aborts the parse.</summary>
public ObservableCollection<ParseError> Errors { get; } = []; public ObservableCollection<ParseErrorViewModel> Errors { get; } = [];
/// <summary>Whether a parse is currently running.</summary> /// <summary>Whether a parse is currently running.</summary>
public bool IsBusy => _isBusy.Value; public bool IsBusy => _isBusy.Value;
@@ -155,7 +157,7 @@ public partial class ParseViewModel : PageViewModel
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken); using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken);
_cancellation = cancellation; _cancellation = cancellation;
var parser = SelectedParser; var parser = SelectedParser.Parser;
var input = InputText; var input = InputText;
var token = cancellation.Token; var token = cancellation.Token;
@@ -228,29 +230,33 @@ public partial class ParseViewModel : PageViewModel
); );
} }
private static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled) /// <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 text = new StringBuilder(); var loc = Localizer.Instance;
text.Append(cancelled ? "Cancelled after " : "Parsed "); var records = loc.Plural("Parse.Count.Records", succeeded);
text.Append(succeeded.ToString("N0", CultureInfo.CurrentCulture)); var milliseconds = elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture);
text.Append(succeeded == 1 ? " record" : " records");
var text = loc.Format(cancelled ? "Parse.Status.Cancelled" : "Parse.Status.Done", records, milliseconds);
if (failed > 0) if (failed > 0)
{ {
text.Append(", ").Append(failed.ToString("N0", CultureInfo.CurrentCulture)); text = loc.Format("Parse.Status.WithErrors", text.TrimEnd('.'), loc.Plural("Parse.Count.Errors", failed));
text.Append(failed == 1 ? " error" : " errors");
} }
text.Append(" in ").Append(elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture)).Append(" ms");
if (truncated) if (truncated)
{ {
text.Append(" — showing the first ") text += " " + loc.Format("Parse.Status.Truncated", loc.Plural("Parse.Count.Records", MaxDisplayedRecords));
.Append(MaxDisplayedRecords.ToString("N0", CultureInfo.CurrentCulture))
.Append(" only");
} }
return text.Append('.').ToString(); return text;
} }
private void FlushBuffers(List<ParsedRecord> records, List<ParseError> errors) private void FlushBuffers(List<ParsedRecord> records, List<ParseError> errors)
@@ -275,11 +281,25 @@ public partial class ParseViewModel : PageViewModel
foreach (var error in errorBatch) foreach (var error in errorBatch)
{ {
Errors.Add(error); Errors.Add(new ParseErrorViewModel(error));
} }
}); });
} }
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// 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() private void ClearResults()
{ {
Records.Clear(); Records.Clear();
@@ -289,7 +309,7 @@ public partial class ParseViewModel : PageViewModel
private void OnCommandFailed(Exception exception) private void OnCommandFailed(Exception exception)
{ {
_logger.LogError(exception, "Parse failed"); _logger.LogError(exception, "Parse failed");
OnUi(() => StatusMessage = $"Parse failed: {exception.Message}"); 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> /// <summary>Marshals a mutation onto the UI thread; the parse loop runs on the thread pool.</summary>
@@ -0,0 +1,43 @@
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));
}
}
+47 -24
View File
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using AvParser.Core.Proxies; using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies; using AvParser.Infrastructure.Proxies;
using AvParser.UI.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.Primitives; using ReactiveUI.Primitives;
@@ -49,11 +50,11 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary> /// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary>
[Reactive] [Reactive]
public partial ProxyProtocolFilter ProtocolFilter { get; set; } public partial LocalizedOption<ProxyProtocolFilter> ProtocolFilter { get; set; }
/// <summary>Health filter.</summary> /// <summary>Health filter.</summary>
[Reactive] [Reactive]
public partial ProxyHealthFilter HealthFilter { get; set; } public partial LocalizedOption<ProxyHealthFilter> HealthFilter { get; set; }
/// <summary>Text box contents for adding custom proxies.</summary> /// <summary>Text box contents for adding custom proxies.</summary>
[Reactive] [Reactive]
@@ -106,8 +107,8 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
SearchText = string.Empty; SearchText = string.Empty;
NewProxies = string.Empty; NewProxies = string.Empty;
ProtocolFilter = ProxyProtocolFilter.All; ProtocolFilter = ProtocolFilters[0];
HealthFilter = ProxyHealthFilter.All; HealthFilter = HealthFilters[0];
var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping); var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping);
@@ -147,7 +148,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Title => "Proxies"; public override string TitleKey => "Page.Proxies";
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconShield"; public override string IconKey => "IconShield";
@@ -156,18 +157,18 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
public ObservableCollection<ProxyRowViewModel> Proxies { get; } = []; public ObservableCollection<ProxyRowViewModel> Proxies { get; } = [];
/// <summary>Protocol filter options.</summary> /// <summary>Protocol filter options.</summary>
public IReadOnlyList<ProxyProtocolFilter> ProtocolFilters { get; } = public IReadOnlyList<LocalizedOption<ProxyProtocolFilter>> ProtocolFilters { get; } =
[ LocalizedOption<ProxyProtocolFilter>.For(
ProxyProtocolFilter.All, ProxyProtocolFilter.All,
ProxyProtocolFilter.Http, ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https, ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4, ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5, ProxyProtocolFilter.Socks5
]; );
/// <summary>Health filter options.</summary> /// <summary>Health filter options.</summary>
public IReadOnlyList<ProxyHealthFilter> HealthFilters { get; } = public IReadOnlyList<LocalizedOption<ProxyHealthFilter>> HealthFilters { get; } =
[ProxyHealthFilter.All, ProxyHealthFilter.Alive, ProxyHealthFilter.Dead, ProxyHealthFilter.Unchecked]; LocalizedOption<ProxyHealthFilter>.ForAll();
/// <summary>Reloads every source into the pool.</summary> /// <summary>Reloads every source into the pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; } public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
@@ -187,7 +188,12 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private async Task RefreshAsync(CancellationToken cancellationToken) private async Task RefreshAsync(CancellationToken cancellationToken)
{ {
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false); var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Pool holds {Format(count)} prox{(count == 1 ? "y" : "ies")}."); var message = Localizer.Instance.Format(
"Proxies.Status.PoolHolds",
Localizer.Instance.Plural("Proxies.Count.Proxies", count)
);
OnUi(() => StatusMessage = message);
} }
private async Task SweepAsync(CancellationToken cancellationToken) private async Task SweepAsync(CancellationToken cancellationToken)
@@ -204,12 +210,13 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction)); var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false); var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count; var total = _pool.Entries.Count;
var message = Localizer.Instance.Format("Proxies.Status.Answered", Format(alive), Format(total));
OnUi(() => StatusMessage = $"{Format(alive)} of {Format(total)} answered."); OnUi(() => StatusMessage = message);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
OnUi(() => StatusMessage = "Check cancelled."); OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.CheckCancelled"]);
} }
finally finally
{ {
@@ -225,24 +232,28 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
{ {
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies); var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
var loc = Localizer.Instance;
if (parsed.Count == 0 && rejected.Count == 0) if (parsed.Count == 0 && rejected.Count == 0)
{ {
OnUi(() => StatusMessage = "Nothing to add."); OnUi(() => StatusMessage = loc["Proxies.Status.Nothing"]);
return; return;
} }
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false); var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false); await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = $"Added {Format(added)} of {Format(parsed.Count)}."; var message = loc.Format("Proxies.Status.Added", Format(added), Format(parsed.Count));
if (rejected.Count > 0) if (rejected.Count > 0)
{ {
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long. // Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
message += $" Could not parse: {string.Join(", ", rejected.Take(3))}"; var sample = string.Join(", ", rejected.Take(3));
if (rejected.Count > 3) if (rejected.Count > 3)
{ {
message += $" and {Format(rejected.Count - 3)} more"; sample += " " + loc.Format("Proxies.Status.RejectedMore", Format(rejected.Count - 3));
} }
message += " " + loc.Format("Proxies.Status.Rejected", sample);
} }
OnUi(() => OnUi(() =>
@@ -265,7 +276,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false); await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false); await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Removed {row.Address}."); OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Removed", row.Address));
} }
private async Task ClearCustomAsync(CancellationToken cancellationToken) private async Task ClearCustomAsync(CancellationToken cancellationToken)
@@ -273,7 +284,19 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false); await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false); await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = "Custom list cleared."); OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.Cleared"]);
}
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// Health and source captions live on the rows, so they need the nudge individually.
foreach (var row in _rows.Values)
{
row.Refresh();
}
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -333,14 +356,14 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private bool PassesFilters(ProxyRowViewModel row) private bool PassesFilters(ProxyRowViewModel row)
{ {
if ( if (
ProtocolFilter != ProxyProtocolFilter.All ProtocolFilter.Value != ProxyProtocolFilter.All
&& !ProtocolFilter.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol)) && !ProtocolFilter.Value.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
) )
{ {
return false; return false;
} }
var healthOk = HealthFilter switch var healthOk = HealthFilter.Value switch
{ {
ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive, ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive,
ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead, ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead,
@@ -354,7 +377,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private void OnCommandFailed(Exception exception) private void OnCommandFailed(Exception exception)
{ {
_logger.LogError(exception, "Proxy action failed"); _logger.LogError(exception, "Proxy action failed");
OnUi(() => StatusMessage = $"Failed: {exception.Message}"); OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Failed", exception.Message));
} }
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture); private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using AvParser.Core.Proxies; using AvParser.Core.Proxies;
using AvParser.UI.Localization;
using ReactiveUI; using ReactiveUI;
namespace AvParser.UI.ViewModels; namespace AvParser.UI.ViewModels;
@@ -35,16 +36,18 @@ public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject
public bool IsCustom => Entry.Source == ProxySourceKind.Custom; public bool IsCustom => Entry.Source == ProxySourceKind.Custom;
/// <summary>Source label.</summary> /// <summary>Source label.</summary>
public string Source => IsCustom ? "custom" : "feed"; public string Source => Localizer.Instance[IsCustom ? "Proxies.Source.Custom" : "Proxies.Source.Feed"];
/// <summary>Last known health, as a word.</summary> /// <summary>Last known health, as a word.</summary>
public string HealthText => public string HealthText =>
Localizer.Instance[
Entry.Health switch Entry.Health switch
{ {
ProxyHealthState.Alive => "alive", ProxyHealthState.Alive => "Proxies.Health.Alive",
ProxyHealthState.Dead => "dead", ProxyHealthState.Dead => "Proxies.Health.Dead",
_ => "unchecked", _ => "Proxies.Health.Unchecked",
}; }
];
/// <summary>Whether the last check succeeded. Drives the row's accent.</summary> /// <summary>Whether the last check succeeded. Drives the row's accent.</summary>
public bool IsAlive => Entry.Health == ProxyHealthState.Alive; public bool IsAlive => Entry.Health == ProxyHealthState.Alive;
@@ -68,9 +71,10 @@ public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject
/// <summary>Whether the entry is sidelined right now.</summary> /// <summary>Whether the entry is sidelined right now.</summary>
public bool IsQuarantined => Entry.QuarantinedUntilUtc is { } until && until > DateTimeOffset.UtcNow; public bool IsQuarantined => Entry.QuarantinedUntilUtc is { } until && until > DateTimeOffset.UtcNow;
/// <summary>Re-reads everything that the pool can change behind our back.</summary> /// <summary>Re-reads everything that the pool — or the language — can change behind our back.</summary>
public void Refresh() public void Refresh()
{ {
this.RaisePropertyChanged(nameof(Source));
this.RaisePropertyChanged(nameof(HealthText)); this.RaisePropertyChanged(nameof(HealthText));
this.RaisePropertyChanged(nameof(IsAlive)); this.RaisePropertyChanged(nameof(IsAlive));
this.RaisePropertyChanged(nameof(IsDead)); this.RaisePropertyChanged(nameof(IsDead));
+68 -29
View File
@@ -2,6 +2,7 @@ using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.Infrastructure.Logging; using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
using AvParser.UI.Localization;
using AvParser.UI.Responsive; using AvParser.UI.Responsive;
using AvParser.UI.Services; using AvParser.UI.Services;
using ReactiveUI; using ReactiveUI;
@@ -19,10 +20,15 @@ public partial class SettingsViewModel : PageViewModel
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly LoggingLevelSwitch _levelSwitch; private readonly LoggingLevelSwitch _levelSwitch;
private readonly IProxyPool _proxyPool; private readonly IProxyPool _proxyPool;
private readonly ILocalizationService _localization;
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary> /// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
[Reactive] [Reactive]
public partial AppTheme SelectedTheme { get; set; } public partial LocalizedOption<AppTheme> SelectedTheme { get; set; }
/// <summary>Selected UI language. Applied immediately.</summary>
[Reactive]
public partial LocalizedOption<AppLanguage> SelectedLanguage { get; set; }
/// <summary>Selected Serilog level name. Takes effect immediately.</summary> /// <summary>Selected Serilog level name. Takes effect immediately.</summary>
[Reactive] [Reactive]
@@ -30,11 +36,11 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>How the pool picks the next proxy.</summary> /// <summary>How the pool picks the next proxy.</summary>
[Reactive] [Reactive]
public partial ProxyRotation SelectedRotation { get; set; } public partial LocalizedOption<ProxyRotation> SelectedRotation { get; set; }
/// <summary>When proxy liveness is verified.</summary> /// <summary>When proxy liveness is verified.</summary>
[Reactive] [Reactive]
public partial ProxyHealthCheck SelectedHealthCheck { get; set; } public partial LocalizedOption<ProxyHealthCheck> SelectedHealthCheck { get; set; }
/// <summary>Whether the remote proxy feed is consulted.</summary> /// <summary>Whether the remote proxy feed is consulted.</summary>
[Reactive] [Reactive]
@@ -59,6 +65,7 @@ public partial class SettingsViewModel : PageViewModel
IAppPaths paths, IAppPaths paths,
LoggingLevelSwitch levelSwitch, LoggingLevelSwitch levelSwitch,
IProxyPool proxyPool, IProxyPool proxyPool,
ILocalizationService localization,
ISequencer? mainThread = null ISequencer? mainThread = null
) )
{ {
@@ -66,6 +73,7 @@ public partial class SettingsViewModel : PageViewModel
_theme = theme ?? throw new ArgumentNullException(nameof(theme)); _theme = theme ?? throw new ArgumentNullException(nameof(theme));
_levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch)); _levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch));
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
_localization = localization ?? throw new ArgumentNullException(nameof(localization));
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler; var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
@@ -73,26 +81,31 @@ public partial class SettingsViewModel : PageViewModel
SettingsFile = paths.SettingsFile; SettingsFile = paths.SettingsFile;
LogDirectory = paths.LogDirectory; LogDirectory = paths.LogDirectory;
SelectedTheme = theme.Current; SelectedTheme = Option(Themes, theme.Current);
SelectedLanguage = Option(Languages, localization.Current);
SelectedLogLevel = settings.Current.MinimumLogLevel; SelectedLogLevel = settings.Current.MinimumLogLevel;
var current = settings.Current; var current = settings.Current;
SelectedRotation = current.ProxyRotation; SelectedRotation = Option(Rotations, current.ProxyRotation);
SelectedHealthCheck = current.ProxyHealthCheck; SelectedHealthCheck = Option(HealthChecks, current.ProxyHealthCheck);
UseProxyFeed = current.ProxyUseFeed; UseProxyFeed = current.ProxyUseFeed;
ProxyProbeUrl = current.ProxyProbeUrl; ProxyProbeUrl = current.ProxyProbeUrl;
ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds; ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds;
ProxyProbeConcurrency = current.ProxyProbeConcurrency; ProxyProbeConcurrency = current.ProxyProbeConcurrency;
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply); this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLanguage)
.ObserveOn(scheduler)
.Subscribe(option => _localization.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLogLevel) this.WhenAnyValue(x => x.SelectedLogLevel)
.Where(static level => !string.IsNullOrEmpty(level)) .Where(static level => !string.IsNullOrEmpty(level))
.DistinctUntilChanged() .DistinctUntilChanged()
.Subscribe(ApplyLogLevel); .Subscribe(ApplyLogLevel);
// Keep the radio group honest when the theme is flipped from the title-bar button. // Keep the picker honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value); theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = Option(Themes, value));
// Every proxy knob funnels through one handler: they all end up rebuilding the same // Every proxy knob funnels through one handler: they all end up rebuilding the same
// ProxyOptions, and applying them one at a time would reconfigure the pool six times. // ProxyOptions, and applying them one at a time would reconfigure the pool six times.
@@ -117,39 +130,42 @@ public partial class SettingsViewModel : PageViewModel
} }
/// <inheritdoc /> /// <inheritdoc />
public override string Title => "Settings"; public override string TitleKey => "Page.Settings";
/// <inheritdoc /> /// <inheritdoc />
public override string IconKey => "IconSettings"; public override string IconKey => "IconSettings";
/// <summary>Theme options offered by the radio group.</summary> /// <summary>Theme options offered by the picker.</summary>
public IReadOnlyList<AppTheme> Themes { get; } = [AppTheme.System, AppTheme.Light, AppTheme.Dark]; public IReadOnlyList<LocalizedOption<AppTheme>> Themes { get; } = LocalizedOption<AppTheme>.ForAll();
/// <summary>Language options offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<AppLanguage>> Languages { get; } = LocalizedOption<AppLanguage>.ForAll();
/// <summary>Serilog level names, most to least verbose.</summary> /// <summary>Serilog level names, most to least verbose.</summary>
public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels; public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels;
/// <summary>Rotation strategies offered by the picker.</summary> /// <summary>Rotation strategies offered by the picker.</summary>
public IReadOnlyList<ProxyRotation> Rotations { get; } = public IReadOnlyList<LocalizedOption<ProxyRotation>> Rotations { get; } = LocalizedOption<ProxyRotation>.ForAll();
[ProxyRotation.Sticky, ProxyRotation.RoundRobin, ProxyRotation.WeightedRandom];
/// <summary>Liveness policies offered by the picker.</summary> /// <summary>Liveness policies offered by the picker.</summary>
public IReadOnlyList<ProxyHealthCheck> HealthChecks { get; } = [ProxyHealthCheck.Pool, ProxyHealthCheck.Lazy]; public IReadOnlyList<LocalizedOption<ProxyHealthCheck>> HealthChecks { get; } =
LocalizedOption<ProxyHealthCheck>.ForAll();
/// <summary>Explains the selected rotation in one line.</summary> /// <summary>Explains the selected rotation in one line.</summary>
public string RotationDescription => public string RotationDescription => Localizer.Instance[$"Settings.Rotation.{SelectedRotation.Value}"];
SelectedRotation switch
{
ProxyRotation.Sticky => "One proxy per session, replaced only when it fails. Keeps site sessions intact.",
ProxyRotation.RoundRobin =>
"A different proxy on every request. Spreads rate limits, but breaks session cookies.",
_ => "Random, weighted by feed score and how often the proxy has actually worked here.",
};
/// <summary>Explains the selected health-check policy in one line.</summary> /// <summary>Explains the selected health-check policy in one line.</summary>
public string HealthCheckDescription => public string HealthCheckDescription => Localizer.Instance[$"Settings.HealthCheck.{SelectedHealthCheck.Value}"];
SelectedHealthCheck == ProxyHealthCheck.Pool
? "Probe the whole pool up front, in parallel. One sweep, then no per-request delay." /// <summary>Describes the compact breakpoint band.</summary>
: "Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip."; public string CompactHint => Localizer.Instance.Format("Settings.Breakpoint.CompactHint", MediumBreakpoint);
/// <summary>Describes the medium breakpoint band.</summary>
public string MediumHint =>
Localizer.Instance.Format("Settings.Breakpoint.MediumHint", MediumBreakpoint, ExpandedBreakpoint);
/// <summary>Describes the expanded breakpoint band.</summary>
public string ExpandedHint => Localizer.Instance.Format("Settings.Breakpoint.ExpandedHint", ExpandedBreakpoint);
/// <summary>Full path of the settings file.</summary> /// <summary>Full path of the settings file.</summary>
public string SettingsFile { get; } public string SettingsFile { get; }
@@ -163,6 +179,29 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>Width in pixels at which the shell switches to the full sidebar.</summary> /// <summary>Width in pixels at which the shell switches to the full sidebar.</summary>
public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth; public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth;
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
foreach (
var name in new[]
{
nameof(RotationDescription),
nameof(HealthCheckDescription),
nameof(CompactHint),
nameof(MediumHint),
nameof(ExpandedHint),
}
)
{
this.RaisePropertyChanged(name);
}
}
private static LocalizedOption<TValue> Option<TValue>(IReadOnlyList<LocalizedOption<TValue>> options, TValue value)
where TValue : struct, Enum => options.First(option => option.Value.Equals(value));
private void ApplyLogLevel(string level) private void ApplyLogLevel(string level)
{ {
_levelSwitch.MinimumLevel = AppLogging.ParseLevel(level); _levelSwitch.MinimumLevel = AppLogging.ParseLevel(level);
@@ -177,8 +216,8 @@ public partial class SettingsViewModel : PageViewModel
{ {
applied = current with applied = current with
{ {
ProxyRotation = SelectedRotation, ProxyRotation = SelectedRotation.Value,
ProxyHealthCheck = SelectedHealthCheck, ProxyHealthCheck = SelectedHealthCheck.Value,
ProxyUseFeed = UseProxyFeed, ProxyUseFeed = UseProxyFeed,
ProxyProbeUrl = ProxyProbeUrl, ProxyProbeUrl = ProxyProbeUrl,
ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds, ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds,
+12 -6
View File
@@ -22,7 +22,6 @@ public partial class ShellViewModel : ViewModelBase
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly ObservableAsPropertyHelper<SplitViewDisplayMode> _paneDisplayMode; private readonly ObservableAsPropertyHelper<SplitViewDisplayMode> _paneDisplayMode;
private readonly ObservableAsPropertyHelper<PageViewModel> _currentPage; private readonly ObservableAsPropertyHelper<PageViewModel> _currentPage;
private readonly ObservableAsPropertyHelper<string> _title;
private readonly ObservableAsPropertyHelper<bool> _canGoBack; private readonly ObservableAsPropertyHelper<bool> _canGoBack;
private readonly ObservableAsPropertyHelper<string> _themeIconKey; private readonly ObservableAsPropertyHelper<string> _themeIconKey;
@@ -58,10 +57,6 @@ public partial class ShellViewModel : ViewModelBase
_currentPage = navigation.CurrentChanges.ToProperty(this, nameof(CurrentPage), navigation.Current, scheduler); _currentPage = navigation.CurrentChanges.ToProperty(this, nameof(CurrentPage), navigation.Current, scheduler);
_title = navigation
.CurrentChanges.Select(static page => page.Title)
.ToProperty(this, nameof(Title), navigation.Current.Title, scheduler);
_canGoBack = navigation.CanGoBack.ToProperty(this, nameof(CanGoBack), false, scheduler); _canGoBack = navigation.CanGoBack.ToProperty(this, nameof(CanGoBack), false, scheduler);
_paneDisplayMode = this.WhenAnyValue(x => x.Breakpoint) _paneDisplayMode = this.WhenAnyValue(x => x.Breakpoint)
@@ -85,6 +80,12 @@ public partial class ShellViewModel : ViewModelBase
.Select(static breakpoint => breakpoint == Breakpoint.Expanded) .Select(static breakpoint => breakpoint == Breakpoint.Expanded)
.Subscribe(open => IsPaneOpen = open); .Subscribe(open => IsPaneOpen = open);
// The title tracks the page, and the page tracks the language.
this.WhenAnyValue(x => x.CurrentPage)
.Select(static page => page.WhenAnyValue(inner => inner.Title))
.Switch()
.Subscribe(_ => this.RaisePropertyChanged(nameof(Title)));
// Rail selection drives navigation... // Rail selection drives navigation...
this.WhenAnyValue(x => x.SelectedPage).Subscribe(_navigation.NavigateTo); this.WhenAnyValue(x => x.SelectedPage).Subscribe(_navigation.NavigateTo);
@@ -113,7 +114,12 @@ public partial class ShellViewModel : ViewModelBase
public PageViewModel CurrentPage => _currentPage.Value; public PageViewModel CurrentPage => _currentPage.Value;
/// <summary>Title of the current page.</summary> /// <summary>Title of the current page.</summary>
public string Title => _title.Value; /// <remarks>
/// Read straight off the page rather than snapshotted into a derived property: the page
/// re-raises its own title when the language changes, and a snapshot would keep showing the
/// caption from the previous language until the user navigated somewhere else.
/// </remarks>
public string Title => CurrentPage.Title;
/// <summary>Whether the back button is enabled.</summary> /// <summary>Whether the back button is enabled.</summary>
public bool CanGoBack => _canGoBack.Value; public bool CanGoBack => _canGoBack.Value;
+20 -2
View File
@@ -1,3 +1,4 @@
using AvParser.UI.Localization;
using ReactiveUI; using ReactiveUI;
namespace AvParser.UI.ViewModels; namespace AvParser.UI.ViewModels;
@@ -17,11 +18,28 @@ public abstract class ViewModelBase : ReactiveObject, IActivatableViewModel
/// <summary>A view model that appears as a top-level destination in the navigation rail.</summary> /// <summary>A view model that appears as a top-level destination in the navigation rail.</summary>
public abstract class PageViewModel : ViewModelBase public abstract class PageViewModel : ViewModelBase
{ {
/// <summary>Label shown in the sidebar and the title bar.</summary> /// <summary>Creates the page and keeps its title in step with the language.</summary>
public abstract string Title { get; } protected PageViewModel() => Localizer.Instance.LanguageChanged += OnLanguageChanged;
/// <summary>Resource key of the label shown in the sidebar and the title bar.</summary>
public abstract string TitleKey { get; }
/// <summary>Translated label.</summary>
public string Title => Localizer.Instance[TitleKey];
/// <summary> /// <summary>
/// Key of a <c>StreamGeometry</c> in <c>Styles/Icons.axaml</c> used as the rail icon. /// Key of a <c>StreamGeometry</c> in <c>Styles/Icons.axaml</c> used as the rail icon.
/// </summary> /// </summary>
public abstract string IconKey { get; } public abstract string IconKey { get; }
/// <summary>
/// Re-reads anything derived from the current language.
/// </summary>
/// <remarks>
/// Overriders must call the base: the rail and the title bar bind to <see cref="Title"/>, and
/// without the notification they would keep the caption from the previous language.
/// </remarks>
protected virtual void OnLanguageChanged() => this.RaisePropertyChanged(nameof(Title));
private void OnLanguageChanged(object? sender, EventArgs e) => OnLanguageChanged();
} }
+5 -4
View File
@@ -1,6 +1,7 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
x:Class="AvParser.UI.Views.AboutView" x:Class="AvParser.UI.Views.AboutView"
x:DataType="vm:AboutViewModel" x:DataType="vm:AboutViewModel"
@@ -18,7 +19,7 @@
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Built with" /> <TextBlock Classes="subtitle" Text="{l:Loc About.BuiltWith}" />
<ItemsControl ItemsSource="{Binding Components}"> <ItemsControl ItemsSource="{Binding Components}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentInfo"> <DataTemplate x:DataType="vm:ComponentInfo">
@@ -34,13 +35,13 @@
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="On disk" /> <TextBlock Classes="subtitle" Text="{l:Loc About.OnDisk}" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="DATA" /> <TextBlock Classes="caption" Text="{l:Loc About.Data}" />
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" /> <SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="LOGS" /> <TextBlock Classes="caption" Text="{l:Loc About.Logs}" />
<SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" /> <SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
+9 -13
View File
@@ -1,8 +1,8 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core"
x:Class="AvParser.UI.Views.DashboardView" x:Class="AvParser.UI.Views.DashboardView"
x:DataType="vm:DashboardViewModel" x:DataType="vm:DashboardViewModel"
> >
@@ -10,15 +10,11 @@
<StackPanel Spacing="24" MaxWidth="1040" HorizontalAlignment="Left"> <StackPanel Spacing="24" MaxWidth="1040" HorizontalAlignment="Left">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="display" Text="AvParser" /> <TextBlock Classes="display" Text="AvParser" />
<TextBlock <TextBlock Classes="muted" MaxWidth="640" Text="{l:Loc Dashboard.Tagline}" />
Classes="muted"
MaxWidth="640"
Text="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."
/>
</StackPanel> </StackPanel>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Registered parsers" /> <TextBlock Classes="subtitle" Text="{l:Loc Dashboard.RegisteredParsers}" />
<ItemsControl ItemsSource="{Binding Parsers}"> <ItemsControl ItemsSource="{Binding Parsers}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
@@ -26,10 +22,10 @@
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="parsing:ITextParser"> <DataTemplate x:DataType="vm:ParserViewModel">
<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 DisplayName}" /> <TextBlock Classes="subtitle" Text="{Binding Name}" />
<Border Classes="chip accent" HorizontalAlignment="Left"> <Border Classes="chip accent" HorizontalAlignment="Left">
<TextBlock Classes="mono caption" Text="{Binding Id}" /> <TextBlock Classes="mono caption" Text="{Binding Id}" />
</Border> </Border>
@@ -42,18 +38,18 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Get started" /> <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 GoToParseCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
<TextBlock Text="Open the parser" /> <TextBlock Text="{l:Loc Dashboard.OpenParser}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Command="{Binding GoToSettingsCommand}"> <Button Command="{Binding GoToSettingsCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconSettings}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconSettings}" />
<TextBlock Text="Settings" /> <TextBlock Text="{l:Loc Page.Settings}" />
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> </StackPanel>
@@ -61,7 +57,7 @@
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="DATA DIRECTORY" /> <TextBlock Classes="caption" Text="{l:Loc Dashboard.DataDirectory}" />
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" /> <SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</Border> </Border>
+18 -20
View File
@@ -1,6 +1,7 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters" xmlns:conv="clr-namespace:AvParser.UI.Converters"
xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core" xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core"
@@ -13,7 +14,7 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="240"> <StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="240">
<TextBlock Classes="caption" Text="PARSER" /> <TextBlock Classes="caption" Text="{l:Loc Parse.Parser}" />
<ComboBox <ComboBox
ItemsSource="{Binding Parsers}" ItemsSource="{Binding Parsers}"
SelectedItem="{Binding SelectedParser}" SelectedItem="{Binding SelectedParser}"
@@ -21,42 +22,39 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
> >
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="parsing:ITextParser"> <DataTemplate x:DataType="vm:ParserViewModel">
<TextBlock Text="{Binding DisplayName}" /> <TextBlock Text="{Binding Name}" />
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" Margin="0,0,0,8"> <StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Classes="caption" Text="ACTIONS" /> <TextBlock Classes="caption" Text="{l:Loc Parse.Actions}" />
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding ParseCommand}"> <Button Classes="primary" Command="{Binding ParseCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
<TextBlock Text="Parse" /> <TextBlock Text="{l:Loc Parse.Run}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Classes="destructive" Command="{Binding CancelCommand}"> <Button Classes="destructive" Command="{Binding CancelCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconStop}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconStop}" />
<TextBlock Text="Cancel" /> <TextBlock Text="{l:Loc Parse.Cancel}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Command="{Binding LoadSampleCommand}" ToolTip.Tip="Fill the input with a small example"> <Button Command="{Binding LoadSampleCommand}" ToolTip.Tip="{l:Loc Parse.SampleTip}">
<TextBlock Text="Sample" /> <TextBlock Text="{l:Loc Parse.Sample}" />
</Button> </Button>
<Button <Button Command="{Binding GenerateLargeSampleCommand}" ToolTip.Tip="{l:Loc Parse.LargeSampleTip}">
Command="{Binding GenerateLargeSampleCommand}" <TextBlock Text="{l:Loc Parse.LargeSample}" />
ToolTip.Tip="Generate 50 000 rows, so progress and cancellation are observable"
>
<TextBlock Text="50k rows" />
</Button> </Button>
<Button Classes="icon" Command="{Binding ClearCommand}" ToolTip.Tip="Clear input and results"> <Button Classes="icon" Command="{Binding ClearCommand}" ToolTip.Tip="{l:Loc Parse.ClearTip}">
<PathIcon Classes="glyph" Data="{DynamicResource IconBroom}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconBroom}" />
</Button> </Button>
</StackPanel> </StackPanel>
@@ -94,7 +92,7 @@
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}" BorderBrush="{DynamicResource AppBorderBrush}"
> >
<TextBlock Classes="caption" Text="INPUT" /> <TextBlock Classes="caption" Text="{l:Loc Parse.Input}" />
</Border> </Border>
<TextBox <TextBox
Text="{Binding InputText}" Text="{Binding InputText}"
@@ -102,7 +100,7 @@
VerticalContentAlignment="Top" VerticalContentAlignment="Top"
AcceptsTab="True" AcceptsTab="True"
TextWrapping="NoWrap" TextWrapping="NoWrap"
PlaceholderText="Paste text here, or press Sample" PlaceholderText="{l:Loc Parse.InputPlaceholder}"
BorderThickness="0" BorderThickness="0"
Background="Transparent" Background="Transparent"
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace" FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
@@ -125,7 +123,7 @@
BorderBrush="{DynamicResource AppBorderBrush}" BorderBrush="{DynamicResource AppBorderBrush}"
> >
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="caption" Text="RECORDS" VerticalAlignment="Center" /> <TextBlock Classes="caption" Text="{l:Loc Parse.Records}" VerticalAlignment="Center" />
<Border Classes="chip"> <Border Classes="chip">
<TextBlock Classes="mono caption" Text="{Binding Records.Count}" /> <TextBlock Classes="mono caption" Text="{Binding Records.Count}" />
</Border> </Border>
@@ -190,7 +188,7 @@
Foreground="{DynamicResource AppDangerBrush}" Foreground="{DynamicResource AppDangerBrush}"
VerticalAlignment="Center" VerticalAlignment="Center"
/> />
<TextBlock Classes="caption" Text="ERRORS" VerticalAlignment="Center" /> <TextBlock Classes="caption" Text="{l:Loc Parse.Errors}" VerticalAlignment="Center" />
<Border Classes="chip danger"> <Border Classes="chip danger">
<TextBlock Classes="mono caption" Text="{Binding Errors.Count}" /> <TextBlock Classes="mono caption" Text="{Binding Errors.Count}" />
</Border> </Border>
@@ -199,12 +197,12 @@
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0"> <ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="parsing:ParseError"> <DataTemplate x:DataType="vm:ParseErrorViewModel">
<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 LineNumber}" HorizontalAlignment="Center" />
</Border> </Border>
<TextBlock Classes="muted" Text="{Binding Message}" VerticalAlignment="Center" /> <TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
+28 -22
View File
@@ -1,53 +1,62 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters" xmlns:conv="clr-namespace:AvParser.UI.Converters"
x:Class="AvParser.UI.Views.ProxiesView" x:Class="AvParser.UI.Views.ProxiesView"
x:DataType="vm:ProxiesViewModel" x:DataType="vm:ProxiesViewModel"
> >
<UserControl.Resources>
<DataTemplate x:Key="LocalizedOptionTemplate" x:DataType="l:LocalizedOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</UserControl.Resources>
<Grid RowDefinitions="Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<!-- ===== Toolbar ===== --> <!-- ===== Toolbar ===== -->
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12"> <Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<StackPanel Spacing="4" Margin="0,0,16,8"> <StackPanel Spacing="4" Margin="0,0,16,8">
<TextBlock Classes="caption" Text="POOL" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.Pool}" />
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding RefreshCommand}" ToolTip.Tip="Reload every source"> <Button Classes="primary" Command="{Binding RefreshCommand}" ToolTip.Tip="{l:Loc Proxies.RefreshTip}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" />
<TextBlock Text="Refresh" /> <TextBlock Text="{l:Loc Proxies.Refresh}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Command="{Binding SweepCommand}" ToolTip.Tip="Probe every proxy in the pool"> <Button Command="{Binding SweepCommand}" ToolTip.Tip="{l:Loc Proxies.CheckAllTip}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconShield}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconShield}" />
<TextBlock Text="Check all" /> <TextBlock Text="{l:Loc Proxies.CheckAll}" />
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="220"> <StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="220">
<TextBlock Classes="caption" Text="SEARCH" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.Search}" />
<TextBox Text="{Binding SearchText}" PlaceholderText="address or country" /> <TextBox Text="{Binding SearchText}" PlaceholderText="{l:Loc Proxies.SearchPlaceholder}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130"> <StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="PROTOCOL" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.Protocol}" />
<ComboBox <ComboBox
ItemsSource="{Binding ProtocolFilters}" ItemsSource="{Binding ProtocolFilters}"
SelectedItem="{Binding ProtocolFilter}" SelectedItem="{Binding ProtocolFilter}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
</StackPanel> </StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130"> <StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="STATUS" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.StatusColumn}" />
<ComboBox <ComboBox
ItemsSource="{Binding HealthFilters}" ItemsSource="{Binding HealthFilters}"
SelectedItem="{Binding HealthFilter}" SelectedItem="{Binding HealthFilter}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
</StackPanel> </StackPanel>
@@ -57,19 +66,19 @@
<Border Classes="chip accent"> <Border Classes="chip accent">
<TextBlock Classes="mono caption"> <TextBlock Classes="mono caption">
<Run Text="{Binding AliveCount}" /> <Run Text="{Binding AliveCount}" />
<Run Text="alive" /> <Run Text="{l:Loc Proxies.Alive}" />
</TextBlock> </TextBlock>
</Border> </Border>
<Border Classes="chip"> <Border Classes="chip">
<TextBlock Classes="mono caption"> <TextBlock Classes="mono caption">
<Run Text="{Binding TotalCount}" /> <Run Text="{Binding TotalCount}" />
<Run Text="total" /> <Run Text="{l:Loc Proxies.Total}" />
</TextBlock> </TextBlock>
</Border> </Border>
<Border Classes="chip"> <Border Classes="chip">
<TextBlock Classes="mono caption"> <TextBlock Classes="mono caption">
<Run Text="{Binding MatchedCount}" /> <Run Text="{Binding MatchedCount}" />
<Run Text="matched" /> <Run Text="{l:Loc Proxies.Matched}" />
</TextBlock> </TextBlock>
</Border> </Border>
</StackPanel> </StackPanel>
@@ -102,7 +111,7 @@
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}" BorderBrush="{DynamicResource AppBorderBrush}"
> >
<TextBlock Classes="caption" Text="PROXIES" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.ListHeader}" />
</Border> </Border>
<ListBox <ListBox
@@ -173,7 +182,7 @@
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}" BorderBrush="{DynamicResource AppBorderBrush}"
> >
<TextBlock Classes="caption" Text="CUSTOM PROXIES" /> <TextBlock Classes="caption" Text="{l:Loc Proxies.CustomHeader}" />
</Border> </Border>
<StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="16,12"> <StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="16,12">
@@ -181,24 +190,21 @@
<Button Classes="primary" Command="{Binding AddCustomCommand}"> <Button Classes="primary" Command="{Binding AddCustomCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlus}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconPlus}" />
<TextBlock Text="Add" /> <TextBlock Text="{l:Loc Proxies.Add}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button <Button
Classes="destructive" Classes="destructive"
Command="{Binding RemoveSelectedCommand}" Command="{Binding RemoveSelectedCommand}"
ToolTip.Tip="Remove the selected custom proxy" ToolTip.Tip="{l:Loc Proxies.RemoveTip}"
> >
<TextBlock Text="Remove" /> <TextBlock Text="{l:Loc Proxies.Remove}" />
</Button> </Button>
<Button Classes="icon" Command="{Binding ClearCustomCommand}" ToolTip.Tip="Clear the custom list"> <Button Classes="icon" Command="{Binding ClearCustomCommand}" ToolTip.Tip="{l:Loc Proxies.ClearTip}">
<PathIcon Classes="glyph" Data="{DynamicResource IconTrash}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconTrash}" />
</Button> </Button>
</StackPanel> </StackPanel>
<TextBlock <TextBlock Classes="muted" Text="{l:Loc Proxies.CustomHint}" />
Classes="muted"
Text="One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported."
/>
</StackPanel> </StackPanel>
<TextBox <TextBox
+47 -43
View File
@@ -1,51 +1,72 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
x:Class="AvParser.UI.Views.SettingsView" x:Class="AvParser.UI.Views.SettingsView"
x:DataType="vm:SettingsViewModel" x:DataType="vm:SettingsViewModel"
> >
<UserControl.Resources>
<!-- Every picker on this page holds LocalizedOption values; the non-generic base is what
lets a compiled binding name a data type here. -->
<DataTemplate x:Key="LocalizedOptionTemplate" x:DataType="l:LocalizedOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</UserControl.Resources>
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="16" MaxWidth="720" HorizontalAlignment="Left"> <StackPanel Spacing="16" MaxWidth="720" HorizontalAlignment="Left">
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<TextBlock Classes="subtitle" Text="Appearance" /> <TextBlock Classes="subtitle" Text="{l:Loc Settings.Appearance}" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="THEME" /> <TextBlock Classes="caption" Text="{l:Loc Settings.Theme}" />
<ComboBox <ComboBox
ItemsSource="{Binding Themes}" ItemsSource="{Binding Themes}"
SelectedItem="{Binding SelectedTheme}" SelectedItem="{Binding SelectedTheme}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
<TextBlock Classes="muted" Text="System follows the operating system's light/dark setting." /> <TextBlock Classes="muted" Text="{l:Loc Settings.ThemeHint}" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="{l:Loc Settings.Language}" />
<ComboBox
ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch"
/>
<TextBlock Classes="muted" Text="{l:Loc Settings.LanguageHint}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<TextBlock Classes="subtitle" Text="Diagnostics" /> <TextBlock Classes="subtitle" Text="{l:Loc Settings.Diagnostics}" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="MINIMUM LOG LEVEL" /> <TextBlock Classes="caption" Text="{l:Loc Settings.LogLevel}" />
<ComboBox <ComboBox
ItemsSource="{Binding LogLevels}" ItemsSource="{Binding LogLevels}"
SelectedItem="{Binding SelectedLogLevel}" SelectedItem="{Binding SelectedLogLevel}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
<TextBlock Classes="muted" Text="Applies immediately — no restart needed." /> <TextBlock Classes="muted" Text="{l:Loc Settings.LogLevelHint}" />
</StackPanel> </StackPanel>
<Separator Classes="section" /> <Separator Classes="section" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="SETTINGS FILE" /> <TextBlock Classes="caption" Text="{l:Loc Settings.SettingsFile}" />
<SelectableTextBlock Classes="mono" Text="{Binding SettingsFile}" TextWrapping="Wrap" /> <SelectableTextBlock Classes="mono" Text="{Binding SettingsFile}" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="LOG DIRECTORY" /> <TextBlock Classes="caption" Text="{l:Loc Settings.LogDirectory}" />
<SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" /> <SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -53,42 +74,41 @@
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<TextBlock Classes="subtitle" Text="Proxies" /> <TextBlock Classes="subtitle" Text="{l:Loc Settings.Proxies}" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="ROTATION" /> <TextBlock Classes="caption" Text="{l:Loc Settings.Rotation}" />
<ComboBox <ComboBox
ItemsSource="{Binding Rotations}" ItemsSource="{Binding Rotations}"
SelectedItem="{Binding SelectedRotation}" SelectedItem="{Binding SelectedRotation}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
<TextBlock Classes="muted" Text="{Binding RotationDescription}" /> <TextBlock Classes="muted" Text="{Binding RotationDescription}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="LIVENESS CHECK" /> <TextBlock Classes="caption" Text="{l:Loc Settings.LivenessCheck}" />
<ComboBox <ComboBox
ItemsSource="{Binding HealthChecks}" ItemsSource="{Binding HealthChecks}"
SelectedItem="{Binding SelectedHealthCheck}" SelectedItem="{Binding SelectedHealthCheck}"
ItemTemplate="{StaticResource LocalizedOptionTemplate}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
/> />
<TextBlock Classes="muted" Text="{Binding HealthCheckDescription}" /> <TextBlock Classes="muted" Text="{Binding HealthCheckDescription}" />
</StackPanel> </StackPanel>
<CheckBox IsChecked="{Binding UseProxyFeed}" Content="Use the public proxifly feed" /> <CheckBox IsChecked="{Binding UseProxyFeed}" Content="{l:Loc Settings.UseFeed}" />
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="caption" Text="PROBE URL" /> <TextBlock Classes="caption" Text="{l:Loc Settings.ProbeUrl}" />
<TextBox Text="{Binding ProxyProbeUrl}" /> <TextBox Text="{Binding ProxyProbeUrl}" />
<TextBlock <TextBlock Classes="muted" Text="{l:Loc Settings.ProbeUrlHint}" />
Classes="muted"
Text="Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones."
/>
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,16,*"> <Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6"> <StackPanel Grid.Column="0" Spacing="6">
<TextBlock Classes="caption" Text="PROBE TIMEOUT (SEC)" /> <TextBlock Classes="caption" Text="{l:Loc Settings.ProbeTimeout}" />
<NumericUpDown <NumericUpDown
Value="{Binding ProxyProbeTimeoutSeconds}" Value="{Binding ProxyProbeTimeoutSeconds}"
Minimum="1" Minimum="1"
@@ -98,7 +118,7 @@
/> />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="6"> <StackPanel Grid.Column="2" Spacing="6">
<TextBlock Classes="caption" Text="PARALLEL PROBES" /> <TextBlock Classes="caption" Text="{l:Loc Settings.ProbeConcurrency}" />
<NumericUpDown <NumericUpDown
Value="{Binding ProxyProbeConcurrency}" Value="{Binding ProxyProbeConcurrency}"
Minimum="1" Minimum="1"
@@ -113,34 +133,18 @@
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Layout breakpoints" /> <TextBlock Classes="subtitle" Text="{l:Loc Settings.Breakpoints}" />
<TextBlock <TextBlock Classes="muted" Text="{l:Loc Settings.BreakpointsHint}" />
Classes="muted"
Text="Window widths at which the navigation changes shape. Resize the window to see it happen."
/>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="16" RowSpacing="8"> <Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="16" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="caption" Text="COMPACT" /> <TextBlock Grid.Row="0" Grid.Column="0" Classes="caption" Text="{l:Loc Settings.Breakpoint.Compact}" />
<TextBlock Grid.Row="0" Grid.Column="1" Classes="muted"> <TextBlock Grid.Row="0" Grid.Column="1" Classes="muted" Text="{Binding CompactHint}" />
<Run Text="below" />
<Run Text="{Binding MediumBreakpoint}" />
<Run Text="px — overlay drawer" />
</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="caption" Text="MEDIUM" /> <TextBlock Grid.Row="1" Grid.Column="0" Classes="caption" Text="{l:Loc Settings.Breakpoint.Medium}" />
<TextBlock Grid.Row="1" Grid.Column="1" Classes="muted"> <TextBlock Grid.Row="1" Grid.Column="1" Classes="muted" Text="{Binding MediumHint}" />
<Run Text="{Binding MediumBreakpoint}" />
<Run Text="" />
<Run Text="{Binding ExpandedBreakpoint}" />
<Run Text="px — icon rail" />
</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="caption" Text="EXPANDED" /> <TextBlock Grid.Row="2" Grid.Column="0" Classes="caption" Text="{l:Loc Settings.Breakpoint.Expanded}" />
<TextBlock Grid.Row="2" Grid.Column="1" Classes="muted"> <TextBlock Grid.Row="2" Grid.Column="1" Classes="muted" Text="{Binding ExpandedHint}" />
<Run Text="from" />
<Run Text="{Binding ExpandedBreakpoint}" />
<Run Text="px — full sidebar" />
</TextBlock>
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
+4 -3
View File
@@ -1,6 +1,7 @@
<rxui:ReactiveUserControl <rxui:ReactiveUserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:AvParser.UI.Localization"
xmlns:rxui="clr-namespace:ReactiveUI.Avalonia;assembly=ReactiveUI.Avalonia" xmlns:rxui="clr-namespace:ReactiveUI.Avalonia;assembly=ReactiveUI.Avalonia"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels" xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters" xmlns:conv="clr-namespace:AvParser.UI.Converters"
@@ -58,7 +59,7 @@
Grid.Column="0" Grid.Column="0"
Classes="icon" Classes="icon"
Command="{Binding TogglePaneCommand}" Command="{Binding TogglePaneCommand}"
ToolTip.Tip="Toggle navigation" ToolTip.Tip="{l:Loc Shell.ToggleNavigation}"
Margin="0,0,4,0" Margin="0,0,4,0"
> >
<PathIcon Classes="glyph" Data="{DynamicResource IconMenu}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconMenu}" />
@@ -70,7 +71,7 @@
Classes="icon" Classes="icon"
Command="{Binding GoBackCommand}" Command="{Binding GoBackCommand}"
IsVisible="{Binding CanGoBack}" IsVisible="{Binding CanGoBack}"
ToolTip.Tip="Back" ToolTip.Tip="{l:Loc Shell.Back}"
Margin="0,0,8,0" Margin="0,0,8,0"
> >
<PathIcon Classes="glyph" Data="{DynamicResource IconBack}" /> <PathIcon Classes="glyph" Data="{DynamicResource IconBack}" />
@@ -90,7 +91,7 @@
Grid.Column="3" Grid.Column="3"
Classes="icon" Classes="icon"
Command="{Binding ToggleThemeCommand}" Command="{Binding ToggleThemeCommand}"
ToolTip.Tip="Switch light / dark" ToolTip.Tip="{l:Loc Shell.ToggleTheme}"
> >
<PathIcon <PathIcon
Classes="glyph" Classes="glyph"
+2 -2
View File
@@ -14,9 +14,9 @@ namespace AvParser.UI.HeadlessTests;
/// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have /// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have
/// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all. /// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all.
/// </remarks> /// </remarks>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel internal sealed class FakePage(string titleKey, string iconKey = "IconHome") : PageViewModel
{ {
public override string Title { get; } = title; public override string TitleKey { get; } = titleKey;
public override string IconKey { get; } = iconKey; public override string IconKey { get; } = iconKey;
} }
@@ -0,0 +1,90 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public sealed class LocalizationViewTests : IDisposable
{
/// <summary>Leaves the localizer where the rest of the assembly expects it.</summary>
public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English);
private static (ShellView View, ShellViewModel ViewModel) ShowShell()
{
var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]);
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
var view = new ShellView { DataContext = viewModel };
new Window
{
Width = 1400,
Height = 800,
Content = view,
}.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel);
}
private static IReadOnlyList<string> RailLabels(Visual view) =>
[
.. view.GetVisualDescendants()
.OfType<TextBlock>()
.Where(block => block.Classes.Contains("navLabel"))
.Select(block => block.Text ?? string.Empty),
];
[AvaloniaFact]
public void Rendered_text_follows_the_language_without_rebuilding_the_view()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var (view, _) = ShowShell();
var english = RailLabels(view);
english.ShouldBe(["Dashboard", "Parse"]);
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Dispatcher.UIThread.RunJobs();
// The same controls, not a rebuilt tree: this is the whole point of binding through the
// localizer's indexer rather than resolving strings once at load.
RailLabels(view).ShouldBe(["Обзор", "Разбор"]);
}
[AvaloniaFact]
public void The_title_bar_follows_the_language_too()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var (view, viewModel) = ShowShell();
var title = view.GetVisualDescendants().OfType<TextBlock>().Single(block => block.Name == "ShellTitle");
title.Text.ShouldBe("Dashboard");
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Dispatcher.UIThread.RunJobs();
viewModel.Title.ShouldBe("Обзор");
title.Text.ShouldBe("Обзор");
}
[AvaloniaFact]
public void Loc_markup_resolves_a_static_caption()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
var (view, _) = ShowShell();
// Tooltips come from {l:Loc} rather than from a view model, so they exercise the markup
// extension itself.
var toggle = view.GetVisualDescendants().OfType<Button>().Single(button => button.Name == "PaneToggle");
ToolTip.GetTip(toggle).ShouldBe("Показать или скрыть навигацию");
}
}
@@ -5,6 +5,7 @@ using Avalonia.Styling;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation; using AvParser.UI.Navigation;
using AvParser.UI.Responsive; using AvParser.UI.Responsive;
using AvParser.UI.Services; using AvParser.UI.Services;
@@ -27,7 +28,10 @@ public class ShellViewTests
/// </remarks> /// </remarks>
private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width) private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width)
{ {
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second", "IconDocument")]); var navigation = new NavigationService([
new FakePage("Page.Dashboard"),
new FakePage("Page.Parse", "IconDocument"),
]);
// ImmediateSequencer, not the real main-thread one: derived properties then settle within // ImmediateSequencer, not the real main-thread one: derived properties then settle within
// the same layout pass, so a single RunJobs() is enough for the bindings to catch up. // the same layout pass, so a single RunJobs() is enough for the bindings to catch up.
@@ -108,7 +112,7 @@ public class ShellViewTests
var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single(); var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single();
host.Content.ShouldBeSameAs(second); host.Content.ShouldBeSameAs(second);
viewModel.Title.ShouldBe("Second"); viewModel.Title.ShouldBe(Localizer.Instance["Page.Parse"]);
} }
/// <summary> /// <summary>
+7 -3
View File
@@ -3,9 +3,13 @@ using AvParser.UI.ViewModels;
namespace AvParser.UI.Tests.Fakes; namespace AvParser.UI.Tests.Fakes;
/// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary> /// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel /// <remarks>
/// Takes a real resource key rather than a literal caption, so the tests exercise the same
/// lookup the app does instead of a special case that would hide a broken key.
/// </remarks>
internal sealed class FakePage(string titleKey, string iconKey = "IconHome") : PageViewModel
{ {
public override string Title { get; } = title; public override string TitleKey { get; } = titleKey;
public override string IconKey { get; } = iconKey; public override string IconKey { get; } = iconKey;
} }
@@ -13,7 +17,7 @@ internal sealed class FakePage(string title, string iconKey = "IconHome") : Page
/// <summary>A second page type, so <c>NavigateTo&lt;TPage&gt;()</c> has something to discriminate on.</summary> /// <summary>A second page type, so <c>NavigateTo&lt;TPage&gt;()</c> has something to discriminate on.</summary>
internal sealed class OtherFakePage : PageViewModel internal sealed class OtherFakePage : PageViewModel
{ {
public override string Title => "Other"; public override string TitleKey => "Page.About";
public override string IconKey => "IconInfo"; public override string IconKey => "IconInfo";
} }
@@ -0,0 +1,221 @@
using System.Globalization;
using System.Resources;
using System.Text.RegularExpressions;
using AvParser.Core.Settings;
using AvParser.UI.Localization;
namespace AvParser.UI.Tests;
public sealed class LocalizationTests : IDisposable
{
private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
private static readonly ResourceManager Resources = new(
"AvParser.UI.Localization.Strings",
typeof(Localizer).Assembly
);
/// <summary>Leaves the localizer where the rest of the suite expects it.</summary>
public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English);
/// <summary>
/// Reads one language's resources without letting it fall back to another.
/// </summary>
/// <remarks>
/// English lives in the neutral <c>Strings.resx</c>, which resolves under the invariant
/// culture rather than under "en". <c>tryParents: false</c> matters: with fallback on, a
/// missing Russian satellite would quietly serve English and every comparison below would
/// pass while the app shipped untranslated.
/// </remarks>
private static IReadOnlyDictionary<string, string> Read(CultureInfo culture)
{
var lookup = Equals(culture, English) ? CultureInfo.InvariantCulture : culture;
// Deliberately not disposed: ResourceManager hands back a cached instance, and disposing
// it breaks every later read in the assembly.
var set = Resources.GetResourceSet(lookup, createIfNotExists: true, tryParents: false);
set.ShouldNotBeNull();
return set.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(
entry => (string)entry.Key,
entry => (string?)entry.Value ?? string.Empty,
StringComparer.Ordinal
);
}
[Fact]
public void Russian_translates_every_english_key()
{
var english = Read(English);
var russian = Read(Russian);
english.ShouldNotBeEmpty();
russian.Keys.Except(english.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian has keys English does not");
english.Keys.Except(russian.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian is missing keys");
}
[Fact]
public void No_translation_is_blank() =>
Read(Russian).Where(entry => string.IsNullOrWhiteSpace(entry.Value)).Select(entry => entry.Key).ShouldBeEmpty();
[Fact]
public void Format_placeholders_match_between_languages()
{
var english = Read(English);
var russian = Read(Russian);
// A translation that drops a {0} throws at runtime rather than merely reading oddly, so
// the placeholder sets have to be identical.
var mismatched = english
.Where(entry => Placeholders(entry.Value) != Placeholders(russian[entry.Key]))
.Select(entry => entry.Key)
.ToArray();
mismatched.ShouldBeEmpty();
}
[Fact]
public void Every_plural_key_has_all_three_forms()
{
var english = Read(English);
// Russian needs One/Few/Many; a family missing one of them would silently render "!key!".
var families = english
.Keys.Where(key => key.EndsWith(".One", StringComparison.Ordinal))
.Select(key => key[..^4])
.ToArray();
families.ShouldNotBeEmpty();
foreach (var family in families)
{
foreach (var suffix in new[] { "One", "Few", "Many" })
{
english.ShouldContainKey($"{family}.{suffix}");
}
}
}
[Theory]
[InlineData(1, "One")]
[InlineData(2, "Few")]
[InlineData(4, "Few")]
[InlineData(5, "Many")]
[InlineData(11, "Many")]
[InlineData(12, "Many")]
[InlineData(14, "Many")]
[InlineData(21, "One")]
[InlineData(22, "Few")]
[InlineData(25, "Many")]
[InlineData(101, "One")]
[InlineData(111, "Many")]
[InlineData(0, "Many")]
public void Russian_plural_rules(int count, string expected) =>
Localizer.SuffixFor(count, Russian).ShouldBe(expected);
[Theory]
[InlineData(0, "Many")]
[InlineData(1, "One")]
[InlineData(2, "Many")]
[InlineData(21, "Many")]
public void English_plural_rules(int count, string expected) =>
Localizer.SuffixFor(count, English).ShouldBe(expected);
[Fact]
public void Plural_lookup_picks_the_right_form()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance.Plural("Parse.Count.Records", 1).ShouldBe("1 запись");
Localizer.Instance.Plural("Parse.Count.Records", 3).ShouldBe("3 записи");
Localizer.Instance.Plural("Parse.Count.Records", 7).ShouldBe("7 записей");
}
[Fact]
public void Switching_language_changes_what_lookups_return()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var english = Localizer.Instance["Page.Settings"];
Localizer.Instance.SetLanguage(AppLanguage.Russian);
Localizer.Instance["Page.Settings"].ShouldNotBe(english);
Localizer.Instance["Page.Settings"].ShouldBe("Настройки");
}
[Fact]
public void Switching_language_invalidates_bindings()
{
Localizer.Instance.SetLanguage(AppLanguage.English);
var notifications = new List<string?>();
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) =>
notifications.Add(e.PropertyName);
Localizer.Instance.PropertyChanged += Handler;
try
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
}
finally
{
Localizer.Instance.PropertyChanged -= Handler;
}
// "Item[]" is what makes every {l:Loc} binding re-read; without it the UI would keep the
// previous language until it was rebuilt.
notifications.ShouldContain("Item[]");
}
[Fact]
public void Setting_the_same_language_twice_does_not_churn_bindings()
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
var raised = 0;
void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => raised++;
Localizer.Instance.PropertyChanged += Handler;
try
{
Localizer.Instance.SetLanguage(AppLanguage.Russian);
}
finally
{
Localizer.Instance.PropertyChanged -= Handler;
}
raised.ShouldBe(0);
}
[Fact]
public void An_unknown_key_is_visibly_marked() => Localizer.Instance["No.Such.Key"].ShouldBe("!No.Such.Key!");
[Fact]
public void A_fallback_is_used_when_a_key_is_absent() =>
Localizer.Instance.GetOrDefault("Parser.brand-new.Name", "Brand new").ShouldBe("Brand new");
[Theory]
[InlineData(AppLanguage.English, "en")]
[InlineData(AppLanguage.Russian, "ru")]
public void Explicit_languages_map_to_their_culture(AppLanguage language, string expected) =>
AppLanguages.ToCulture(language).TwoLetterISOLanguageName.ShouldBe(expected);
[Theory]
[InlineData("ru-RU", "ru")]
[InlineData("ru", "ru")]
[InlineData("en-GB", "en")]
[InlineData("de-DE", "en")]
[InlineData("ja-JP", "en")]
public void System_follows_the_os_only_where_a_translation_exists(string system, string expected) =>
// A half-translated German would be worse than plain English, so anything unsupported
// lands on English rather than on the OS language.
AppLanguages
.ToCulture(AppLanguage.System, CultureInfo.GetCultureInfo(system))
.TwoLetterISOLanguageName.ShouldBe(expected);
private static string Placeholders(string value) =>
string.Concat(Regex.Matches(value, @"\{\d+\}").Select(match => match.Value).Order());
}
@@ -84,7 +84,7 @@ public class ProxiesViewModelTests
); );
await Run(page.RefreshCommand); await Run(page.RefreshCommand);
page.ProtocolFilter = ProxyProtocolFilter.Socks5; page.ProtocolFilter = page.ProtocolFilters.Single(option => option.Value == ProxyProtocolFilter.Socks5);
await Task.Delay(250, TestContext.Current.CancellationToken); await Task.Delay(250, TestContext.Current.CancellationToken);
page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5"); page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5");
@@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.UI.Localization;
using AvParser.UI.Navigation; using AvParser.UI.Navigation;
using AvParser.UI.Responsive; using AvParser.UI.Responsive;
using AvParser.UI.Tests.Fakes; using AvParser.UI.Tests.Fakes;
@@ -15,7 +16,11 @@ public class ShellViewModelTests
AppTheme theme = AppTheme.System AppTheme theme = AppTheme.System
) )
{ {
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second"), new OtherFakePage()]); var navigation = new NavigationService([
new FakePage("Page.Dashboard"),
new FakePage("Page.Parse"),
new OtherFakePage(),
]);
var themeService = new FakeThemeService(theme); var themeService = new FakeThemeService(theme);
// ImmediateSequencer makes every derived property settle before the next line runs, // ImmediateSequencer makes every derived property settle before the next line runs,
@@ -98,7 +103,7 @@ public class ShellViewModelTests
navigation.Current.ShouldBeSameAs(shell.Pages[2]); navigation.Current.ShouldBeSameAs(shell.Pages[2]);
shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]); shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]);
shell.Title.ShouldBe("Other"); shell.Title.ShouldBe(Localizer.Instance["Page.About"]);
} }
[Fact] [Fact]