Browse Source

Debug -> Debugging folder (not git ignored)

Krzysztof Krysiński 1 year ago
parent
commit
14a7fee1c7

+ 1 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/DebugViewModel.cs

@@ -13,7 +13,7 @@ using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
 using PixiEditor.AvaloniaUI.Models.Commands.Templates.Providers.Parsers;
 using PixiEditor.AvaloniaUI.Models.Dialogs;
 using PixiEditor.AvaloniaUI.Views;
-using PixiEditor.AvaloniaUI.Views.Dialogs.Debug.Localization;
+using PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.Localization;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.Common.UserPreferences;
 using PixiEditor.OperatingSystem;

+ 439 - 0
src/PixiEditor.AvaloniaUI/Views/Dialogs/Debugging/Localization/LocalizationDataContext.cs

@@ -0,0 +1,439 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using PixiEditor.AvaloniaUI.Helpers.Extensions;
+using PixiEditor.AvaloniaUI.Models.Dialogs;
+using PixiEditor.AvaloniaUI.ViewModels;
+using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.Localization;
+
+internal class LocalizationDataContext : PixiObservableObject
+{
+    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))
+            {
+                IPreferences.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 AsyncRelayCommand CopySelectedUpdatedCommand { get; }
+    
+    public RelayCommand UpdateSourceCommand { get; }
+
+    public LocalizationDataContext()
+    {
+        dispatcher = Dispatcher.UIThread;
+        apiKey = IPreferences.Current.GetLocalPreference<string>("POEditor_API_Key");
+        LoadApiKeyCommand = new RelayCommand(LoadApiKey, () => !string.IsNullOrWhiteSpace(apiKey));
+        ApplyLanguageCommand =
+            new RelayCommand(ApplyLanguage, () => loggedIn && SelectedLanguage != null);
+        CopySelectedUpdatedCommand = new AsyncRelayCommand(CopySelectedAsync);
+        UpdateSourceCommand = new RelayCommand(UpdateSource);
+    }
+
+    private async Task CopySelectedAsync()
+    {
+        await Application.Current.ForDesktopMainWindowAsync(async x =>
+            await x.Clipboard.SetTextAsync(
+                SelectedLanguage.UpdatedUTC.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)));
+    }
+
+    private void LoadApiKey()
+    {
+        LanguageCodes.Clear();
+
+        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);
+            }
+        });
+    }
+
+    private void ApplyLanguage()
+    {
+        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);
+            }
+        });
+    }
+
+    private void UpdateSource()
+    {
+        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(() =>
+        {
+            Application.Current.ForDesktopMainWindow(x => x.Clipboard.SetTextAsync(language.UpdatedUTC.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)));
+            
+            var dialog = new OptionsDialog<string>("SUCCESS", new LocalizedString("OPEN_LOCALIZATION_DATA"), MainWindow.Current);
+            dialog["VS Code"] = _ => IOperatingSystem.Current.ProcessUtility.ShellExecute($"vscode://file/{dataPath}");
+            dialog[new LocalizedString("DEFAULT")] = _ => IOperatingSystem.Current.ProcessUtility.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);
+        }
+    }
+}

+ 157 - 0
src/PixiEditor.AvaloniaUI/Views/Dialogs/Debugging/Localization/LocalizationDebugWindow.axaml

