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:
co-authored by
Claude Opus 5
parent
9bf2ea5532
commit
8b552470b7
@@ -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
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`, с отдельными словарями
|
||||||
|
|||||||
@@ -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)])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
Entry.Health switch
|
Localizer.Instance[
|
||||||
{
|
Entry.Health switch
|
||||||
ProxyHealthState.Alive => "alive",
|
{
|
||||||
ProxyHealthState.Dead => "dead",
|
ProxyHealthState.Alive => "Proxies.Health.Alive",
|
||||||
_ => "unchecked",
|
ProxyHealthState.Dead => "Proxies.Health.Dead",
|
||||||
};
|
_ => "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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<TPage>()</c> has something to discriminate on.</summary>
|
/// <summary>A second page type, so <c>NavigateTo<TPage>()</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]
|
||||||
|
|||||||
Reference in New Issue
Block a user