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

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

Three places where this is more than a string swap:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 18:15:01 +03:00
co-authored by Claude Opus 5
parent 9bf2ea5532
commit 8b552470b7
42 changed files with 2216 additions and 226 deletions
@@ -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>