@@ -0,0 +1,157 @@
+<dialogs:PixiEditorPopup xmlns="https://github.com/avaloniaui"
+                         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+                         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+                         xmlns:dialogs="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs"
+                         xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+                         xmlns:localization="clr-namespace:PixiEditor.Extensions.Common.Localization;assembly=PixiEditor.Extensions"
+                         xmlns:markupExtensions="clr-namespace:PixiEditor.AvaloniaUI.Helpers.MarkupExtensions"
+                         xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML"
+                         xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+                         xmlns:main="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.SubViewModels"
+                         xmlns:debug="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs.Debug"
+                         xmlns:ui1="clr-namespace:PixiEditor.AvaloniaUI.Helpers.UI"
+                         xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
+                         xmlns:localization1="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.Localization"
+                         mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                         Width="600"
+                         Height="400"
+                         x:Class="PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.Localization.LocalizationDebugWindow"
+                         Title="LOCALIZATION_DEBUG_WINDOW_TITLE">
+    <StackPanel Grid.Row="1" Margin="5">
+        <StackPanel Orientation="Horizontal" Height="25">
+            <TextBlock ui:Translator.Key="LOCALIZATION_VIEW_TYPE" Margin="0,0,5,0" MinWidth="160" />
+            <ComboBox
+                SelectedItem="{Binding DebugViewModel.LocalizationKeyShowMode}"
+                ItemsSource="{markupExtensions:Enum {x:Type localization:LocalizationKeyShowMode}}" />
+        </StackPanel>
+        <StackPanel Orientation="Horizontal" Height="25" Margin="0,5,0,0">
+            <TextBlock ui:Translator.Key="FORCE_OTHER_FLOW_DIRECTION" Margin="0,0,5,0" MinWidth="160" />
+            <CheckBox IsChecked="{Binding DebugViewModel.ForceOtherFlowDirection}" />
+        </StackPanel>
+        <Button ui:Translator.Key="LOAD_LANGUAGE_FROM_FILE"
+                Command="{xaml:Command PixiEditor.Debug.SetLanguageFromFilePicker}"
+                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 ui:Translator.Key="API_KEY" Margin="0,0,5,0"></TextBlock>
+            <TextBox Grid.Column="1" TextChanged="ApiKeyChanged"
+                     Text="{Binding ApiKey}">
+            </TextBox>
+            <Button Margin="5,0,0,0" Grid.Column="2"
+                    ui:Translator.Key="LOG_IN"
+                    Command="{Binding LoadApiKeyCommand}" />
+        </Grid>
+        <StackPanel
+            IsVisible="{Binding LoggedIn, Mode=OneWay}">
+            <Grid Margin="0,5,0,0" Height="25">
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="120" />
+                    <ColumnDefinition />
+                </Grid.ColumnDefinitions>
+                <TextBlock ui:Translator.Key="LANGUAGE" Margin="0,0,5,0" />
+                <Grid Grid.Column="1">
+                    <ComboBox ItemsSource="{Binding LanguageCodes}"
+                              SelectedItem="{Binding SelectedLanguage}"
+                              x:Name="LanguageComboBox">
+                        <ComboBox.ItemContainerTheme>
+                            <ControlTheme TargetType="ComboBoxItem">
+                                <Setter Property="ContentTemplate">
+                                    <Setter.Value>
+                                        <DataTemplate DataType="localization1: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 Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}"/>
+                                            </Border>
+                                        </ControlTemplate>
+                                    </Setter.Value>
+                                </Setter>
+                                <Style Selector="^ComboBoxItem /template/ Border">
+                                    <Setter Property="Background" Value="Transparent" />
+                                    <Setter Property="BorderBrush" Value="Transparent" />
+                                </Style>
+                                <Style Selector="^ComboBoxItem:pointerover /template/ Border">
+                                    <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
+                                    <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderHighBrush}" />
+                                </Style>
+                            </ControlTheme>
+                        </ComboBox.ItemContainerTheme>
+                    </ComboBox>
+                    <TextBlock ui:Translator.Key="SELECT_A_LANGUAGE"
+                               IsVisible="{Binding SelectedItem, ElementName=LanguageComboBox, Converter={converters:NullToVisibilityConverter}}"
+                               Margin="5,0,0,0" VerticalAlignment="Center"
+                               IsHitTestVisible="False">
+                    </TextBlock>
+                </Grid>
+            </Grid>
+            <Border Padding="5"
+                    BorderThickness="1" CornerRadius="5"
+                    Margin="0,5,0,5"
+                    IsVisible="{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 ui: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"
+                                   ui:Translator.LocalizedString="{Binding SelectedLanguage.StatusText}" Margin="5,0" />
+                        <TextBlock ui1:Hyperlink.Command="{Binding CopySelectedUpdatedCommand}" Grid.Column="2" ui:Translator.TooltipKey="COPY_TO_CLIPBOARD">
+                            <Run
+                                Text="{Binding SelectedLanguage.UpdatedLocal, Mode=OneWay, StringFormat='g'}" />
+                            <Run Text=" &#xe855;" FontFamily="{DynamicResource Feather}" />
+                        </TextBlock>
+                    </Grid>
+                    <Grid>
+                        <Grid.ColumnDefinitions>
+                            <ColumnDefinition />
+                            <ColumnDefinition
+                                Width="{Binding Source={x:Static main:DebugViewModel.IsDebugBuild}, Converter={converters:BoolToValueConverter FalseValue=Auto, TrueValue=*}}" />
+                        </Grid.ColumnDefinitions>
+                        <Button ui:Translator.Key="APPLY" Command="{Binding ApplyLanguageCommand}" />
+                        <Button Grid.Column="1" Margin="5,0,0,0" ui:Translator.Key="UPDATE_SOURCE"
+                                Command="{Binding UpdateSourceCommand}"
+                                IsVisible="{Binding Source={x:Static main:DebugViewModel.IsDebugBuild}}" />
+                    </Grid>
+                </StackPanel>
+            </Border>
+        </StackPanel>
+        <TextBlock Text="{Binding StatusMessage}" />
+    </StackPanel>
+</dialogs:PixiEditorPopup>

+ 28 - 0
src/PixiEditor.AvaloniaUI/Views/Dialogs/Debugging/Localization/LocalizationDebugWindow.axaml.cs

@@ -0,0 +1,28 @@
+using Avalonia.Controls;
+
+namespace PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.Localization;
+
+public partial class LocalizationDebugWindow : PixiEditorPopup
+{
+    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"; // TODO: For some reason it was NOT_LOGGED_IN
+    }
+}
+

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

@@ -0,0 +1,74 @@
+using System.Globalization;
+using Avalonia.Media;
+using Newtonsoft.Json;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.Views.Dialogs.Debugging.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
+        }
+    }