Browse Source

Merge pull request #529 from PixiEditor/localization-window-improvements

Localization window improvements
Krzysztof Krysiński 2 years ago
parent
commit
5f5da35f2e

+ 17 - 2
src/PixiEditor/Data/Localization/Languages/en.json

@@ -540,5 +540,20 @@
     "REMOVE": "Remove",
     "FILE_FORMAT_NOT_ASEPRITE_KEYS": "File is not a \".aseprite-keys\" file",
     "FILE_HAS_INVALID_SHORTCUT": "The file contains an invalid shortcut",
-    "FILE_EXTENSION_NOT_SUPPORTED": "The file type '{0}' is not supported"
-}
+    "FILE_EXTENSION_NOT_SUPPORTED": "The file type '{0}' is not supported",
+  "OPEN_LOCALIZATION_DATA": "Do you want to open the LocalizationData.json?\nThe updated date has been put in the clipboard.\nNote that changes wont be applied until a restart",
+  "DOWNLOADING_LANGUAGE_FAILED": "Downloading language failed.\nAPI Key might have been overused.",
+  "LOCALIZATION_DATA_NOT_FOUND": "Localization data path not found",
+  "APPLY": "Apply",
+  "UPDATE_SOURCE": "Update source",
+  "COPY_TO_CLIPBOARD": "Copy to clipboard",
+  "LANGUAGE_FILE_NOT_FOUND": "Language file not found.\nLooking for {0}",
+  "PROJECT_ROOT_NOT_FOUND": "PixiEditor Project root not found.\nLooking for PixiEditor.csproj",
+  "LOCALIZATION_FOLDER_NOT_FOUND": "Localization folder not found.\nLooking for /Data/Localization",
+  "SELECT_A_LANGUAGE": "Select a language",
+  "DONE": "Done",
+  "SOURCE_UNSET_OR_MISSING": "Source missing/unset",
+  "SOURCE_NEWER": "Source newer",
+  "SOURCE_UP_TO_DATE": "Source is up to date",
+  "SOURCE_OLDER": "Cloud newer"
+}

+ 1 - 1
src/PixiEditor/Data/Localization/LocalizationData.json

@@ -6,7 +6,7 @@
       "code": "en",
       "localeFileName": "en.json",
       "iconFileName": "en.png",
