diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5de8ffc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore Localizer.slnx + + - name: Build + run: dotnet build Localizer.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test Localizer.slnx --no-build --configuration Release --verbosity normal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9e1fca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Timo Kuronen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs b/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs new file mode 100644 index 0000000..f2e722d --- /dev/null +++ b/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs @@ -0,0 +1,119 @@ +using CommunityToolkit.Mvvm.Input; + +namespace Localizer.Desktop.ViewModels; + +public partial class MainViewModel +{ + [RelayCommand] + private async Task NewCatalogAsync() + { + var request = await _dialogs.PromptCreateCatalogAsync().ConfigureAwait(true); + if (request is null) + { + return; + } + + var result = _createCatalog.Execute(request); + if (!result.Succeeded || result.Value is null) + { + await _dialogs.ShowMessageAsync("Create catalog failed", FormatError(result)).ConfigureAwait(true); + return; + } + + SetCatalog(result.Value, path: null, dirty: true); + await LoadProjectFolderBindingAsync(result.Value.CatalogId.Value).ConfigureAwait(true); + StatusText = "Created new catalog."; + } + + [RelayCommand] + private async Task OpenCatalogAsync(CancellationToken cancellationToken) + { + var path = await _dialogs.PickOpenCatalogPathAsync(cancellationToken).ConfigureAwait(true); + if (path is null) + { + return; + } + + await RunIoAsync(async token => + { + var result = await _openCatalog.ExecuteAsync(path, token).ConfigureAwait(true); + if (!result.Succeeded || result.Value is null) + { + await _dialogs.ShowMessageAsync("Open catalog failed", FormatError(result)).ConfigureAwait(true); + return; + } + + SetCatalog(result.Value, path, dirty: false); + await LoadProjectFolderBindingAsync(result.Value.CatalogId.Value).ConfigureAwait(true); + StatusText = $"Opened {path}"; + }, cancellationToken).ConfigureAwait(true); + } + + [RelayCommand] + private async Task SaveCatalogAsync(CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + var path = _catalogPath; + if (string.IsNullOrWhiteSpace(path)) + { + path = await _dialogs.PickSaveCatalogPathAsync( + suggestedFileName: $"{_catalog.CatalogId.Value}.json", + cancellationToken).ConfigureAwait(true); + if (path is null) + { + return; + } + } + + await SaveToPathAsync(path, cancellationToken).ConfigureAwait(true); + } + + [RelayCommand] + private async Task SaveCatalogAsAsync(CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + var path = await _dialogs.PickSaveCatalogPathAsync( + suggestedFileName: $"{_catalog.CatalogId.Value}.json", + cancellationToken).ConfigureAwait(true); + if (path is null) + { + return; + } + + await SaveToPathAsync(path, cancellationToken).ConfigureAwait(true); + } + + [RelayCommand] + private void CancelIo() => _ioCts?.Cancel(); + + private async Task SaveToPathAsync(string path, CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + await RunIoAsync(async token => + { + var result = await _saveCatalog.ExecuteAsync(path, _catalog, token).ConfigureAwait(true); + if (!result.Succeeded) + { + await _dialogs.ShowMessageAsync("Save catalog failed", FormatError(result)).ConfigureAwait(true); + return; + } + + _catalogPath = path; + IsDirty = false; + UpdateWindowTitle(); + StatusText = $"Saved {path}"; + }, cancellationToken).ConfigureAwait(true); + } +} diff --git a/Localizer.Desktop/ViewModels/MainViewModel.Drafting.cs b/Localizer.Desktop/ViewModels/MainViewModel.Drafting.cs new file mode 100644 index 0000000..d5816a0 --- /dev/null +++ b/Localizer.Desktop/ViewModels/MainViewModel.Drafting.cs @@ -0,0 +1,149 @@ +using CommunityToolkit.Mvvm.Input; +using Localizer.Application.UseCases; + +namespace Localizer.Desktop.ViewModels; + +public partial class MainViewModel +{ + [RelayCommand] + private async Task DraftMissingAndStaleAsync(CancellationToken cancellationToken) => + await DraftAsync( + WorkQueueFilter.Missing | WorkQueueFilter.Stale, + "Requesting local model drafts for Missing and Stale items", + cancellationToken).ConfigureAwait(true); + + [RelayCommand] + private async Task DraftAllAsync(CancellationToken cancellationToken) => + await DraftAsync( + WorkQueueFilter.AllStatuses, + "Requesting local model drafts for all locales", + cancellationToken).ConfigureAwait(true); + + private async Task DraftAsync( + WorkQueueFilter filter, + string busyStatus, + CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + var selectedKey = SelectedEntry?.Key; + + BeginBusyStatus(busyStatus); + await RunIoAsync(async token => + { + var result = await _requestTranslationDrafts + .ExecuteAsync(_catalog, new RequestTranslationDraftsRequest { Filter = filter }, token) + .ConfigureAwait(true); + + if (!result.Succeeded) + { + await _dialogs.ShowMessageAsync("Draft failed", FormatError(result)).ConfigureAwait(true); + StatusText = "Drafting failed."; + return; + } + + var outcome = result.Value!; + IsDirty = outcome.AppliedCount > 0 || IsDirty; + RefreshFromCatalog(selectKey: selectedKey); + RunValidation(); + + StatusText = outcome.RequestedCount == 0 + ? "No matching items to draft." + : $"Drafted {outcome.AppliedCount} of {outcome.RequestedCount} item(s)."; + + if (outcome.RejectionMessages.Count > 0) + { + var detail = string.Join(Environment.NewLine, outcome.RejectionMessages.Take(12)); + if (outcome.RejectionMessages.Count > 12) + { + detail += $"{Environment.NewLine}...and {outcome.RejectionMessages.Count - 12} more."; + } + + await _dialogs.ShowMessageAsync( + "Drafting finished with rejections", + detail).ConfigureAwait(true); + } + }, cancellationToken).ConfigureAwait(true); + } + + private async Task ApproveTranslationRowAsync(TranslationEditViewModel row) + { + if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) + { + return; + } + + var key = EditorKey; + + if (row.HasTextChanged) + { + if (string.IsNullOrEmpty(row.Text)) + { + await _dialogs.ShowMessageAsync( + "Approve failed", + "Translation text is empty. Enter text before approving.").ConfigureAwait(true); + return; + } + + var draftResult = _setTranslationDraft.Execute(_catalog, new SetTranslationDraftRequest + { + Key = key, + Locale = row.Locale, + Text = row.Text + }); + + if (!draftResult.Succeeded) + { + await _dialogs.ShowMessageAsync("Approve failed", FormatError(draftResult)).ConfigureAwait(true); + return; + } + } + + var approveResult = _approveTranslation.Execute(_catalog, new ApproveTranslationRequest + { + Key = key, + Locale = row.Locale + }); + + if (!approveResult.Succeeded) + { + await _dialogs.ShowMessageAsync("Approve failed", FormatError(approveResult)).ConfigureAwait(true); + RunValidation(); + return; + } + + IsDirty = true; + RefreshFromCatalog(selectKey: key); + RunValidation(); + StatusText = $"Approved '{key}' for {row.Locale}."; + } + + private async Task InvalidateTranslationRowAsync(TranslationEditViewModel row) + { + if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) + { + return; + } + + var key = EditorKey; + var result = _invalidateTranslation.Execute(_catalog, new InvalidateTranslationRequest + { + Key = key, + Locale = row.Locale + }); + + if (!result.Succeeded) + { + await _dialogs.ShowMessageAsync("Invalidate failed", FormatError(result)).ConfigureAwait(true); + return; + } + + IsDirty = true; + RefreshFromCatalog(selectKey: key); + RunValidation(); + StatusText = $"Marked '{key}' ({row.Locale}) as Stale for re-draft."; + } +} diff --git a/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs b/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs new file mode 100644 index 0000000..dd2f71d --- /dev/null +++ b/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs @@ -0,0 +1,246 @@ +using CommunityToolkit.Mvvm.Input; +using Localizer.Application.UseCases; +using Localizer.Core.Identity; + +namespace Localizer.Desktop.ViewModels; + +public partial class MainViewModel +{ + partial void OnSelectedEntryChanged(EntryRowViewModel? value) => + _ = HandleSelectedEntryChangedAsync(value); + + private async Task HandleSelectedEntryChangedAsync(EntryRowViewModel? value) + { + if (_suppressSelectionLoad) + { + return; + } + + if (CanEditEntry && HasPendingEditorChanges()) + { + var previousKey = EditorKey; + var applied = await TryApplyPendingEditorChangesAsync(refreshUi: false).ConfigureAwait(true); + if (!applied) + { + _suppressSelectionLoad = true; + SelectedEntry = Entries.FirstOrDefault(entry => entry.Key == previousKey); + _suppressSelectionLoad = false; + return; + } + + IsDirty = true; + StatusText = $"Saved pending edits for '{previousKey}'."; + } + + LoadSelectedEntry(value); + } + + [RelayCommand] + private async Task AddEntryAsync() + { + if (_catalog is null) + { + return; + } + + var request = await _dialogs.PromptAddEntryAsync().ConfigureAwait(true); + if (request is null) + { + return; + } + + var result = _addEntry.Execute(_catalog, request); + if (!result.Succeeded) + { + await _dialogs.ShowMessageAsync("Add entry failed", FormatError(result)).ConfigureAwait(true); + return; + } + + IsDirty = true; + RefreshFromCatalog(selectKey: request.Key); + StatusText = $"Added entry '{request.Key}'."; + } + + [RelayCommand] + private async Task RemoveEntryAsync() + { + if (_catalog is null || SelectedEntry is null) + { + return; + } + + var key = SelectedEntry.Key; + var result = _removeEntry.Execute(_catalog, key); + if (!result.Succeeded) + { + await _dialogs.ShowMessageAsync("Remove entry failed", FormatError(result)).ConfigureAwait(true); + return; + } + + IsDirty = true; + RefreshFromCatalog(selectKey: null); + StatusText = $"Removed entry '{key}'."; + } + + [RelayCommand] + private async Task ApplyEntryEditsAsync() + { + if (_catalog is null || !CanEditEntry || string.IsNullOrWhiteSpace(EditorKey)) + { + return; + } + + var key = EditorKey; + var applied = await TryApplyPendingEditorChangesAsync(refreshUi: true).ConfigureAwait(true); + if (!applied) + { + return; + } + + StatusText = $"Applied changes to '{key}'."; + } + + private bool HasPendingEditorChanges() + { + if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) + { + return false; + } + + if (!_catalog.Entries.TryGetValue(EntryKey.Create(EditorKey), out var existing)) + { + return false; + } + + if (!string.Equals(EditorSourceText, existing.SourceText, StringComparison.Ordinal)) + { + return true; + } + + var notes = string.IsNullOrWhiteSpace(EditorDeveloperNotes) ? null : EditorDeveloperNotes; + if (!string.Equals(notes, existing.DeveloperNotes, StringComparison.Ordinal)) + { + return true; + } + + return Translations.Any(translation => translation.HasTextChanged); + } + + private async Task TryApplyPendingEditorChangesAsync(bool refreshUi) + { + if (_catalog is null || !CanEditEntry || string.IsNullOrWhiteSpace(EditorKey)) + { + return false; + } + + var key = EditorKey; + if (!_catalog.Entries.TryGetValue(EntryKey.Create(key), out var existing)) + { + await _dialogs.ShowMessageAsync("Apply failed", $"No entry with key '{key}' exists.").ConfigureAwait(true); + return false; + } + + var updateResult = _updateEntry.Execute(_catalog, new UpdateCatalogEntryRequest + { + Key = key, + SourceText = EditorSourceText, + Category = existing.Category, + AllowEmptyText = existing.AllowEmptyText, + DeveloperNotes = string.IsNullOrWhiteSpace(EditorDeveloperNotes) + ? null + : EditorDeveloperNotes, + Context = existing.Context, + Constraints = existing.Constraints, + SyntaxProfileOverride = existing.SyntaxProfileOverride, + ExternalIds = existing.ExternalIds + }); + + if (!updateResult.Succeeded) + { + await _dialogs.ShowMessageAsync("Apply failed", FormatError(updateResult)).ConfigureAwait(true); + return false; + } + + foreach (var translation in Translations) + { + if (!translation.HasTextChanged) + { + continue; + } + + if (string.IsNullOrEmpty(translation.Text) && !existing.AllowEmptyText) + { + continue; + } + + var draftResult = _setTranslationDraft.Execute(_catalog, new SetTranslationDraftRequest + { + Key = key, + Locale = translation.Locale, + Text = translation.Text + }); + + if (!draftResult.Succeeded) + { + await _dialogs.ShowMessageAsync("Apply translation failed", FormatError(draftResult)).ConfigureAwait(true); + return false; + } + } + + IsDirty = true; + if (refreshUi) + { + RefreshFromCatalog(selectKey: key); + RunValidation(); + } + + return true; + } + + private void LoadSelectedEntry(EntryRowViewModel? row) + { + if (_suppressSelectionLoad) + { + return; + } + + if (_catalog is null || row is null) + { + ClearEditor(); + return; + } + + if (!_catalog.Entries.TryGetValue(EntryKey.Create(row.Key), out var entry)) + { + ClearEditor(); + return; + } + + EditorKey = entry.Key.Value; + EditorSourceText = entry.SourceText; + EditorDeveloperNotes = entry.DeveloperNotes ?? string.Empty; + CanEditEntry = true; + + Translations.Clear(); + foreach (var locale in _catalog.RequiredLocales.OrderBy(locale => locale.Value, StringComparer.Ordinal)) + { + entry.Translations.TryGetValue(locale, out var translation); + var status = _catalog.GetEffectiveStatus(entry, locale); + Translations.Add(new TranslationEditViewModel( + locale.Value, + translation?.Text ?? string.Empty, + status, + ApproveTranslationRowAsync, + InvalidateTranslationRowAsync)); + } + } + + private void ClearEditor() + { + EditorKey = string.Empty; + EditorSourceText = string.Empty; + EditorDeveloperNotes = string.Empty; + CanEditEntry = false; + Translations.Clear(); + } +} diff --git a/Localizer.Desktop/ViewModels/MainViewModel.Export.cs b/Localizer.Desktop/ViewModels/MainViewModel.Export.cs new file mode 100644 index 0000000..f708b9b --- /dev/null +++ b/Localizer.Desktop/ViewModels/MainViewModel.Export.cs @@ -0,0 +1,90 @@ +using CommunityToolkit.Mvvm.Input; + +namespace Localizer.Desktop.ViewModels; + +public partial class MainViewModel +{ + [RelayCommand] + private void ValidateCatalog() + { + if (_catalog is null) + { + return; + } + + RunValidation(); + StatusText = Diagnostics.Count == 0 + ? "Validation found no diagnostics." + : $"Validation reported {Diagnostics.Count} diagnostic(s)."; + } + + [RelayCommand] + private async Task ExportCatalogAsync(CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + var directory = await _dialogs.PickExportDirectoryAsync(cancellationToken).ConfigureAwait(true); + if (directory is null) + { + return; + } + + await RunIoAsync(async token => + { + var outcome = await _exportCatalog.ExecuteAsync(_catalog, directory, token).ConfigureAwait(true); + if (!outcome.Succeeded) + { + Diagnostics.Clear(); + foreach (var diagnostic in outcome.Validation.Diagnostics) + { + Diagnostics.Add(new DiagnosticRowViewModel( + diagnostic.Severity.ToString(), + diagnostic.Code, + diagnostic.Message, + diagnostic.EntryKey?.Value, + diagnostic.Locale?.Value)); + } + + var message = string.IsNullOrWhiteSpace(outcome.ErrorCode) + ? outcome.ErrorMessage ?? "Export failed." + : $"{outcome.ErrorCode}: {outcome.ErrorMessage}"; + if (outcome.Validation.Diagnostics.Count > 0) + { + message += $"{Environment.NewLine}{Environment.NewLine}See diagnostics panel for {outcome.Validation.Diagnostics.Count} finding(s)."; + } + + await _dialogs.ShowMessageAsync("Export failed", message).ConfigureAwait(true); + StatusText = "Export blocked."; + return; + } + + StatusText = $"Exported {outcome.WrittenFiles.Count} locale file(s) to {directory}."; + await _dialogs.ShowMessageAsync( + "Export complete", + string.Join(Environment.NewLine, outcome.WrittenFiles)).ConfigureAwait(true); + }, cancellationToken).ConfigureAwait(true); + } + + private void RunValidation() + { + Diagnostics.Clear(); + if (_catalog is null) + { + return; + } + + var result = _validateCatalog.Execute(_catalog); + foreach (var diagnostic in result.Diagnostics) + { + Diagnostics.Add(new DiagnosticRowViewModel( + diagnostic.Severity.ToString(), + diagnostic.Code, + diagnostic.Message, + diagnostic.EntryKey?.Value, + diagnostic.Locale?.Value)); + } + } +} diff --git a/Localizer.Desktop/ViewModels/MainViewModel.ProjectBridge.cs b/Localizer.Desktop/ViewModels/MainViewModel.ProjectBridge.cs new file mode 100644 index 0000000..81c17fe --- /dev/null +++ b/Localizer.Desktop/ViewModels/MainViewModel.ProjectBridge.cs @@ -0,0 +1,112 @@ +using CommunityToolkit.Mvvm.Input; +using Localizer.Application.Project; +using Localizer.Application.UseCases; + +namespace Localizer.Desktop.ViewModels; + +public partial class MainViewModel +{ + [RelayCommand] + private async Task SetProjectFolderAsync(CancellationToken cancellationToken) + { + if (_catalog is null) + { + return; + } + + var folder = await _dialogs.PickExportDirectoryAsync(cancellationToken).ConfigureAwait(true); + if (folder is null) + { + return; + } + + var binding = await _dialogs + .PromptProjectFolderAsync(folder, _projectFolderBinding) + .ConfigureAwait(true); + if (binding is null) + { + return; + } + + binding = binding with { CatalogId = _catalog.CatalogId.Value }; + + await _projectFolderSettings.SaveAsync(binding, cancellationToken).ConfigureAwait(true); + ApplyProjectFolderBinding(binding); + StatusText = $"Project folder set to {folder}."; + } + + [RelayCommand] + private async Task ImportFromProjectAsync(CancellationToken cancellationToken) + { + if (_catalog is null || _projectFolderBinding is null || !_projectFolderBinding.IsConfigured) + { + return; + } + + var importPath = _projectFolderBinding.ImportFilePath; + if (!File.Exists(importPath)) + { + await _dialogs.ShowMessageAsync( + "Import failed", + $"{UseCaseErrorCodes.ProjectFileNotFound}: Expected import file was not found at '{importPath}'.").ConfigureAwait(true); + return; + } + + await RunIoAsync(async token => + { + var outcome = await _importUnityCsv.ExecuteAsync(_catalog, importPath, token).ConfigureAwait(true); + if (!outcome.Succeeded) + { + await _dialogs.ShowMessageAsync( + "Import failed", + FormatImportError(outcome)).ConfigureAwait(true); + return; + } + + IsDirty = true; + RefreshFromCatalog(selectKey: SelectedEntry?.Key); + RunValidation(); + StatusText = + $"Imported Unity CSV from {importPath}. Added {outcome.MergeOutcome.AddedCount}, updated {outcome.MergeOutcome.UpdatedCount}, unchanged {outcome.MergeOutcome.UnchangedCount}."; + }, cancellationToken).ConfigureAwait(true); + } + + [RelayCommand] + private async Task ExportToProjectAsync(CancellationToken cancellationToken) + { + if (_catalog is null || _projectFolderBinding is null || !_projectFolderBinding.IsConfigured) + { + return; + } + + var exportPath = _projectFolderBinding.ExportFilePath; + + await RunIoAsync(async token => + { + var outcome = await _exportUnityCsv.ExecuteAsync(_catalog, exportPath, token).ConfigureAwait(true); + if (!outcome.Succeeded) + { + await _dialogs.ShowMessageAsync("Export failed", FormatUnityExportError(outcome)).ConfigureAwait(true); + return; + } + + StatusText = $"Exported Unity CSV to {exportPath}."; + await _dialogs.ShowMessageAsync("Export complete", exportPath).ConfigureAwait(true); + }, cancellationToken).ConfigureAwait(true); + } + + private async Task LoadProjectFolderBindingAsync(string catalogId) + { + var binding = await _projectFolderSettings.GetAsync(catalogId).ConfigureAwait(true); + ApplyProjectFolderBinding(binding); + } + + private void ApplyProjectFolderBinding(ProjectFolderBinding? binding) + { + _projectFolderBinding = binding; + HasProjectFolder = binding?.IsConfigured == true; + ProjectFolderText = binding?.IsConfigured == true + ? $"{binding.FolderPath} | import: {binding.ImportFileName} | export: {binding.ExportFileName}" + : "Project folder not set."; + } +} diff --git a/Localizer.Desktop/ViewModels/MainViewModel.cs b/Localizer.Desktop/ViewModels/MainViewModel.cs index 1e5720d..69ef359 100644 --- a/Localizer.Desktop/ViewModels/MainViewModel.cs +++ b/Localizer.Desktop/ViewModels/MainViewModel.cs @@ -1,11 +1,9 @@ using System.Collections.ObjectModel; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using Localizer.Application.Project; using Localizer.Application.UseCases; using Localizer.Core.Catalogs; -using Localizer.Core.Identity; using Localizer.Desktop.Services; namespace Localizer.Desktop.ViewModels; @@ -186,605 +184,6 @@ private void BeginBusyStatus(string baseStatus) RefreshBusyStatusText(); } - partial void OnSelectedEntryChanged(EntryRowViewModel? value) => - _ = HandleSelectedEntryChangedAsync(value); - - private async Task HandleSelectedEntryChangedAsync(EntryRowViewModel? value) - { - if (_suppressSelectionLoad) - { - return; - } - - if (CanEditEntry && HasPendingEditorChanges()) - { - var previousKey = EditorKey; - var applied = await TryApplyPendingEditorChangesAsync(refreshUi: false).ConfigureAwait(true); - if (!applied) - { - _suppressSelectionLoad = true; - SelectedEntry = Entries.FirstOrDefault(entry => entry.Key == previousKey); - _suppressSelectionLoad = false; - return; - } - - IsDirty = true; - StatusText = $"Saved pending edits for '{previousKey}'."; - } - - LoadSelectedEntry(value); - } - - [RelayCommand] - private async Task NewCatalogAsync() - { - var request = await _dialogs.PromptCreateCatalogAsync().ConfigureAwait(true); - if (request is null) - { - return; - } - - var result = _createCatalog.Execute(request); - if (!result.Succeeded || result.Value is null) - { - await _dialogs.ShowMessageAsync("Create catalog failed", FormatError(result)).ConfigureAwait(true); - return; - } - - SetCatalog(result.Value, path: null, dirty: true); - await LoadProjectFolderBindingAsync(result.Value.CatalogId.Value).ConfigureAwait(true); - StatusText = "Created new catalog."; - } - - [RelayCommand] - private async Task OpenCatalogAsync(CancellationToken cancellationToken) - { - var path = await _dialogs.PickOpenCatalogPathAsync(cancellationToken).ConfigureAwait(true); - if (path is null) - { - return; - } - - await RunIoAsync(async token => - { - var result = await _openCatalog.ExecuteAsync(path, token).ConfigureAwait(true); - if (!result.Succeeded || result.Value is null) - { - await _dialogs.ShowMessageAsync("Open catalog failed", FormatError(result)).ConfigureAwait(true); - return; - } - - SetCatalog(result.Value, path, dirty: false); - await LoadProjectFolderBindingAsync(result.Value.CatalogId.Value).ConfigureAwait(true); - StatusText = $"Opened {path}"; - }, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private async Task SetProjectFolderAsync(CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - var folder = await _dialogs.PickExportDirectoryAsync(cancellationToken).ConfigureAwait(true); - if (folder is null) - { - return; - } - - var binding = await _dialogs - .PromptProjectFolderAsync(folder, _projectFolderBinding) - .ConfigureAwait(true); - if (binding is null) - { - return; - } - - binding = binding with { CatalogId = _catalog.CatalogId.Value }; - - await _projectFolderSettings.SaveAsync(binding, cancellationToken).ConfigureAwait(true); - ApplyProjectFolderBinding(binding); - StatusText = $"Project folder set to {folder}."; - } - - [RelayCommand] - private async Task ImportFromProjectAsync(CancellationToken cancellationToken) - { - if (_catalog is null || _projectFolderBinding is null || !_projectFolderBinding.IsConfigured) - { - return; - } - - var importPath = _projectFolderBinding.ImportFilePath; - if (!File.Exists(importPath)) - { - await _dialogs.ShowMessageAsync( - "Import failed", - $"{UseCaseErrorCodes.ProjectFileNotFound}: Expected import file was not found at '{importPath}'.").ConfigureAwait(true); - return; - } - - await RunIoAsync(async token => - { - var outcome = await _importUnityCsv.ExecuteAsync(_catalog, importPath, token).ConfigureAwait(true); - if (!outcome.Succeeded) - { - await _dialogs.ShowMessageAsync( - "Import failed", - FormatImportError(outcome)).ConfigureAwait(true); - return; - } - - IsDirty = true; - RefreshFromCatalog(selectKey: SelectedEntry?.Key); - RunValidation(); - StatusText = - $"Imported Unity CSV from {importPath}. Added {outcome.MergeOutcome.AddedCount}, updated {outcome.MergeOutcome.UpdatedCount}, unchanged {outcome.MergeOutcome.UnchangedCount}."; - }, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private async Task ExportToProjectAsync(CancellationToken cancellationToken) - { - if (_catalog is null || _projectFolderBinding is null || !_projectFolderBinding.IsConfigured) - { - return; - } - - var exportPath = _projectFolderBinding.ExportFilePath; - - await RunIoAsync(async token => - { - var outcome = await _exportUnityCsv.ExecuteAsync(_catalog, exportPath, token).ConfigureAwait(true); - if (!outcome.Succeeded) - { - await _dialogs.ShowMessageAsync("Export failed", FormatUnityExportError(outcome)).ConfigureAwait(true); - return; - } - - StatusText = $"Exported Unity CSV to {exportPath}."; - await _dialogs.ShowMessageAsync("Export complete", exportPath).ConfigureAwait(true); - }, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private async Task SaveCatalogAsync(CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - var path = _catalogPath; - if (string.IsNullOrWhiteSpace(path)) - { - path = await _dialogs.PickSaveCatalogPathAsync( - suggestedFileName: $"{_catalog.CatalogId.Value}.json", - cancellationToken).ConfigureAwait(true); - if (path is null) - { - return; - } - } - - await SaveToPathAsync(path, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private async Task SaveCatalogAsAsync(CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - var path = await _dialogs.PickSaveCatalogPathAsync( - suggestedFileName: $"{_catalog.CatalogId.Value}.json", - cancellationToken).ConfigureAwait(true); - if (path is null) - { - return; - } - - await SaveToPathAsync(path, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private void CancelIo() => _ioCts?.Cancel(); - - [RelayCommand] - private async Task AddEntryAsync() - { - if (_catalog is null) - { - return; - } - - var request = await _dialogs.PromptAddEntryAsync().ConfigureAwait(true); - if (request is null) - { - return; - } - - var result = _addEntry.Execute(_catalog, request); - if (!result.Succeeded) - { - await _dialogs.ShowMessageAsync("Add entry failed", FormatError(result)).ConfigureAwait(true); - return; - } - - IsDirty = true; - RefreshFromCatalog(selectKey: request.Key); - StatusText = $"Added entry '{request.Key}'."; - } - - [RelayCommand] - private async Task RemoveEntryAsync() - { - if (_catalog is null || SelectedEntry is null) - { - return; - } - - var key = SelectedEntry.Key; - var result = _removeEntry.Execute(_catalog, key); - if (!result.Succeeded) - { - await _dialogs.ShowMessageAsync("Remove entry failed", FormatError(result)).ConfigureAwait(true); - return; - } - - IsDirty = true; - RefreshFromCatalog(selectKey: null); - StatusText = $"Removed entry '{key}'."; - } - - [RelayCommand] - private async Task ApplyEntryEditsAsync() - { - if (_catalog is null || !CanEditEntry || string.IsNullOrWhiteSpace(EditorKey)) - { - return; - } - - var key = EditorKey; - var applied = await TryApplyPendingEditorChangesAsync(refreshUi: true).ConfigureAwait(true); - if (!applied) - { - return; - } - - StatusText = $"Applied changes to '{key}'."; - } - - private bool HasPendingEditorChanges() - { - if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) - { - return false; - } - - if (!_catalog.Entries.TryGetValue(EntryKey.Create(EditorKey), out var existing)) - { - return false; - } - - if (!string.Equals(EditorSourceText, existing.SourceText, StringComparison.Ordinal)) - { - return true; - } - - var notes = string.IsNullOrWhiteSpace(EditorDeveloperNotes) ? null : EditorDeveloperNotes; - if (!string.Equals(notes, existing.DeveloperNotes, StringComparison.Ordinal)) - { - return true; - } - - return Translations.Any(translation => translation.HasTextChanged); - } - - private async Task TryApplyPendingEditorChangesAsync(bool refreshUi) - { - if (_catalog is null || !CanEditEntry || string.IsNullOrWhiteSpace(EditorKey)) - { - return false; - } - - var key = EditorKey; - if (!_catalog.Entries.TryGetValue(EntryKey.Create(key), out var existing)) - { - await _dialogs.ShowMessageAsync("Apply failed", $"No entry with key '{key}' exists.").ConfigureAwait(true); - return false; - } - - var updateResult = _updateEntry.Execute(_catalog, new UpdateCatalogEntryRequest - { - Key = key, - SourceText = EditorSourceText, - Category = existing.Category, - AllowEmptyText = existing.AllowEmptyText, - DeveloperNotes = string.IsNullOrWhiteSpace(EditorDeveloperNotes) - ? null - : EditorDeveloperNotes, - Context = existing.Context, - Constraints = existing.Constraints, - SyntaxProfileOverride = existing.SyntaxProfileOverride, - ExternalIds = existing.ExternalIds - }); - - if (!updateResult.Succeeded) - { - await _dialogs.ShowMessageAsync("Apply failed", FormatError(updateResult)).ConfigureAwait(true); - return false; - } - - foreach (var translation in Translations) - { - if (!translation.HasTextChanged) - { - continue; - } - - if (string.IsNullOrEmpty(translation.Text) && !existing.AllowEmptyText) - { - continue; - } - - var draftResult = _setTranslationDraft.Execute(_catalog, new SetTranslationDraftRequest - { - Key = key, - Locale = translation.Locale, - Text = translation.Text - }); - - if (!draftResult.Succeeded) - { - await _dialogs.ShowMessageAsync("Apply translation failed", FormatError(draftResult)).ConfigureAwait(true); - return false; - } - } - - IsDirty = true; - if (refreshUi) - { - RefreshFromCatalog(selectKey: key); - RunValidation(); - } - - return true; - } - - [RelayCommand] - private void ValidateCatalog() - { - if (_catalog is null) - { - return; - } - - RunValidation(); - StatusText = Diagnostics.Count == 0 - ? "Validation found no diagnostics." - : $"Validation reported {Diagnostics.Count} diagnostic(s)."; - } - - [RelayCommand] - private async Task DraftMissingAndStaleAsync(CancellationToken cancellationToken) => - await DraftAsync( - WorkQueueFilter.Missing | WorkQueueFilter.Stale, - "Requesting local model drafts for Missing and Stale items", - cancellationToken).ConfigureAwait(true); - - [RelayCommand] - private async Task DraftAllAsync(CancellationToken cancellationToken) => - await DraftAsync( - WorkQueueFilter.AllStatuses, - "Requesting local model drafts for all locales", - cancellationToken).ConfigureAwait(true); - - private async Task DraftAsync( - WorkQueueFilter filter, - string busyStatus, - CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - var selectedKey = SelectedEntry?.Key; - - BeginBusyStatus(busyStatus); - await RunIoAsync(async token => - { - var result = await _requestTranslationDrafts - .ExecuteAsync(_catalog, new RequestTranslationDraftsRequest { Filter = filter }, token) - .ConfigureAwait(true); - - if (!result.Succeeded) - { - await _dialogs.ShowMessageAsync("Draft failed", FormatError(result)).ConfigureAwait(true); - StatusText = "Drafting failed."; - return; - } - - var outcome = result.Value!; - IsDirty = outcome.AppliedCount > 0 || IsDirty; - RefreshFromCatalog(selectKey: selectedKey); - RunValidation(); - - StatusText = outcome.RequestedCount == 0 - ? "No matching items to draft." - : $"Drafted {outcome.AppliedCount} of {outcome.RequestedCount} item(s)."; - - if (outcome.RejectionMessages.Count > 0) - { - var detail = string.Join(Environment.NewLine, outcome.RejectionMessages.Take(12)); - if (outcome.RejectionMessages.Count > 12) - { - detail += $"{Environment.NewLine}...and {outcome.RejectionMessages.Count - 12} more."; - } - - await _dialogs.ShowMessageAsync( - "Drafting finished with rejections", - detail).ConfigureAwait(true); - } - }, cancellationToken).ConfigureAwait(true); - } - - [RelayCommand] - private async Task ExportCatalogAsync(CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - var directory = await _dialogs.PickExportDirectoryAsync(cancellationToken).ConfigureAwait(true); - if (directory is null) - { - return; - } - - await RunIoAsync(async token => - { - var outcome = await _exportCatalog.ExecuteAsync(_catalog, directory, token).ConfigureAwait(true); - if (!outcome.Succeeded) - { - Diagnostics.Clear(); - foreach (var diagnostic in outcome.Validation.Diagnostics) - { - Diagnostics.Add(new DiagnosticRowViewModel( - diagnostic.Severity.ToString(), - diagnostic.Code, - diagnostic.Message, - diagnostic.EntryKey?.Value, - diagnostic.Locale?.Value)); - } - - var message = string.IsNullOrWhiteSpace(outcome.ErrorCode) - ? outcome.ErrorMessage ?? "Export failed." - : $"{outcome.ErrorCode}: {outcome.ErrorMessage}"; - if (outcome.Validation.Diagnostics.Count > 0) - { - message += $"{Environment.NewLine}{Environment.NewLine}See diagnostics panel for {outcome.Validation.Diagnostics.Count} finding(s)."; - } - - await _dialogs.ShowMessageAsync("Export failed", message).ConfigureAwait(true); - StatusText = "Export blocked."; - return; - } - - StatusText = $"Exported {outcome.WrittenFiles.Count} locale file(s) to {directory}."; - await _dialogs.ShowMessageAsync( - "Export complete", - string.Join(Environment.NewLine, outcome.WrittenFiles)).ConfigureAwait(true); - }, cancellationToken).ConfigureAwait(true); - } - - private async Task ApproveTranslationRowAsync(TranslationEditViewModel row) - { - if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) - { - return; - } - - var key = EditorKey; - - if (row.HasTextChanged) - { - if (string.IsNullOrEmpty(row.Text)) - { - await _dialogs.ShowMessageAsync( - "Approve failed", - "Translation text is empty. Enter text before approving.").ConfigureAwait(true); - return; - } - - var draftResult = _setTranslationDraft.Execute(_catalog, new SetTranslationDraftRequest - { - Key = key, - Locale = row.Locale, - Text = row.Text - }); - - if (!draftResult.Succeeded) - { - await _dialogs.ShowMessageAsync("Approve failed", FormatError(draftResult)).ConfigureAwait(true); - return; - } - } - - var approveResult = _approveTranslation.Execute(_catalog, new ApproveTranslationRequest - { - Key = key, - Locale = row.Locale - }); - - if (!approveResult.Succeeded) - { - await _dialogs.ShowMessageAsync("Approve failed", FormatError(approveResult)).ConfigureAwait(true); - RunValidation(); - return; - } - - IsDirty = true; - RefreshFromCatalog(selectKey: key); - RunValidation(); - StatusText = $"Approved '{key}' for {row.Locale}."; - } - - private async Task InvalidateTranslationRowAsync(TranslationEditViewModel row) - { - if (_catalog is null || string.IsNullOrWhiteSpace(EditorKey)) - { - return; - } - - var key = EditorKey; - var result = _invalidateTranslation.Execute(_catalog, new InvalidateTranslationRequest - { - Key = key, - Locale = row.Locale - }); - - if (!result.Succeeded) - { - await _dialogs.ShowMessageAsync("Invalidate failed", FormatError(result)).ConfigureAwait(true); - return; - } - - IsDirty = true; - RefreshFromCatalog(selectKey: key); - RunValidation(); - StatusText = $"Marked '{key}' ({row.Locale}) as Stale for re-draft."; - } - - private async Task SaveToPathAsync(string path, CancellationToken cancellationToken) - { - if (_catalog is null) - { - return; - } - - await RunIoAsync(async token => - { - var result = await _saveCatalog.ExecuteAsync(path, _catalog, token).ConfigureAwait(true); - if (!result.Succeeded) - { - await _dialogs.ShowMessageAsync("Save catalog failed", FormatError(result)).ConfigureAwait(true); - return; - } - - _catalogPath = path; - IsDirty = false; - UpdateWindowTitle(); - StatusText = $"Saved {path}"; - }, cancellationToken).ConfigureAwait(true); - } - private async Task RunIoAsync(Func action, CancellationToken externalToken) { _ioCts?.Cancel(); @@ -825,21 +224,6 @@ private void SetCatalog(Catalog catalog, string? path, bool dirty) UpdateWindowTitle(); } - private async Task LoadProjectFolderBindingAsync(string catalogId) - { - var binding = await _projectFolderSettings.GetAsync(catalogId).ConfigureAwait(true); - ApplyProjectFolderBinding(binding); - } - - private void ApplyProjectFolderBinding(ProjectFolderBinding? binding) - { - _projectFolderBinding = binding; - HasProjectFolder = binding?.IsConfigured == true; - ProjectFolderText = binding?.IsConfigured == true - ? $"{binding.FolderPath} | import: {binding.ImportFileName} | export: {binding.ExportFileName}" - : "Project folder not set."; - } - private void RefreshFromCatalog(string? selectKey) { if (_catalog is null) @@ -881,73 +265,6 @@ private void RefreshFromCatalog(string? selectKey) } } - private void LoadSelectedEntry(EntryRowViewModel? row) - { - if (_suppressSelectionLoad) - { - return; - } - - if (_catalog is null || row is null) - { - ClearEditor(); - return; - } - - if (!_catalog.Entries.TryGetValue(EntryKey.Create(row.Key), out var entry)) - { - ClearEditor(); - return; - } - - EditorKey = entry.Key.Value; - EditorSourceText = entry.SourceText; - EditorDeveloperNotes = entry.DeveloperNotes ?? string.Empty; - CanEditEntry = true; - - Translations.Clear(); - foreach (var locale in _catalog.RequiredLocales.OrderBy(locale => locale.Value, StringComparer.Ordinal)) - { - entry.Translations.TryGetValue(locale, out var translation); - var status = _catalog.GetEffectiveStatus(entry, locale); - Translations.Add(new TranslationEditViewModel( - locale.Value, - translation?.Text ?? string.Empty, - status, - ApproveTranslationRowAsync, - InvalidateTranslationRowAsync)); - } - } - - private void ClearEditor() - { - EditorKey = string.Empty; - EditorSourceText = string.Empty; - EditorDeveloperNotes = string.Empty; - CanEditEntry = false; - Translations.Clear(); - } - - private void RunValidation() - { - Diagnostics.Clear(); - if (_catalog is null) - { - return; - } - - var result = _validateCatalog.Execute(_catalog); - foreach (var diagnostic in result.Diagnostics) - { - Diagnostics.Add(new DiagnosticRowViewModel( - diagnostic.Severity.ToString(), - diagnostic.Code, - diagnostic.Message, - diagnostic.EntryKey?.Value, - diagnostic.Locale?.Value)); - } - } - private void UpdateWindowTitle() { if (_catalog is null) diff --git a/README.md b/README.md index e5b0d54..6833809 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,23 @@ # Localizer -Local-first localization authoring and QA utility for .NET applications, games, and other software that consumes text resources. It keeps an engine-neutral catalog, validates machine-sensitive message syntax deterministically, and treats model output as untrusted drafts while deterministic code owns identity, lifecycle, approval eligibility, and export. +Local-first localization authoring and QA utility for .NET applications and games. A local model proposes drafts; deterministic code owns identity, validation, approval, and export. Consumers exchange files — they do not load Localizer assemblies. -Consumers exchange files with Localizer. They do not load its assemblies or share its .NET runtime. +[![CI](https://github.com/TimoKuronen/Localizer/actions/workflows/ci.yml/badge.svg)](https://github.com/TimoKuronen/Localizer/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -This repository currently ships the catalog domain, Application authoring use cases, authoritative JSON persistence, validation for `plain` and indexed `composite` text, local-model drafting via an Ollama HTTP adapter, human approval of drafts, compact per-locale production export, Unity Localization String Table CSV import/merge and export, per-catalog project folder bindings, and an Avalonia desktop shell for catalog create/open/save, entry editing, draft, approve, and export. +![Localizer desktop app](docs/images/desktop.png) ## Highlights -- Engine-neutral catalog model with stable keys, BCP 47 locales, and Draft/Approved workflow state -- Derived Missing and Stale status from fingerprints so approved text is preserved when source inputs change -- Authoritative versioned catalog JSON (UTF-8 without BOM) with transactional save -- Deterministic validators for `plain` and indexed `.NET`-style `composite` placeholders (`{0}`, `{1}`, with escapes) -- Entry constraints (grapheme, UTF-8 byte, line, term) plus approval and export validation policies -- Explicit human approval gated by `ApprovalValidationPolicy`; export gated by `ExportValidationPolicy` -- Compact per-locale runtime JSON exporter (`ICatalogExporter` / `CompactLocaleJsonExporter`) -- Unity Localization String Table CSV import/merge and permissive CSV export (`IUnityCsvReader` / `IUnityCsvExporter`) -- Per-catalog project folder bindings with configurable import/export file names +- Engine-neutral catalog with stable keys, BCP 47 locales, and Draft/Approved workflow +- Derived Missing and Stale status from fingerprints so approved text survives source changes +- Deterministic validators for `plain` and indexed `composite` placeholders (`{0}`, `{1}`) +- Local Ollama drafting through a provider port; drafts stay unapproved until a human Approves +- Compact per-locale runtime JSON export and Unity Localization String Table CSV import/export +- Avalonia desktop host for catalog authoring, review, and project-folder handoff - Layered Core / Application / Infrastructure / Desktop solution; dependencies point inward -- Application use cases for catalog lifecycle, entry edits, human drafts, approval, export, work-queue/status queries, and validation -- Avalonia desktop host as the composition root for authoring -- Local-model drafting through `ITranslationDraftProvider` (Ollama HTTP); drafts store sanitized provenance and stay unapproved until a human Approves -- `ICatalogStore` port with Infrastructure `JsonCatalogStore` and golden-file round-trip tests -- NUnit tests across Core, Application, and Infrastructure (default suite needs no network or running model) +- 118 NUnit tests across Core, Application, and Infrastructure (no network or model in CI) +- Ubuntu CI via GitHub Actions ## Architecture @@ -36,11 +31,7 @@ Localizer.Infrastructure Localizer.Desktop ``` -Pipeline intent: - -```text -Sources -> Neutral catalog -> Drafting and QA -> Review -> Generated outputs -``` +Pipeline intent: `Sources -> Neutral catalog -> Drafting and QA -> Review -> Generated outputs`. Projects: `Localizer.Core` (domain, lifecycle, validation), `Localizer.Application` (ports and use cases), `Localizer.Infrastructure` (JSON persistence, Ollama drafting, compact locale export, Unity CSV adapters), `Localizer.Desktop` (Avalonia composition root), plus Core/Application/Infrastructure test projects. @@ -50,22 +41,15 @@ Details: [docs/architecture.md](docs/architecture.md) - C# / .NET 10 - Avalonia +- [Ollama](https://ollama.com/) local HTTP API - System.Text.Json - NUnit -## Build - -```powershell -dotnet build .\Localizer.slnx -dotnet test .\Localizer.slnx -dotnet run --project .\Localizer.Desktop\Localizer.Desktop.csproj -``` - -Open/Save dialogs start in the repo `Storage/` folder (gitignored working catalogs and local test files). - -Local drafting expects a running Ollama service at `http://127.0.0.1:11434/` by default. Preferred model is `llama3.2` when installed; if that name is missing and exactly one model is installed, Localizer uses that model automatically. Override with `LOCALIZER_OLLAMA_URL` and `LOCALIZER_OLLAMA_MODEL` when needed. In the desktop app, **Draft Missing/Stale** targets unfinished locales; **Draft All** re-drafts every required locale (including Approved). Use **Invalidate** on a locale to mark it Stale for re-draft without deleting text. Review and **Approve** before **Export to Project**. - ## Docs - [Architecture](docs/architecture.md) - [Catalog and format contract](docs/catalog-and-formats.md) + +## License + +[MIT](LICENSE) diff --git a/docs/catalog-and-formats.md b/docs/catalog-and-formats.md index 7db8cfb..5c4a1d3 100644 --- a/docs/catalog-and-formats.md +++ b/docs/catalog-and-formats.md @@ -258,7 +258,7 @@ Minimum capabilities: - Deterministic row and locale ordering. - Import diagnostics for duplicate keys, locales, malformed rows, and conflicting external identifiers. -CSV import must define merge behavior explicitly in Milestone 7. Until then, the only required rule is that imports must never silently overwrite current Approved content. +The Unity Localization preset below defines merge behavior explicitly. A fully configurable CSV layout beyond that preset remains deferred (see roadmap Band D); the only required rule for any CSV import is that it must never silently overwrite current Approved content. ## Unity Localization String Table CSV diff --git a/docs/images/desktop.png b/docs/images/desktop.png new file mode 100644 index 0000000..42fcb69 Binary files /dev/null and b/docs/images/desktop.png differ