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
+490
View File
@@ -0,0 +1,490 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Shell.ToggleNavigation" xml:space="preserve">
<value>Toggle navigation</value>
</data>
<data name="Shell.Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Shell.ToggleTheme" xml:space="preserve">
<value>Switch light / dark</value>
</data>
<data name="Page.Dashboard" xml:space="preserve">
<value>Dashboard</value>
</data>
<data name="Page.Parse" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Page.Proxies" xml:space="preserve">
<value>Proxies</value>
</data>
<data name="Page.Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Page.About" xml:space="preserve">
<value>About</value>
</data>
<data name="Dashboard.Tagline" xml:space="preserve">
<value>A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer.</value>
</data>
<data name="Dashboard.RegisteredParsers" xml:space="preserve">
<value>Registered parsers</value>
</data>
<data name="Dashboard.GetStarted" xml:space="preserve">
<value>Get started</value>
</data>
<data name="Dashboard.OpenParser" xml:space="preserve">
<value>Open the parser</value>
</data>
<data name="Dashboard.DataDirectory" xml:space="preserve">
<value>DATA DIRECTORY</value>
</data>
<data name="Parse.Parser" xml:space="preserve">
<value>PARSER</value>
</data>
<data name="Parse.Actions" xml:space="preserve">
<value>ACTIONS</value>
</data>
<data name="Parse.Run" xml:space="preserve">
<value>Parse</value>
</data>
<data name="Parse.Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Parse.Sample" xml:space="preserve">
<value>Sample</value>
</data>
<data name="Parse.SampleTip" xml:space="preserve">
<value>Fill the input with a small example</value>
</data>
<data name="Parse.LargeSample" xml:space="preserve">
<value>50k rows</value>
</data>
<data name="Parse.LargeSampleTip" xml:space="preserve">
<value>Generate 50 000 rows, so progress and cancellation are observable</value>
</data>
<data name="Parse.ClearTip" xml:space="preserve">
<value>Clear input and results</value>
</data>
<data name="Parse.Input" xml:space="preserve">
<value>INPUT</value>
</data>
<data name="Parse.InputPlaceholder" xml:space="preserve">
<value>Paste text here, or press Sample</value>
</data>
<data name="Parse.Records" xml:space="preserve">
<value>RECORDS</value>
</data>
<data name="Parse.Errors" xml:space="preserve">
<value>ERRORS</value>
</data>
<data name="Parse.Count.Records.One" xml:space="preserve">
<value>{0} record</value>
</data>
<data name="Parse.Count.Records.Few" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Records.Many" xml:space="preserve">
<value>{0} records</value>
</data>
<data name="Parse.Count.Errors.One" xml:space="preserve">
<value>{0} error</value>
</data>
<data name="Parse.Count.Errors.Few" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Count.Errors.Many" xml:space="preserve">
<value>{0} errors</value>
</data>
<data name="Parse.Status.Done" xml:space="preserve">
<value>Parsed {0} in {1} ms.</value>
</data>
<data name="Parse.Status.Cancelled" xml:space="preserve">
<value>Cancelled after {0} in {1} ms.</value>
</data>
<data name="Parse.Status.WithErrors" xml:space="preserve">
<value>{0}, {1}.</value>
</data>
<data name="Parse.Status.Truncated" xml:space="preserve">
<value>Showing the first {0} only.</value>
</data>
<data name="Parse.Status.Failed" xml:space="preserve">
<value>Parse failed: {0}</value>
</data>
<data name="Proxies.Pool" xml:space="preserve">
<value>POOL</value>
</data>
<data name="Proxies.Refresh" xml:space="preserve">
<value>Refresh</value>
</data>
<data name="Proxies.RefreshTip" xml:space="preserve">
<value>Reload every source</value>
</data>
<data name="Proxies.CheckAll" xml:space="preserve">
<value>Check all</value>
</data>
<data name="Proxies.CheckAllTip" xml:space="preserve">
<value>Probe every proxy in the pool</value>
</data>
<data name="Proxies.Search" xml:space="preserve">
<value>SEARCH</value>
</data>
<data name="Proxies.SearchPlaceholder" xml:space="preserve">
<value>address or country</value>
</data>
<data name="Proxies.Protocol" xml:space="preserve">
<value>PROTOCOL</value>
</data>
<data name="Proxies.StatusColumn" xml:space="preserve">
<value>STATUS</value>
</data>
<data name="Proxies.Alive" xml:space="preserve">
<value>alive</value>
</data>
<data name="Proxies.Total" xml:space="preserve">
<value>total</value>
</data>
<data name="Proxies.Matched" xml:space="preserve">
<value>matched</value>
</data>
<data name="Proxies.ListHeader" xml:space="preserve">
<value>PROXIES</value>
</data>
<data name="Proxies.CustomHeader" xml:space="preserve">
<value>CUSTOM PROXIES</value>
</data>
<data name="Proxies.Add" xml:space="preserve">
<value>Add</value>
</data>
<data name="Proxies.Remove" xml:space="preserve">
<value>Remove</value>
</data>
<data name="Proxies.RemoveTip" xml:space="preserve">
<value>Remove the selected custom proxy</value>
</data>
<data name="Proxies.ClearTip" xml:space="preserve">
<value>Clear the custom list</value>
</data>
<data name="Proxies.CustomHint" xml:space="preserve">
<value>One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported.</value>
</data>
<data name="Proxies.Health.Alive" xml:space="preserve">
<value>alive</value>
</data>
<data name="Proxies.Health.Dead" xml:space="preserve">
<value>dead</value>
</data>
<data name="Proxies.Health.Unchecked" xml:space="preserve">
<value>unchecked</value>
</data>
<data name="Proxies.Source.Feed" xml:space="preserve">
<value>feed</value>
</data>
<data name="Proxies.Source.Custom" xml:space="preserve">
<value>custom</value>
</data>
<data name="Proxies.Count.Proxies.One" xml:space="preserve">
<value>{0} proxy</value>
</data>
<data name="Proxies.Count.Proxies.Few" xml:space="preserve">
<value>{0} proxies</value>
</data>
<data name="Proxies.Count.Proxies.Many" xml:space="preserve">
<value>{0} proxies</value>
</data>
<data name="Proxies.Status.PoolHolds" xml:space="preserve">
<value>Pool holds {0}.</value>
</data>
<data name="Proxies.Status.Nothing" xml:space="preserve">
<value>Nothing to add.</value>
</data>
<data name="Proxies.Status.Added" xml:space="preserve">
<value>Added {0} of {1}.</value>
</data>
<data name="Proxies.Status.Rejected" xml:space="preserve">
<value>Could not parse: {0}</value>
</data>
<data name="Proxies.Status.RejectedMore" xml:space="preserve">
<value>and {0} more</value>
</data>
<data name="Proxies.Status.Answered" xml:space="preserve">
<value>{0} of {1} answered.</value>
</data>
<data name="Proxies.Status.CheckCancelled" xml:space="preserve">
<value>Check cancelled.</value>
</data>
<data name="Proxies.Status.Removed" xml:space="preserve">
<value>Removed {0}.</value>
</data>
<data name="Proxies.Status.Cleared" xml:space="preserve">
<value>Custom list cleared.</value>
</data>
<data name="Proxies.Status.Failed" xml:space="preserve">
<value>Failed: {0}</value>
</data>
<data name="Settings.Appearance" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="Settings.Theme" xml:space="preserve">
<value>THEME</value>
</data>
<data name="Settings.ThemeHint" xml:space="preserve">
<value>System follows the operating system's light/dark setting.</value>
</data>
<data name="Settings.Language" xml:space="preserve">
<value>LANGUAGE</value>
</data>
<data name="Settings.LanguageHint" xml:space="preserve">
<value>Applies immediately — no restart needed.</value>
</data>
<data name="Settings.Diagnostics" xml:space="preserve">
<value>Diagnostics</value>
</data>
<data name="Settings.LogLevel" xml:space="preserve">
<value>MINIMUM LOG LEVEL</value>
</data>
<data name="Settings.LogLevelHint" xml:space="preserve">
<value>Applies immediately — no restart needed.</value>
</data>
<data name="Settings.SettingsFile" xml:space="preserve">
<value>SETTINGS FILE</value>
</data>
<data name="Settings.LogDirectory" xml:space="preserve">
<value>LOG DIRECTORY</value>
</data>
<data name="Settings.Proxies" xml:space="preserve">
<value>Proxies</value>
</data>
<data name="Settings.Rotation" xml:space="preserve">
<value>ROTATION</value>
</data>
<data name="Settings.LivenessCheck" xml:space="preserve">
<value>LIVENESS CHECK</value>
</data>
<data name="Settings.UseFeed" xml:space="preserve">
<value>Use the public proxifly feed</value>
</data>
<data name="Settings.ProbeUrl" xml:space="preserve">
<value>PROBE URL</value>
</data>
<data name="Settings.ProbeUrlHint" xml:space="preserve">
<value>Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones.</value>
</data>
<data name="Settings.ProbeTimeout" xml:space="preserve">
<value>PROBE TIMEOUT (SEC)</value>
</data>
<data name="Settings.ProbeConcurrency" xml:space="preserve">
<value>PARALLEL PROBES</value>
</data>
<data name="Settings.Rotation.Sticky" xml:space="preserve">
<value>One proxy per session, replaced only when it fails. Keeps site sessions intact.</value>
</data>
<data name="Settings.Rotation.RoundRobin" xml:space="preserve">
<value>A different proxy on every request. Spreads rate limits, but breaks session cookies.</value>
</data>
<data name="Settings.Rotation.WeightedRandom" xml:space="preserve">
<value>Random, weighted by feed score and how often the proxy has actually worked here.</value>
</data>
<data name="Settings.HealthCheck.Pool" xml:space="preserve">
<value>Probe the whole pool up front, in parallel. One sweep, then no per-request delay.</value>
</data>
<data name="Settings.HealthCheck.Lazy" xml:space="preserve">
<value>Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip.</value>
</data>
<data name="Settings.Breakpoints" xml:space="preserve">
<value>Layout breakpoints</value>
</data>
<data name="Settings.BreakpointsHint" xml:space="preserve">
<value>Window widths at which the navigation changes shape. Resize the window to see it happen.</value>
</data>
<data name="Settings.Breakpoint.Compact" xml:space="preserve">
<value>COMPACT</value>
</data>
<data name="Settings.Breakpoint.Medium" xml:space="preserve">
<value>MEDIUM</value>
</data>
<data name="Settings.Breakpoint.Expanded" xml:space="preserve">
<value>EXPANDED</value>
</data>
<data name="Settings.Breakpoint.CompactHint" xml:space="preserve">
<value>below {0} px — overlay drawer</value>
</data>
<data name="Settings.Breakpoint.MediumHint" xml:space="preserve">
<value>{0} {1} px — icon rail</value>
</data>
<data name="Settings.Breakpoint.ExpandedHint" xml:space="preserve">
<value>from {0} px — full sidebar</value>
</data>
<data name="About.BuiltWith" xml:space="preserve">
<value>Built with</value>
</data>
<data name="About.OnDisk" xml:space="preserve">
<value>On disk</value>
</data>
<data name="About.Data" xml:space="preserve">
<value>DATA</value>
</data>
<data name="About.Logs" xml:space="preserve">
<value>LOGS</value>
</data>
<data name="About.Component.Dotnet" xml:space="preserve">
<value>.NET</value>
</data>
<data name="About.Component.Os" xml:space="preserve">
<value>Operating system</value>
</data>
<data name="About.Component.Avalonia" xml:space="preserve">
<value>Avalonia</value>
</data>
<data name="About.Component.ReactiveUI" xml:space="preserve">
<value>ReactiveUI</value>
</data>
<data name="About.Component.Semi" xml:space="preserve">
<value>Semi.Avalonia</value>
</data>
<data name="About.Component.NotLoaded" xml:space="preserve">
<value>not loaded</value>
</data>
<data name="Enum.AppTheme.System" xml:space="preserve">
<value>System</value>
</data>
<data name="Enum.AppTheme.Light" xml:space="preserve">
<value>Light</value>
</data>
<data name="Enum.AppTheme.Dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="Enum.AppLanguage.System" xml:space="preserve">
<value>System</value>
</data>
<data name="Enum.AppLanguage.English" xml:space="preserve">
<value>English</value>
</data>
<data name="Enum.AppLanguage.Russian" xml:space="preserve">
<value>Русский</value>
</data>
<data name="Enum.ProxyRotation.Sticky" xml:space="preserve">
<value>Sticky</value>
</data>
<data name="Enum.ProxyRotation.RoundRobin" xml:space="preserve">
<value>Round-robin</value>
</data>
<data name="Enum.ProxyRotation.WeightedRandom" xml:space="preserve">
<value>Weighted random</value>
</data>
<data name="Enum.ProxyHealthCheck.Pool" xml:space="preserve">
<value>Whole pool</value>
</data>
<data name="Enum.ProxyHealthCheck.Lazy" xml:space="preserve">
<value>On hand-out</value>
</data>
<data name="Enum.ProxyProtocolFilter.All" xml:space="preserve">
<value>All</value>
</data>
<data name="Enum.ProxyProtocolFilter.Http" xml:space="preserve">
<value>HTTP</value>
</data>
<data name="Enum.ProxyProtocolFilter.Https" xml:space="preserve">
<value>HTTPS</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks4" xml:space="preserve">
<value>SOCKS4</value>
</data>
<data name="Enum.ProxyProtocolFilter.Socks5" xml:space="preserve">
<value>SOCKS5</value>
</data>
<data name="Enum.ProxyHealthFilter.All" xml:space="preserve">
<value>All</value>
</data>
<data name="Enum.ProxyHealthFilter.Alive" xml:space="preserve">
<value>Alive</value>
</data>
<data name="Enum.ProxyHealthFilter.Dead" xml:space="preserve">
<value>Dead</value>
</data>
<data name="Enum.ProxyHealthFilter.Unchecked" xml:space="preserve">
<value>Unchecked</value>
</data>
<data name="Parser.delimited.Name" xml:space="preserve">
<value>Delimited text</value>
</data>
<data name="Parser.delimited.Description" xml:space="preserve">
<value>First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).</value>
</data>
<data name="Parser.key-value.Name" xml:space="preserve">
<value>Key / value pairs</value>
</data>
<data name="Parser.key-value.Description" xml:space="preserve">
<value>One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.</value>
</data>
<data name="Parse.Error.FieldCount" xml:space="preserve">
<value>Expected {0} field(s) but found {1}.</value>
</data>
<data name="Parse.Error.NoHeader" xml:space="preserve">
<value>Input contains no header row.</value>
</data>
<data name="Parse.Error.NoSeparator" xml:space="preserve">
<value>No '=' or ':' separator found.</value>
</data>
<data name="Parse.Error.EmptyKey" xml:space="preserve">
<value>Key is empty.</value>
</data>
</root>