-      "lastUpdated": "2023-05-07 13:31:45"
+      "lastUpdated": "2023-05-08 19:30:00"
     },
     {
       "name": "Polski",

+ 1 - 1
src/PixiEditor/Helpers/CrashHelper.cs

@@ -103,7 +103,7 @@ internal class CrashHelper
 
     public static async Task SendExceptionInfoToWebhook(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
     {
-        if (ViewModelMain.Current.DebugSubViewModel.IsDebugBuild)
+        if (DebugViewModel.IsDebugBuild)
             return;
         await SendReportTextToWebhook(CrashReport.Generate(e), $"{filePath}; Method {memberName}");
     }

+ 26 - 5
src/PixiEditor/Styles/ComboBoxDarkStyle.xaml

@@ -11,7 +11,7 @@
     <SolidColorBrush x:Key="ComboBox.Static.Editable.Border" Color="#2F2F37" />
     <SolidColorBrush x:Key="ComboBox.Static.Editable.Button.Background" Color="Transparent" />
     <SolidColorBrush x:Key="ComboBox.Static.Editable.Button.Border" Color="Transparent" />
-    <SolidColorBrush x:Key="ComboBox.MouseOver.Background" Color="#333333" />
+    <SolidColorBrush x:Key="ComboBox.MouseOver.Background" Color="White" />
     <SolidColorBrush x:Key="ComboBox.MouseOver.Border" Color="#FF0000" />
     <SolidColorBrush x:Key="ComboBox.MouseOver.Glyph" Color="#007ACC" />
     <SolidColorBrush x:Key="ComboBox.MouseOver.Editable.Background" Color="#333333" />
@@ -42,12 +42,21 @@
                 <ControlTemplate TargetType="{x:Type ToggleButton}">
                     <Border x:Name="templateRoot" Background="{StaticResource AccentColor}"
                             BorderThickness="{TemplateBinding BorderThickness}"
-                            CornerRadius="5"
                             BorderBrush="{StaticResource ComboBox.Static.Border}" SnapsToDevicePixels="true">
                         <Path x:Name="arrow"
                             Data="M 0 3 L 3 6 L 6 3 L 6 0 L 3 3 L 0 0 L 0 3 Z"
                             Fill="{StaticResource BrighterAccentColor}" HorizontalAlignment="Right" Margin="5"
                             VerticalAlignment="Center" />
+                        <Border.Style>
+                            <Style TargetType="Border">
+                                <Setter Property="CornerRadius" Value="5"/>
+                                <Style.Triggers>
+                                    <DataTrigger Binding="{Binding IsChecked, RelativeSource={RelativeSource TemplatedParent}}" Value="true">
+                                        <Setter Property="CornerRadius" Value="5,5,0,0"/>
+                                    </DataTrigger>
+                                </Style.Triggers>
+                            </Style>
+                        </Border.Style>
                     </Border>
                     <ControlTemplate.Triggers>
                         <MultiDataTrigger>
@@ -103,10 +112,10 @@
             <Popup x:Name="PART_Popup" AllowsTransparency="true" Grid.ColumnSpan="2"
                    IsOpen="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
                    Margin="1" Placement="Bottom">
-                <theme:SystemDropShadowChrome x:Name="shadow" Color="Transparent" CornerRadius="5"
+                <theme:SystemDropShadowChrome x:Name="shadow" Color="Transparent" CornerRadius="0,0,5,5"
                                               MinWidth="{Binding ActualWidth, ElementName=templateRoot}"
                                               MaxHeight="{TemplateBinding MaxDropDownHeight}">
-                    <Border x:Name="dropDownBorder" CornerRadius="5"
+                    <Border x:Name="dropDownBorder" CornerRadius="0,0,5,5"
                             Background="{StaticResource DarkerAccentColor}" BorderThickness="1"
                             BorderBrush="{StaticResource BrighterAccentColor}">
                         <ScrollViewer x:Name="DropDownScrollViewer" Margin="1">
@@ -182,9 +191,21 @@
         <Setter Property="Template">
             <Setter.Value>
                 <ControlTemplate TargetType="ComboBoxItem">
-                    <Border Name="Bd" Background="{TemplateBinding Background}" BorderThickness="0">
+                    <Border Name="Bd" Background="{TemplateBinding Background}" BorderThickness="0,1">
                         <ContentPresenter Content="{TemplateBinding Content}" HorizontalAlignment="Stretch"
                                           VerticalAlignment="Center" Margin="4,2"/>
+                        
+                        <Border.Style>
+                            <Style TargetType="Border">
+                                <Setter Property="BorderBrush" Value="Transparent" />
+                                <Style.Triggers>
+                                    <Trigger Property="IsMouseOver" Value="True">
+                                        <Setter Property="BorderBrush" Value="{StaticResource AlmostLightModeAccentColor}" />
+                                    </Trigger>
+                                </Style.Triggers>
+                            </Style>
+
+                        </Border.Style>
                     </Border>
                     <ControlTemplate.Triggers>
                         <Trigger Property="IsHighlighted" Value="True">

+ 8 - 5
src/PixiEditor/ViewModels/SubViewModels/Main/DebugViewModel.cs

@@ -1,25 +1,26 @@
 using System.Diagnostics;
 using System.IO;
 using System.Reflection;
+using System.Windows;
 using System.Windows.Input;
 using Microsoft.Win32;
 using Newtonsoft.Json;
 using PixiEditor.Helpers;
 using PixiEditor.Localization;
-using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Templates.Parsers;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.UserPreferences;
-using PixiEditor.Views.Dialogs;
+using PixiEditor.Views.Dialogs.DebugDialogs;
+using PixiEditor.Views.Dialogs.DebugDialogs.Localization;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main;
 
 [Command.Group("PixiEditor.Debug", "DEBUG")]
 internal class DebugViewModel : SubViewModel<ViewModelMain>
 {
-    public bool IsDebugBuild { get; set; }
+    public static bool IsDebugBuild { get; set; }
 
     public bool IsDebugModeEnabled { get; set; }
 
@@ -198,7 +199,9 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
     [Command.Debug("PixiEditor.Debug.OpenLocalizationDebugWindow", "OPEN_LOCALIZATION_DEBUG_WINDOW", "OPEN_LOCALIZATION_DEBUG_WINDOW")]
     public void OpenLocalizationDebugWindow()
     {
-        new LocalizationDebugWindow().Show();
+        var window = Application.Current.Windows.OfType<LocalizationDebugWindow>().FirstOrDefault(new LocalizationDebugWindow());
+        window.Show();
+        window.Activate();
     }
 
     [Command.Internal("PixiEditor.Debug.SetLanguageFromFilePicker")]
@@ -244,7 +247,7 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
     }
 
     [Conditional("DEBUG")]
-    private void SetDebug() => IsDebugBuild = true;
+    private static void SetDebug() => IsDebugBuild = true;
 
     private void UpdateDebugMode(bool setting)
     {

+ 2 - 1
src/PixiEditor/ViewModels/SubViewModels/Tools/ToolSettings/Settings/EnumSetting.cs

@@ -72,7 +72,8 @@ internal sealed class EnumSetting<TEnum> : Setting<TEnum, ComboBox>
     {
         var combobox = new ComboBox
         {
-            VerticalAlignment = VerticalAlignment.Center
+            VerticalAlignment = VerticalAlignment.Center,
+            MinWidth = 85
         };
 
         GenerateItems(combobox);

+ 1 - 1
src/PixiEditor/Views/Dialogs/CommandDebugPopup.xaml → src/PixiEditor/Views/Dialogs/DebugDialogs/CommandDebugPopup.xaml

@@ -1,4 +1,4 @@
-<Window x:Class="PixiEditor.Views.Dialogs.CommandDebugPopup"
+<Window x:Class="PixiEditor.Views.Dialogs.DebugDialogs.CommandDebugPopup"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

+ 2 - 4
src/PixiEditor/Views/Dialogs/CommandDebugPopup.xaml.cs → src/PixiEditor/Views/Dialogs/DebugDialogs/CommandDebugPopup.xaml.cs

@@ -1,5 +1,4 @@
-using System.Collections.ObjectModel;
-using System.Windows;
+using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Documents;
 using System.Windows.Input;
@@ -7,9 +6,8 @@ using System.Windows.Media;
 using PixiEditor.Models.Commands;
 using PixiEditor.Models.Commands.Commands;
 using PixiEditor.Models.Commands.Evaluators;
-using PixiEditor.Models.DataHolders;
 
-namespace PixiEditor.Views.Dialogs;
+namespace PixiEditor.Views.Dialogs.DebugDialogs;
 
 public partial class CommandDebugPopup : Window
 {

+ 440 - 0
src/PixiEditor/Views/Dialogs/DebugDialogs/Localization/LocalizationDataContext.cs

@@ -0,0 +1,440 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Net.Http;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Threading;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using PixiEditor.Helpers;
+using PixiEditor.Localization;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.UserPreferences;
+
+namespace PixiEditor.Views.Dialogs.DebugDialogs.Localization;
+
+internal class LocalizationDataContext : NotifyableObject
+{
+    private const int ProjectId = 400351;
+
+    private Dispatcher dispatcher;
+    private string apiKey;
+    private bool loggedIn;
+    private LocalizedString statusMessage = "NOT_LOGGED_IN";
+    private PoeLanguage selectedLanguage;
+
+    public DebugViewModel DebugViewModel { get; } = ViewModelMain.Current.DebugSubViewModel;
+
+    public string ApiKey
+    {
+        get => apiKey;
+        set
+        {
+            if (SetProperty(ref apiKey, value))
+            {
+                PreferencesSettings.Current.UpdateLocalPreference("POEditor_API_Key", apiKey);
+            }
+        }
+    }
+
+    public bool LoggedIn
+    {
+        get => loggedIn;
+        set => SetProperty(ref loggedIn, value);
+    }
+
+    public LocalizedString StatusMessage
+    {
+        get => statusMessage;
+        set => SetProperty(ref statusMessage, value);
+    }
+
+    public PoeLanguage SelectedLanguage
+    {
+        get => selectedLanguage;
+        set => SetProperty(ref selectedLanguage, value);
+    }
+
+    public ObservableCollection<PoeLanguage> LanguageCodes { get; } = new();
+
+    public RelayCommand LoadApiKeyCommand { get; }
+
+    public RelayCommand ApplyLanguageCommand { get; }
+
+    public RelayCommand CopySelectedUpdatedCommand { get; }
+    
+    public RelayCommand UpdateSourceCommand { get; }
+
+    public LocalizationDataContext()
+    {
+        dispatcher = Application.Current.Dispatcher;
+        apiKey = PreferencesSettings.Current.GetLocalPreference<string>("POEditor_API_Key");
+        LoadApiKeyCommand = new RelayCommand(LoadApiKey, _ => !string.IsNullOrWhiteSpace(apiKey));
+        ApplyLanguageCommand =
+            new RelayCommand(ApplyLanguage, _ => loggedIn && SelectedLanguage != null);
+        CopySelectedUpdatedCommand = new RelayCommand(_ =>
+            Clipboard.SetText(SelectedLanguage.UpdatedUTC.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)));
+        UpdateSourceCommand = new RelayCommand(UpdateSource);
+    }
+
+    private void LoadApiKey(object parameter)
+    {
+        LanguageCodes.Clear();
+        Mouse.OverrideCursor = Cursors.Wait;
+
+        Task.Run(async () =>
+        {
+            try
+            {
+                var result = await CheckProjectByIdAsync(ApiKey);
+
+                dispatcher.Invoke(() =>
+                {
+                    LoggedIn = result.IsSuccess;
+                    StatusMessage = result.Message;
+
+                    if (!result.IsSuccess)
+                    {
+                        return;
+                    }
+
+                    foreach (var language in result.Output
+                                 .OrderByDescending(x =>
+                                     CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == x.Code ||
+                                     CultureInfo.InstalledUICulture.TwoLetterISOLanguageName == x.Code)
+                                 .ThenByDescending(x => x.UpdatedUTC))
+                    {
+                        language.LocalEquivalent = ILocalizationProvider.Current.LocalizationData.Languages
+                            .OrderByDescending(x => language.Code == x.Code)
+                            .FirstOrDefault(x => language.Code.StartsWith(x.Code));
+
+                        LanguageCodes.Add(language);
+                    }
+                });
+            }
+            catch (Exception e)
+            {
+                LoggedIn = false;
+                StatusMessage = new LocalizedString("EXCEPTION_ERROR", e.Message);
+            }
+            finally
+            {
+                dispatcher.Invoke(() => Mouse.OverrideCursor = null);
+            }
+        });
+    }
+
+    private void ApplyLanguage(object parameter)
+    {
+        Mouse.OverrideCursor = Cursors.Wait;
+
+        Task.Run(async () =>
+        {
+            try
+            {
+                var result = await DownloadLanguage(ApiKey, SelectedLanguage.Code);
+
+                dispatcher.Invoke(() =>
+                {
+                    StatusMessage = result.Message;
+                    DebugViewModel.Owner.LocalizationProvider.LoadDebugKeys(result.Output,
+                        SelectedLanguage.IsRightToLeft);
+                });
+            }
+            catch (Exception e)
+            {
+                StatusMessage = new LocalizedString("EXCEPTION_ERROR", e.Message);
+            }
+            finally
+            {
+                dispatcher.Invoke(() => Mouse.OverrideCursor = null);
+            }
+        });
+    }
+
+    private void UpdateSource(object obj)
+    {
+        if (!GetProjectRoot(out var localizationRoot))
+        {
+            return;
+        }
+        
+        var dataPath = Path.Combine(localizationRoot, "LocalizationData.json");
+
+        if (!File.Exists(dataPath))
+        {
+            NoticeDialog.Show("LOCALIZATION_DATA_NOT_FOUND", "ERROR");
+        }
+
+        string code = SelectedLanguage.Code;
+        
+        if (!GetLanguageFile(code, localizationRoot, out string languagePath))
+        {
+            return;
+        }
+
+        Task.Run(async () => await UpdateSourceAsync(code, languagePath, dataPath));
+    }
+
+    private async Task UpdateSourceAsync(string code, string path, string dataPath)
+    {
+        // Fetch latest data to make sure data is up to date
+        var languages = await CheckProjectByIdAsync(apiKey);
+
+        if (!languages.IsSuccess)
+        {
+            dispatcher.Invoke(() =>
+            {
+                NoticeDialog.Show(new LocalizedString("DOWNLOADING_LANGUAGE_FAILED", languages.Message), "ERROR");
+            });
+        }
+
+        var language = languages.Output.First(x => x.Code == code);
+        
+        try
+        {
+            var languageData = await DownloadLanguage(apiKey, code);
+
+            if (!languageData.IsSuccess)
+            {
+                dispatcher.Invoke(() =>
+                {
+                    NoticeDialog.Show(new LocalizedString("DOWNLOADING_LANGUAGE_FAILED", languageData.Message), "ERROR");
+                });
+            }
+            
+            await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(languageData.Output, Formatting.Indented));
+        }
+        catch (Exception e)
+        {
+            dispatcher.Invoke(() =>
+            {
+                NoticeDialog.Show(new LocalizedString("DOWNLOADING_LANGUAGE_FAILED", e), "ERROR");
+            });
+        }
+
+        dispatcher.Invoke(() =>
+        {
+            Clipboard.SetText(language.UpdatedUTC.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
+            
+            var dialog = new OptionsDialog<string>("SUCCESS", new LocalizedString("OPEN_LOCALIZATION_DATA"));
+            dialog["VS Code"] = _ => ProcessHelpers.ShellExecute($"vscode://file/{dataPath}");
+            dialog[new LocalizedString("DEFAULT")] = _ => ProcessHelpers.ShellExecute(dataPath);
+            dialog[new LocalizedString("CANCEL")] = null;
+
+            dialog.ShowDialog();
+        });
+    }
+
+    private static bool GetLanguageFile(string code, string root, [NotNullWhen(true)] out string? languagePath)
+    {
+        root = Path.Combine(root, "Languages");
+
+        languagePath = null;
+        string file;
+        
+        if (code.Length == 2)
+        {
+            file = Path.Combine(root, $"{code}.json");
+            languagePath = file;
+            
+            if (!File.Exists(file))
+            {
+                File.Create(file);
+            }
+
+            return true;
+        }
+        
+        file = Path.Combine(root, $"{code}.json");
+        
+        if (File.Exists(file))
+        {
+            languagePath = file;
+            return true;
+        }
+        
+        string file2 = Path.Combine(root, $"{code[..2]}.json");
+        
+        if (File.Exists(file2))
+        {
+            languagePath = file2;
+            return true;
+        }
+
+        NoticeDialog.Show(new LocalizedString("LANGUAGE_FILE_NOT_FOUND", $"{Path.GetFileName(file)} or {Path.GetFileName(file2)}"), "ERROR");
+        return false;
+    }
+
+    private static bool GetProjectRoot([NotNullWhen(true)] out string? root)
+    {
+        const string fileName = "PixiEditor.csproj";
+        root = Directory.GetCurrentDirectory();
+
+        while (root != null)
+        {
+            string[] files = Directory.GetFiles(root, fileName, SearchOption.TopDirectoryOnly);
+
+            if (files.Length > 0)
+            {
+                // Found the file in the current directory
+                break;
+            }
+
+            // Move up to the parent directory
+            root = Directory.GetParent(root)?.FullName;
+        }
+
+        if (!Directory.Exists(root))
+        {
+            NoticeDialog.Show("PROJECT_ROOT_NOT_FOUND", "ERROR");
+            return false;
+        }
+        
+        root = Path.Combine(root, "Data", "Localization");
+        
+        if (!Directory.Exists(root))
+        {
+            NoticeDialog.Show("LOCALIZATION_FOLDER_NOT_FOUND", "ERROR");
+            return false;
+        }
+
+        return true;
+    }
+
+    private static async Task<Result<PoeLanguage[]>>
+        CheckProjectByIdAsync(string key)
+    {
+        using HttpClient client = new HttpClient();
+
+        // --- Check if user is part of project ---
+        var response = await PostAsync(client, "https://api.poeditor.com/v2/projects/list", key);
+        var result = await ParseResponseAsync(response);
+
+        if (!result.IsSuccess)
+        {
+            return result.As<PoeLanguage[]>();
+        }
+
+        var projects = (JArray)result.Output["result"]["projects"];
+
+        // Check if user is part of project
+        if (!projects.Any(x => x["id"].Value<int>() == ProjectId))
+        {
+            return Error("LOGGED_IN_NO_PROJECT_ACCESS");
+        }
+
+        response = await PostAsync(client, "https://api.poeditor.com/v2/languages/list", key,
+            ("id", ProjectId.ToString()));
+        result = await ParseResponseAsync(response);
+
+        if (!result.IsSuccess)
+        {
+            return result.As<PoeLanguage[]>();
+        }
+
+        var languages = result.Output["result"]["languages"].ToObject<PoeLanguage[]>();
+
+        return Result.Success(new LocalizedString("LOGGED_IN"), languages);
+
+        Result<PoeLanguage[]> Error(LocalizedString message) => Result.Error<PoeLanguage[]>(message);
+    }
+
+    private static async Task<Result<Dictionary<string, string>>> DownloadLanguage(
+        string key,
+        string language)
+    {
+        using var client = new HttpClient();
+
+        // Get Url to key_value_json in language
+        var response = await PostAsync(
+            client,
+            "https://api.poeditor.com/v2/projects/export",
+            key,
+            ("id", ProjectId.ToString()), ("type", "key_value_json"), ("language", language));
+
+        var result = await ParseResponseAsync(response);
+
+        if (!result.IsSuccess)
+        {
+            return result.As<Dictionary<string, string>>();
+        }
+
+        response = await client.GetAsync(result.Output["result"]["url"].Value<string>());
+
+        // Failed with an HTTP error code, according to API docs this should not be possible
+        if (!response.IsSuccessStatusCode)
+        {
+            return Error(new LocalizedString("HTTP_ERROR_MESSAGE", (int)response.StatusCode, response.StatusCode));
+        }
+
+        string responseJson = await response.Content.ReadAsStringAsync();
+        var keys = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseJson);
+
+        return Result.Success("SYNCED_SUCCESSFULLY", keys);
+
+        Result<Dictionary<string, string>> Error(LocalizedString message) =>
+            Result.Error<Dictionary<string, string>>(message);
+    }
+
+    private static async Task<Result<JObject>> ParseResponseAsync(HttpResponseMessage response)
+    {
+        // Failed with an HTTP error code, according to API docs this should not be possible
+        if (!response.IsSuccessStatusCode)
+        {
+            return Error("HTTP_ERROR_MESSAGE", (int)response.StatusCode, response.StatusCode);
+        }
+
+        string jsonResponse = await response.Content.ReadAsStringAsync();
+        var root = JObject.Parse(jsonResponse);
+
+        var rsp = root["response"];
+        string rspCode = rsp["code"].Value<string>();
+
+        // Failed with an error code from the POEditor API, alongside a message
+        if (rspCode != "200")
+        {
+            return Error("POE_EDITOR_ERROR", rspCode, rsp["message"].Value<string>());
+        }
+
+        return Result.Success(root);
+
+        Result<JObject> Error(string key, params object[] param) =>
+            Result.Error<JObject>(new LocalizedString(key, param));
+    }
+
+    private static Task<HttpResponseMessage> PostAsync(HttpClient client, string requestUri, string apiKey,
+        params (string key, string value)[] body)
+    {
+        var bodyKeys = new List<KeyValuePair<string, string>>(
+            body.Select(x => new KeyValuePair<string, string>(x.key, x.value))) { new("api_token", apiKey) };
+
+        return client.PostAsync(requestUri, new FormUrlEncodedContent(bodyKeys));
+    }
+
+    private struct Result
+    {
+        public static Result<T> Error<T>(LocalizedString message) => new(false, message, default);
+
+        public static Result<T> Success<T>(LocalizedString message, T output) => new(true, message, output);
+
+        public static Result<T> Success<T>(T output) => new(true, null, output);
+    }
+
+    private record struct Result<T>(bool IsSuccess, LocalizedString Message, T Output)
+    {
+        public Result<TOther> As<TOther>()
+        {
+            if (IsSuccess)
+            {
+                throw new ArgumentException("Result can't be a success");
+            }
+
+            return new Result<TOther>(false, Message, default);
+        }
+    }
+}

+ 194 - 0
src/PixiEditor/Views/Dialogs/DebugDialogs/Localization/LocalizationDebugWindow.xaml

@@ -0,0 +1,194 @@
+<Window x:Class="PixiEditor.Views.Dialogs.DebugDialogs.Localization.LocalizationDebugWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:local="clr-namespace:PixiEditor.Views.Dialogs.DebugDialogs.Localization"
+        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
+        xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
+        xmlns:views="clr-namespace:PixiEditor.Views"
+        xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+        xmlns:enums="clr-namespace:PixiEditor.Models.Enums"
+        xmlns:helpers="clr-namespace:PixiEditor.Helpers"
+        xmlns:xaml="clr-namespace:PixiEditor.Models.Commands.XAML"
+        xmlns:dialogs="clr-namespace:PixiEditor.Views.Dialogs"
+        xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
+        xmlns:main="clr-namespace:PixiEditor.ViewModels.SubViewModels.Main"
+        x:Name="popup"
+        mc:Ignorable="d"
+        Foreground="White"
+        Title="LocalizationDebugWindow"
+        MinHeight="240" MinWidth="465"
+        Height="350" Width="465"
+        FlowDirection="{helpers:Localization FlowDirection}"
+        d:DataContext="{d:DesignInstance local:LocalizationDataContext}">
+
+    <WindowChrome.WindowChrome>
+        <WindowChrome CaptionHeight="32" GlassFrameThickness="0.1"
+                      ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
+    </WindowChrome.WindowChrome>
+
+    <Window.CommandBindings>
+        <CommandBinding Command="{x:Static SystemCommands.CloseWindowCommand}" CanExecute="CommandBinding_CanExecute"
+                        Executed="CommandBinding_Executed_Close" />
+    </Window.CommandBindings>
+
+    <Window.Resources>
+        <Style TargetType="Button" BasedOn="{StaticResource DarkRoundButton}" />
+    </Window.Resources>
+
+    <Grid Background="{StaticResource AccentColor}" Focusable="True">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition />
+        </Grid.RowDefinitions>
+
+        <b:Interaction.Behaviors>
+            <behaviours:ClearFocusOnClickBehavior />
+        </b:Interaction.Behaviors>
+
+        <dialogs:DialogTitleBar TitleText="Localization Debug Window"
+                                CloseCommand="{x:Static SystemCommands.CloseWindowCommand}" />
+
+        <StackPanel Grid.Row="1" Margin="5">
+            <StackPanel Orientation="Horizontal" Height="25">
+                <TextBlock views:Translator.Key="LOCALIZATION_VIEW_TYPE" Margin="0,0,5,0" MinWidth="160" />
+                <ComboBox
+                    SelectedItem="{Binding DebugViewModel.LocalizationKeyShowMode}"
+                    ItemsSource="{helpers:Enum {x:Type enums:LocalizationKeyShowMode}}" />
+            </StackPanel>
+            <StackPanel Orientation="Horizontal" Height="25" Margin="0,5,0,0">
+                <TextBlock views:Translator.Key="FORCE_OTHER_FLOW_DIRECTION" Margin="0,0,5,0" MinWidth="160" />
+                <CheckBox IsChecked="{Binding DebugViewModel.ForceOtherFlowDirection}" />
+            </StackPanel>
+            <Button views:Translator.Key="LOAD_LANGUAGE_FROM_FILE"
+                    Command="{xaml:Command PixiEditor.Debug.SetLanguageFromFilePicker}"
+                    Style="{StaticResource DarkRoundButton}" Margin="0,5,0,0" />
+            <TextBlock Text="POEditor" FontWeight="Bold" FontSize="22" Margin="0,10,0,0" />
+            <Grid Height="25">
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="120" />
+                    <ColumnDefinition />
+                    <ColumnDefinition Width="120" />
+                </Grid.ColumnDefinitions>
+                <TextBlock views:Translator.Key="API_KEY" Margin="0,0,5,0"></TextBlock>
+                <TextBox Grid.Column="1" Style="{StaticResource DarkTextBoxStyle}" TextChanged="ApiKeyChanged"
+                         Text="{Binding ApiKey}">
+                </TextBox>
+                <Button Margin="5,0,0,0" Grid.Column="2"
+                        views:Translator.Key="LOG_IN"
+                        Command="{Binding LoadApiKeyCommand}" />
+            </Grid>
+            <StackPanel
+                Visibility="{Binding LoggedIn, Mode=OneWay, Converter={BoolToVisibilityConverter}}">
+                <Grid Margin="0,5,0,0" Height="25">
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="120" />
+                        <ColumnDefinition />
+                    </Grid.ColumnDefinitions>
+                    <TextBlock views:Translator.Key="LANGUAGE" Margin="0,0,5,0" />
+                    <Grid Grid.Column="1">
+                        <ComboBox ItemsSource="{Binding LanguageCodes}"
+                                  SelectedItem="{Binding SelectedLanguage}"
+                                  x:Name="LanguageComboBox">
+                            <ComboBox.ItemContainerStyle>
+                                <Style TargetType="ComboBoxItem">
+                                    <Setter Property="ContentTemplate">
+                                        <Setter.Value>
+                                            <DataTemplate DataType="local:PoeLanguage">
+                                                <StackPanel Orientation="Horizontal">
+                                                    <Ellipse Width="10" Height="10"
+                                                             VerticalAlignment="Center"
+                                                             Fill="{Binding StatusBrush}"
+                                                             Margin="0,0,5,0" />
+                                                    <TextBlock VerticalAlignment="Center">
+                                                        <Run Text="{Binding Name}"/>
+                                                        <Run Text="{Binding Code, StringFormat='(\{0\})'}"/>
+                                                    </TextBlock>
+                                                </StackPanel>
+                                            </DataTemplate>
+                                        </Setter.Value>
+                                    </Setter>
+                                    <Setter Property="Template">
+                                        <Setter.Value>
+                                            <ControlTemplate TargetType="{x:Type ComboBoxItem}">
+                                                <Border Height="25" Margin="0" Padding="5,0" BorderThickness="0,1">
+                                                    <ContentPresenter/>
+                                                    <Border.Style>
+                                                        <Style TargetType="{x:Type Border}">
+                                                            <Style.Triggers>
+                                                                <Trigger Property="IsMouseOver" Value="False">
+                                                                    <Setter Property="Background" Value="Transparent"/>
+                                                                    <Setter Property="BorderBrush" Value="Transparent"/>
+                                                                </Trigger>
+                                                                <Trigger Property="IsMouseOver" Value="True">
+                                                                    <Setter Property="Background" Value="{StaticResource MainColor}"/>
+                                                                    <Setter Property="BorderBrush" Value="{StaticResource AlmostLightModeAccentColor}"/>
+                                                                </Trigger>
+                                                            </Style.Triggers>
+                                                        </Style>
+                                                    </Border.Style>
+                                                </Border>
+                                            </ControlTemplate>
+                                        </Setter.Value>
+                                    </Setter>
+                                </Style>
+                            </ComboBox.ItemContainerStyle>
+                        </ComboBox>
+                        <TextBlock views:Translator.Key="SELECT_A_LANGUAGE" Visibility="{Binding SelectedItem, ElementName=LanguageComboBox, Converter={converters:NullToVisibilityConverter}}"
+                                   Margin="5,0,0,0" VerticalAlignment="Center"
+                                   IsHitTestVisible="False">
+                        </TextBlock>
+                    </Grid>
+                </Grid>
+                <Border Background="{StaticResource DarkerAccentColor}" Padding="5"
+                        BorderThickness="1" CornerRadius="5" BorderBrush="{StaticResource BrighterAccentColor}" 
+                        Margin="0,5,0,5"
+                        Visibility="{Binding SelectedLanguage, Converter={converters:NotNullToVisibilityConverter}}">
+                    <StackPanel>
+                        <Grid Margin="0,0,0,5">
+                            <Grid.ColumnDefinitions>
+                                <ColumnDefinition/>
+                                <ColumnDefinition Width="Auto"/>
+                            </Grid.ColumnDefinitions>
+                            <TextBlock>
+                                <Run Text="{Binding SelectedLanguage.Name}"/>
+                                <Run Text="{Binding SelectedLanguage.Code, StringFormat='(\{0\})'}"/>
+                            </TextBlock>
+                            <TextBlock Grid.Column="1">
+                                <Run Text="{Binding SelectedLanguage.Percentage, Mode=OneWay, StringFormat='\{0\}%'}"/>
+                                <Run views:Translator.Key="DONE"/>
+                            </TextBlock>
+                        </Grid>
+                        <Grid Margin="0,0,0,5">
+                            <Grid.ColumnDefinitions>
+                                <ColumnDefinition Width="4" />
+                                <ColumnDefinition />
+                                <ColumnDefinition Width="Auto" />
+                            </Grid.ColumnDefinitions>
+                            <Border Background="{Binding SelectedLanguage.StatusBrush}" CornerRadius="2" />
+                            <TextBlock Grid.Column="1" views:Translator.LocalizedString="{Binding SelectedLanguage.StatusText}" Margin="5,0" />
+                            <TextBlock Grid.Column="2" views:Translator.TooltipKey="COPY_TO_CLIPBOARD">
+                                <Hyperlink Command="{Binding CopySelectedUpdatedCommand}">
+                                    <Run
+                                        Text="{Binding SelectedLanguage.UpdatedLocal, Mode=OneWay, StringFormat='g', ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" />
+                                    <Run Text=" &#xe855;" FontFamily="{StaticResource Feather}" />
+                                </Hyperlink>
+                            </TextBlock>
+                        </Grid>
+                        <Grid>
+                            <Grid.ColumnDefinitions>
+                                <ColumnDefinition />
+                                <ColumnDefinition Width="{Binding Source={x:Static main:DebugViewModel.IsDebugBuild}, Converter={converters:BoolToValueConverter FalseValue=Auto, TrueValue=*}}" />
+                            </Grid.ColumnDefinitions>
+                            <Button views:Translator.Key="APPLY" Command="{Binding ApplyLanguageCommand}" />
+                            <Button Grid.Column="1" Margin="5,0,0,0"  views:Translator.Key="UPDATE_SOURCE" Command="{Binding UpdateSourceCommand}"
+                                    Visibility="{Binding Source={x:Static main:DebugViewModel.IsDebugBuild}, Converter={BoolToVisibilityConverter}}"/>
+                        </Grid>
+                    </StackPanel>
+                </Border>
+            </StackPanel>
+            <TextBlock Text="{Binding StatusMessage}" />
+        </StackPanel>
+    </Grid>
+</Window>

+ 39 - 0
src/PixiEditor/Views/Dialogs/DebugDialogs/Localization/LocalizationDebugWindow.xaml.cs

@@ -0,0 +1,39 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace PixiEditor.Views.Dialogs.DebugDialogs.Localization;
+
+public partial class LocalizationDebugWindow : Window
+{
+    private static LocalizationDataContext dataContext;
+    private bool passedStartup;
+
+    public LocalizationDebugWindow()
+    {
+        InitializeComponent();
+        DataContext = (dataContext ??= new LocalizationDataContext());
+    }
+
+    private void ApiKeyChanged(object sender, TextChangedEventArgs e)
+    {
+        if (!passedStartup)
+        {
+            passedStartup = true;
+            return;
+        }
+        
+        dataContext.LoggedIn = false;
+        dataContext.StatusMessage = "NOT_LOGGED_IN";
+    }
+
+    private void CommandBinding_Executed_Close(object sender, ExecutedRoutedEventArgs e)
+    {
+        SystemCommands.CloseWindow(this);
+    }
+
+    private void CommandBinding_CanExecute(object sender, CanExecuteRoutedEventArgs e)
+    {
+        e.CanExecute = true;
+    }
+}

+ 74 - 0
src/PixiEditor/Views/Dialogs/DebugDialogs/Localization/PoeLanguage.cs

@@ -0,0 +1,74 @@
+using System.Globalization;
+using System.Windows.Media;
+using Newtonsoft.Json;
+using PixiEditor.Localization;
+
+namespace PixiEditor.Views.Dialogs.DebugDialogs.Localization;
+
+public class PoeLanguage
+    {
+        private static readonly SolidColorBrush LocalOlder = new(Colors.Red);
+        private static readonly SolidColorBrush LocalMin = new(Colors.Orange);
+        private static readonly SolidColorBrush Equal = new(Colors.Lime);
+        private static readonly SolidColorBrush LocalNewer = new(Colors.DodgerBlue);
+        private static readonly SolidColorBrush LocalMissing = new(Colors.Gray);
+
+        public string Name { get; set; }
+
+        public string Code { get; set; }
+
+        [JsonProperty("Updated")]
+        public string UpdatedText { get; set; }
+
+        [JsonIgnore]
+        public DateTimeOffset UpdatedUTC => string.IsNullOrWhiteSpace(UpdatedText)
+            ? DateTimeOffset.MinValue
+            : DateTimeOffset.Parse(UpdatedText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
+        
+        public double Percentage { get; set; }
+
+        public DateTimeOffset UpdatedLocal => UpdatedUTC.ToLocalTime();
+
+        public bool IsRightToLeft => Code is "ar" or "he" or "ku" or "fa" or "ur";
+
+        public LanguageData LocalEquivalent { get; set; }
+
+        public SolidColorBrush StatusBrush => Status switch
+        {
+            LanguageStatus.LocalMissing => LocalMissing,
+            LanguageStatus.LocalMin => LocalMin,
+            LanguageStatus.LocalOlder => LocalOlder,
+            LanguageStatus.Equal => Equal,
+            LanguageStatus.LocalNewer => LocalNewer,
+            _ => throw new ArgumentOutOfRangeException()
+        };
+
+        public LocalizedString StatusText => Status switch
+        {
+            LanguageStatus.LocalMissing or LanguageStatus.LocalMin => new LocalizedString("SOURCE_UNSET_OR_MISSING"),
+            LanguageStatus.LocalOlder => new LocalizedString("SOURCE_NEWER"),
+            LanguageStatus.Equal => new LocalizedString("SOURCE_UP_TO_DATE"),
+            LanguageStatus.LocalNewer => new LocalizedString("SOURCE_OLDER")
+        };
+
+        private LanguageStatus Status => (l: LocalEquivalent?.LastUpdated, r: UpdatedUTC) switch
+        {
+            (null, _) => LanguageStatus.LocalMissing,
+            { l.Ticks: 0 } => LanguageStatus.LocalMin,
+            { l: var l, r: var r } when r > l.Value => LanguageStatus.LocalOlder,
+            { l: var l, r: var r } when r == l.Value => LanguageStatus.Equal,
+            { l: var l, r: var r } when r < l.Value => LanguageStatus.LocalNewer,
+            _ => throw new ArgumentOutOfRangeException()
+        };
+
+        public override string ToString() => Name;
+
+        enum LanguageStatus
+        {
+            LocalMissing,
+            LocalMin,
+            LocalOlder,
+            Equal,
+            LocalNewer
+        }
+    }

+ 0 - 98
src/PixiEditor/Views/Dialogs/LocalizationDebugWindow.xaml

@@ -1,98 +0,0 @@
-<Window x:Class="PixiEditor.Views.Dialogs.LocalizationDebugWindow"
-        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
-        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
-        xmlns:local="clr-namespace:PixiEditor.Views.Dialogs"
-        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
-        xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
-        xmlns:views="clr-namespace:PixiEditor.Views"
-        xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
-        xmlns:enums="clr-namespace:PixiEditor.Models.Enums"
-        xmlns:helpers="clr-namespace:PixiEditor.Helpers"
-        xmlns:xaml="clr-namespace:PixiEditor.Models.Commands.XAML"
-        x:Name="popup"
-        mc:Ignorable="d"
-        Foreground="White"
-        Title="LocalizationDebugWindow" 
-        MinHeight="240" MinWidth="480"
-        Height="250" Width="500"
-        FlowDirection="{helpers:Localization FlowDirection}">
-
-    <WindowChrome.WindowChrome>
-        <WindowChrome CaptionHeight="32"  GlassFrameThickness="0.1"
-                      ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
-    </WindowChrome.WindowChrome>
-
-    <Window.CommandBindings>
-        <CommandBinding Command="{x:Static SystemCommands.CloseWindowCommand}" CanExecute="CommandBinding_CanExecute"
-                        Executed="CommandBinding_Executed_Close" />
-    </Window.CommandBindings>
-
-    <Grid Background="{StaticResource AccentColor}" Focusable="True">
-        <Grid.RowDefinitions>
-            <RowDefinition Height="Auto"/>
-            <RowDefinition/>
-        </Grid.RowDefinitions>
-        
-        <b:Interaction.Behaviors>
-            <behaviours:ClearFocusOnClickBehavior/>
-        </b:Interaction.Behaviors>
-
-        <local:DialogTitleBar TitleText="Localization Debug Window" CloseCommand="{x:Static SystemCommands.CloseWindowCommand}" />
-
-        <StackPanel Grid.Row="1" Margin="5">
-            <StackPanel Orientation="Horizontal" Height="25">
-                <TextBlock views:Translator.Key="LOCALIZATION_VIEW_TYPE" Margin="0,0,5,0" MinWidth="160"/>
-                <ComboBox SelectedItem="{Binding DataContext.DebugViewModel.LocalizationKeyShowMode, ElementName=popup}"
-                          ItemsSource="{helpers:Enum {x:Type enums:LocalizationKeyShowMode}}"/>
-            </StackPanel>
-            <StackPanel Orientation="Horizontal" Height="25" Margin="0,5,0,0">
-                <TextBlock views:Translator.Key="FORCE_OTHER_FLOW_DIRECTION" Margin="0,0,5,0" MinWidth="160"/>
-                <CheckBox IsChecked="{Binding DataContext.DebugViewModel.ForceOtherFlowDirection, ElementName=popup}" />
-            </StackPanel>
-            <Button views:Translator.Key="LOAD_LANGUAGE_FROM_FILE" Command="{xaml:Command PixiEditor.Debug.SetLanguageFromFilePicker}"
-                    Style="{StaticResource DarkRoundButton}" Margin="0,5,0,0"/>
-            <TextBlock Text="POEditor" FontWeight="Bold" FontSize="22" Margin="0,10,0,0"/>
-            <Grid Height="25">
-                <Grid.ColumnDefinitions>
-                    <ColumnDefinition Width="120"/>
-                    <ColumnDefinition/>
-                    <ColumnDefinition Width="120"/>
-                </Grid.ColumnDefinitions>
-                <TextBlock views:Translator.Key="API_KEY" Margin="0,0,5,0"></TextBlock>
-                <TextBox Grid.Column="1" Style="{StaticResource DarkTextBoxStyle}" TextChanged="ApiKeyChanged" Text="{Binding DataContext.ApiKey, ElementName=popup}"></TextBox>
-                <Button Style="{StaticResource DarkRoundButton}" Margin="5,0,0,0" Grid.Column="2" views:Translator.Key="LOG_IN" Command="{Binding DataContext.LoadApiKeyCommand, ElementName=popup}"/>
-            </Grid>
-            <Grid Visibility="{Binding DataContext.LoggedIn, ElementName=popup, Converter={BoolToVisibilityConverter}}" Margin="0,5,0,0" Height="25">
-                <Grid.ColumnDefinitions>
-                    <ColumnDefinition Width="120"/>
-                    <ColumnDefinition/>
-                    <ColumnDefinition Width="120"/>
-                </Grid.ColumnDefinitions>
-                <TextBlock views:Translator.Key="LANGUAGE" Margin="0,0,5,0"/>
-                <ComboBox Grid.Column="1" ItemsSource="{Binding DataContext.LanguageCodes, ElementName=popup}" SelectedItem="{Binding DataContext.SelectedLanguage, ElementName=popup}">
-                    <ComboBox.ItemContainerStyle>
-                        <Style TargetType="ComboBoxItem">
-                            <Setter Property="ContentTemplate">
-                                <Setter.Value>
-                                    <DataTemplate>
-                                        <StackPanel Orientation="Horizontal">
-                                            <Ellipse Width="10" Height="10" 
-                                                     VerticalAlignment="Center" 
-                                                     Fill="{Binding Comparison}"
-                                                     Margin="0,0,5,0"/>
-                                            <TextBlock Text="{Binding}"/>
-                                        </StackPanel>
-                                    </DataTemplate>
-                                </Setter.Value>
-                            </Setter>
-                        </Style>
-                    </ComboBox.ItemContainerStyle>
-                </ComboBox>
-                <Button Grid.Column="2" Style="{StaticResource DarkRoundButton}" views:Translator.Key="SYNC" Command="{Binding DataContext.SyncLanguageCommand, ElementName=popup}" Margin="5,0,0,0"/>
-            </Grid>
-            <TextBlock Text="{Binding DataContext.StatusMessage, ElementName=popup}"/>
-        </StackPanel>
-    </Grid>
-</Window>

+ 0 - 336
src/PixiEditor/Views/Dialogs/LocalizationDebugWindow.xaml.cs

@@ -1,336 +0,0 @@
-using System.Collections.ObjectModel;
-using System.Globalization;
-using System.Net;
-using System.Net.Http;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Media;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using PixiEditor.Helpers;
-using PixiEditor.Localization;
-using PixiEditor.Models.UserPreferences;
-
-namespace PixiEditor.Views.Dialogs;
-
-public partial class LocalizationDebugWindow : Window
-{
-    private LocalizationDataContext dataContext;
-
-    public LocalizationDebugWindow()
-    {
-        InitializeComponent();
-        DataContext = dataContext = new LocalizationDataContext(this);
-    }
-
-    private void ApiKeyChanged(object sender, TextChangedEventArgs e)
-    {
-        dataContext.LoggedIn = false;
-        dataContext.StatusMessage = "NOT_LOGGED_IN";
-    }
-
-    private void CommandBinding_Executed_Close(object sender, ExecutedRoutedEventArgs e)
-    {
-        SystemCommands.CloseWindow(this);
-    }
-
-    private void CommandBinding_CanExecute(object sender, CanExecuteRoutedEventArgs e)
-    {
-        e.CanExecute = true;
-    }
-
-    private class LocalizationDataContext : NotifyableObject
-    {
-        private const int ProjectId = 400351;
-
-        private readonly LocalizationDebugWindow window;
-        private string apiKey;
-        private bool loggedIn;
-        private LocalizedString statusMessage = "NOT_LOGGED_IN";
-        private PoeLanguage selectedLanguage;
-
-        public DebugViewModel DebugViewModel { get; } = ViewModelMain.Current.DebugSubViewModel;
-
-        public string ApiKey
-        {
-            get => apiKey;
-            set
-            {
-                if (SetProperty(ref apiKey, value))
-                {
-                    PreferencesSettings.Current.UpdateLocalPreference("POEditor_API_Key", apiKey);
-                }
-            }
-        }
-
-        public bool LoggedIn
-        {
-            get => loggedIn;
-            set => SetProperty(ref loggedIn, value);
-        }
-
-        public LocalizedString StatusMessage
-        {
-            get => statusMessage;
-            set => SetProperty(ref statusMessage, value);
-        }
-
-        public PoeLanguage SelectedLanguage
-        {
-            get => selectedLanguage;
-            set => SetProperty(ref selectedLanguage, value);
-        }
-
-        public ObservableCollection<PoeLanguage> LanguageCodes { get; } = new();
-
-        public RelayCommand LoadApiKeyCommand { get; }
-
-        public RelayCommand SyncLanguageCommand { get; }
-
-        public LocalizationDataContext(LocalizationDebugWindow window)
-        {
-            this.window = window;
-            apiKey = PreferencesSettings.Current.GetLocalPreference<string>("POEditor_API_Key");
-            LoadApiKeyCommand = new RelayCommand(LoadApiKey, _ => !string.IsNullOrWhiteSpace(apiKey));
-            SyncLanguageCommand =
-                new RelayCommand(SyncLanguage, _ => loggedIn && SelectedLanguage != null);
-        }
-
-        private void LoadApiKey(object parameter)
-        {
-            LanguageCodes.Clear();
-            Mouse.OverrideCursor = Cursors.Wait;
-
-            Task.Run(async () =>
-            {
-                try
-                {
-                    var result = await CheckProjectByIdAsync(ApiKey);
-
-                    window.Dispatcher.Invoke(() =>
-                    {
-                        LoggedIn = result.IsSuccess;
-                        StatusMessage = result.Message;
-
-                        if (!result.IsSuccess)
-                        {
-                            return;
-                        }
-
-                        foreach (var language in result.Output
-                                     .OrderByDescending(x => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == x.Code || CultureInfo.InstalledUICulture.TwoLetterISOLanguageName == x.Code)
-                                     .ThenByDescending(x => x.UpdateSortable))
-                        {
-                            language.LocalEquivalent = ILocalizationProvider.Current.LocalizationData.Languages
-                                .OrderByDescending(x => language.Code == x.Code)
-                                .FirstOrDefault(x => language.Code.StartsWith(x.Code));
-                            
-                            LanguageCodes.Add(language);
-                        }
-
-                        SelectedLanguage = LanguageCodes.FirstOrDefault();
-                    });
-                }
-                catch (Exception e)
-                {
-                    LoggedIn = false;
-                    StatusMessage = new LocalizedString("EXCEPTION_ERROR", e.Message);
-                }
-                finally
-                {
-                    window.Dispatcher.Invoke(() => Mouse.OverrideCursor = null);
-                }
-            });
-        }
-
-        private void SyncLanguage(object parameter)
-        {
-            Mouse.OverrideCursor = Cursors.Wait;
-
-            Task.Run(async () =>
-            {
-                try
-                {
-                    var result = await DownloadLanguage(ApiKey, SelectedLanguage.Code);
-
-                    window.Dispatcher.Invoke(() =>
-                    {
-                        StatusMessage = result.Message;
-                        DebugViewModel.Owner.LocalizationProvider.LoadDebugKeys(result.Output, SelectedLanguage.IsRightToLeft);
-                    });
-                }
-                catch (Exception e)
-                {
-                    StatusMessage = new LocalizedString("EXCEPTION_ERROR", e.Message);
-                }
-                finally
-                {
-                    window.Dispatcher.Invoke(() => Mouse.OverrideCursor = null);
-                }
-            });
-        }
-
-        private static async Task<Result<PoeLanguage[]>>
-            CheckProjectByIdAsync(string key)
-        {
-            using HttpClient client = new HttpClient();
-
-            // --- Check if user is part of project ---
-            var response = await PostAsync(client, "https://api.poeditor.com/v2/projects/list", key);
-            var result = await ParseResponseAsync(response);
-
-            if (!result.IsSuccess)
-            {
-                return result.As<PoeLanguage[]>();
-            }
-
-            var projects = (JArray)result.Output["result"]["projects"];
-
-            // Check if user is part of project
-            if (!projects.Any(x => x["id"].Value<int>() == ProjectId))
-            {
-                return Error("LOGGED_IN_NO_PROJECT_ACCESS");
-            }
-
-            response = await PostAsync(client, "https://api.poeditor.com/v2/languages/list", key, ("id", ProjectId.ToString()));
-            result = await ParseResponseAsync(response);
-
-            if (!result.IsSuccess)
-            {
-                return result.As<PoeLanguage[]>();
-            }
-
-            var languages = result.Output["result"]["languages"].ToObject<PoeLanguage[]>();
-
-            return Result.Success(new LocalizedString("LOGGED_IN"), languages);
-
-            Result<PoeLanguage[]> Error(LocalizedString message) => Result.Error<PoeLanguage[]>(message);
-        }
-
-        private static async Task<Result<Dictionary<string, string>>> DownloadLanguage(
-            string key,
-            string language)
-        {
-            using var client = new HttpClient();
-
-            // Get Url to key_value_json in language
-            var response = await PostAsync(
-                client,
-                "https://api.poeditor.com/v2/projects/export",
-                key,
-                ("id", ProjectId.ToString()), ("type", "key_value_json"), ("language", language));
-
-            var result = await ParseResponseAsync(response);
-
-            if (!result.IsSuccess)
-            {
-                return result.As<Dictionary<string, string>>();
-            }
-
-            response = await client.GetAsync(result.Output["result"]["url"].Value<string>());
-
-            // Failed with an HTTP error code, according to API docs this should not be possible
-            if (!response.IsSuccessStatusCode)
-            {
-                return Error(new LocalizedString("HTTP_ERROR_MESSAGE", (int)response.StatusCode, response.StatusCode));
-            }
-
-            string responseJson = await response.Content.ReadAsStringAsync();
-            var keys = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseJson);
-
-            return Result.Success("SYNCED_SUCCESSFULLY", keys);
-
-            Result<Dictionary<string, string>> Error(LocalizedString message) => Result.Error<Dictionary<string, string>>(message);
-        }
-
-        private static async Task<Result<JObject>> ParseResponseAsync(HttpResponseMessage response)
-        {
-            // Failed with an HTTP error code, according to API docs this should not be possible
-            if (!response.IsSuccessStatusCode)
-            {
-                return Error("HTTP_ERROR_MESSAGE", (int)response.StatusCode, response.StatusCode);
-            }
-
-            string jsonResponse = await response.Content.ReadAsStringAsync();
-            var root = JObject.Parse(jsonResponse);
-
-            var rsp = root["response"];
-            string rspCode = rsp["code"].Value<string>();
-
-            // Failed with an error code from the POEditor API, alongside a message
-            if (rspCode != "200")
-            {
-                return Error("POE_EDITOR_ERROR", rspCode, rsp["message"].Value<string>());
-            }
-
-            return Result.Success(root);
-            
-            Result<JObject> Error(string key, params object[] param) => Result.Error<JObject>(new LocalizedString(key, param));
-        }
-
-        private static Task<HttpResponseMessage> PostAsync(HttpClient client, string requestUri, string apiKey,
-            params (string key, string value)[] body)
-        {
-            var bodyKeys = new List<KeyValuePair<string, string>>(
-                body.Select(x => new KeyValuePair<string, string>(x.key, x.value))) { new("api_token", apiKey) };
-
-            return client.PostAsync(requestUri, new FormUrlEncodedContent(bodyKeys));
-        }
-
-        private struct Result
-        {
-            public static Result<T> Error<T>(LocalizedString message) => new(false, message, default);
-
-            public static Result<T> Success<T>(LocalizedString message, T output) => new(true, message, output);
-            
-            public static Result<T> Success<T>(T output) => new(true, null, output);
-        }
-
-        private record struct Result<T>(bool IsSuccess, LocalizedString Message, T Output)
-        {
-            public Result<TOther> As<TOther>()
-            {
-                if (IsSuccess)
-                {
-                    throw new ArgumentException("Result can't be a success");
-                }
-
-                return new Result<TOther>(false, Message, default);
-            }
-        }
-
-        public class PoeLanguage
-        {
-            private static readonly SolidColorBrush LocalOlder = new(Colors.Red);
-            private static readonly SolidColorBrush LocalMin = new(Colors.Orange);
-            private static readonly SolidColorBrush Equal = new(Colors.Lime);
-            private static readonly SolidColorBrush LocalNewer = new(Colors.DodgerBlue);
-            private static readonly SolidColorBrush LocalMissing = new(Colors.Gray);
-            
-            public string Name { get; set; }
-
-            public string Code { get; set; }
-
-            public string Updated { get; set; }
-
-            public DateTimeOffset UpdateSortable => string.IsNullOrWhiteSpace(Updated) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(Updated, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
-
-            public bool IsRightToLeft => Code is "ar" or "he" or "ku" or "fa" or "ur";
-
-            public LanguageData LocalEquivalent { get; set; }
-
-            public SolidColorBrush Comparison => (l: LocalEquivalent?.LastUpdated, r: UpdateSortable) switch
-            {
-                (null, _) => LocalMissing,
-                { l.Ticks: 0 } => LocalMin,
-                { l: var l, r: var r } when r > l.Value => LocalOlder,
-                { l: var l, r: var r } when r == l.Value => Equal,
-                { l: var l, r: var r } when r < l.Value => LocalNewer,
-                _ => throw new ArgumentOutOfRangeException()
-            };
-            
-            public override string ToString() => $"{Name} ({Code}) {UpdateSortable.ToString(CultureInfo.InvariantCulture)}";
-        }
-    }
-}

+ 15 - 13
src/PixiEditor/Views/Dialogs/SettingsWindow.xaml

@@ -73,18 +73,6 @@
                     <ComboBox.ItemTemplate>
                         <DataTemplate>
                             <StackPanel Orientation="Horizontal">
-                                <StackPanel.Style>
-                                    <Style TargetType="{x:Type StackPanel}">
-                                        <Style.Triggers>
-                                            <Trigger Property="IsMouseOver" Value="True">
-                                                <Setter Property="Background" Value="{StaticResource AccentColor}"/>
-                                            </Trigger>
-                                            <Trigger Property="IsMouseOver" Value="False">
-                                                <Setter Property="Background" Value="Transparent"/>
-                                            </Trigger>
-                                        </Style.Triggers>
-                                    </Style>
-                                </StackPanel.Style>
                                 <Image VerticalAlignment="Center" Margin="5 0" Source="{Binding IconPath}"/>
                                 <TextBlock VerticalAlignment="Center" Text="{Binding Name}"/>
                             </StackPanel>
@@ -95,8 +83,22 @@
                             <Setter Property="Template">
                                 <Setter.Value>
                                     <ControlTemplate TargetType="{x:Type ComboBoxItem}">
-                                        <Border Height="25" Margin="5 0" Background="Transparent" BorderBrush="Transparent" BorderThickness="1">
+                                        <Border Height="25" Margin="0" Padding="5,0" BorderThickness="0,1">
                                             <ContentPresenter/>
+                                            <Border.Style>
+                                                <Style TargetType="{x:Type Border}">
+                                                    <Style.Triggers>
+                                                        <Trigger Property="IsMouseOver" Value="False">
+                                                            <Setter Property="Background" Value="Transparent"/>
+                                                            <Setter Property="BorderBrush" Value="Transparent"/>
+                                                        </Trigger>
+                                                        <Trigger Property="IsMouseOver" Value="True">
+                                                            <Setter Property="Background" Value="{StaticResource MainColor}"/>
+                                                            <Setter Property="BorderBrush" Value="{StaticResource AlmostLightModeAccentColor}"/>
+                                                        </Trigger>
+                                                    </Style.Triggers>
+                                                </Style>
+                                            </Border.Style>
                                         </Border>
                                     </ControlTemplate>
                                 </Setter.Value>

+ 4 - 2
src/PixiEditor/Views/UserControls/Viewport.xaml

@@ -159,7 +159,8 @@
                             Source="{Binding Document.ReferenceLayerViewModel.ReferenceBitmap, Mode=OneWay}"
                             Visibility="{Binding Document.ReferenceLayerViewModel.IsVisibleBindable, Converter={converters:BoolToHiddenVisibilityConverter}}"
                             SizeChanged="OnReferenceImageSizeChanged"
-                            RenderOptions.BitmapScalingMode="{Binding ReferenceLayerScale, Converter={converters:ScaleToBitmapScalingModeConverter}}">
+                            RenderOptions.BitmapScalingMode="{Binding ReferenceLayerScale, Converter={converters:ScaleToBitmapScalingModeConverter}}"
+                            FlowDirection="LeftToRight">
                             <Image.RenderTransform>
                                 <TransformGroup>
                                     <MatrixTransform
@@ -225,7 +226,8 @@
                         Width="{Binding Document.Width}"
                         Height="{Binding Document.Height}"
                         Source="{Binding TargetBitmap}"
-                        RenderOptions.BitmapScalingMode="{Binding Zoombox.Scale, Converter={converters:ScaleToBitmapScalingModeConverter}}">
+                        RenderOptions.BitmapScalingMode="{Binding Zoombox.Scale, Converter={converters:ScaleToBitmapScalingModeConverter}}"
+                        FlowDirection="LeftToRight">
                         <Image.Style>
                             <Style>
                                 <Style.Triggers>