From 6a334b4f1fddc12a76dd59227a44f626a9e195ce Mon Sep 17 00:00:00 2001 From: TimoKuronen Date: Wed, 16 Sep 2026 15:42:11 +0300 Subject: [PATCH] Polish repo for public cut: CI, LICENSE, README, and lean UI shell. - Split MainViewModel into feature-area partials for clearer desktop orchestration - Align README with sibling utilities and add desktop screenshot - Add MIT license, Ubuntu CI workflow, and fix stale CSV merge docs note --- .github/workflows/ci.yml | 28 + LICENSE | 21 + .../MainViewModel.CatalogLifecycle.cs | 119 +++ .../ViewModels/MainViewModel.Drafting.cs | 149 ++++ .../ViewModels/MainViewModel.EntryEditing.cs | 246 +++++++ .../ViewModels/MainViewModel.Export.cs | 90 +++ .../ViewModels/MainViewModel.ProjectBridge.cs | 112 +++ Localizer.Desktop/ViewModels/MainViewModel.cs | 683 ------------------ README.md | 52 +- docs/catalog-and-formats.md | 2 +- docs/images/desktop.png | Bin 0 -> 71861 bytes 11 files changed, 784 insertions(+), 718 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs create mode 100644 Localizer.Desktop/ViewModels/MainViewModel.Drafting.cs create mode 100644 Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs create mode 100644 Localizer.Desktop/ViewModels/MainViewModel.Export.cs create mode 100644 Localizer.Desktop/ViewModels/MainViewModel.ProjectBridge.cs create mode 100644 docs/images/desktop.png 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 0000000000000000000000000000000000000000..42fcb6962693d46d69c8eb0133b9682e353202ba GIT binary patch literal 71861 zcmb5V2Ut^Cw=f(P3xXveV5kBKJs=5P%Fsz@5_%sx0YWcQ9TkvXLX$2fK!8v~2Oa4h z>0PBaQKXryru zCw`6&0E|NaANTx!T_(4-@vtJ@@P+gSyOSm-E%puxzGL?vnCmBO`5##FC+y?t>Pfoi z;ZN9I2PIE}tw}JK-T#0s{|9X4>i+ZoDAGNW7$>ivbNy^TS4?T+tgB7>zD9a80z3dH zfHFYt=l)6mlbTB|03db%09=au?`xK+06<*`0C0EazpsH`0syyy0f5?}|GxI$IB~Ub zxA?E&$Vi_TZEXR7-9iB1h9Lkz{T2YYZv0<;q>ul{+U}9M7)k4LA-(JXP5>LgJpcmW z46p+5lOP!2K0pv4ayAQ)2VA;%@#i0@k&*r`U%PynjO_CDt5>gFqr6T@NpYQm;>Jy? z+c$0kZ&Fa)ro9cMzC%MpLwSqtF72JWRCj3Z{2b)MCDL!mE|XurOn&DE#f>}vzuVd0 z0N}L?ugL(HF0cVE0xw(wUN~z9Fplz@Eu7r3ru0{@wtT(H5ygbDO<-7#d4<<*Igqu=2ebvV6rYHXv+vjQu>Whygh!k z;jAFapv`2l{%#kx>%+n}NjxDN6n+{+f0gX_u#PT*yo`Z~51`jJC;SmAdil!79l^x* zPwTygQ=@}_X)&vzuX4aG<=U>aeP_ZC-Ecf^jaRvasulH&bH~HfM8d>#BQCZ^+qOz_E75-u! z70v~{odk76#VX`*!{GpZ7FvCC#n_hbmwpdo4-2}ooOnnRJqlk)3M{osHMRPque=VD zYp$0!OH2-d1R=5c@$spCD#}v}WyZX$ejyoV3$wM_Ei+?(crLR_CyCa%(S5zrc=7eO zs>Wl!z|S9~O%@2F_Nc~M-5kdneh)zl$8Y8@W(A?&#BgJsmfZGnOEjMde0Y{DRhT9g zJ(ure=w>ko%`_p4d)Cw-SMhSRnuaR)EjhXfQ-j`XMj_= zH~Ou9y-xP=qb)}_AqAslX?P_C#S=9&#~kaEuHT$->(@>%7Z27megfUp*Gd?DZ#0Qi zpx}AIr<_XdKSTY|pCr z3MOhSvCU88<0%JiLS27j5%A5T%=npN?Ya6jS=9_mOjhvrJS&4KF2Dql#+e zGZKzMO?pg2*(2rR@{nO8xl)u-M3~^C+vNfk^+V7?l)c+^Aw1_Qj7luyK|?C2Ze&(P zH5>wj4&0LeNMD-pLrUhv=5g5ecnBdt?hLRWe@b@uS<-J*GxL?bie&NsMq%;7l}Zn! zD&8Y5#eQ_zkrG+am1d0Nc;MnRWnFVG-Ai$T)BRzd(AL`9cBQsZ!$4K1h6l6_p|kyE z@~T0XiXg<`Pe$DXk|S6cT)OohAI;bEGU7Ai%)BB_T{&Caxm}tO*VAny$SSr`Kg^3J zfD^Ou5MVN{&rBZq5k#r3BWmD=M|c!#^Mgl-<)$|{l>1}NjIOw9a5DZDjo(C~eHPYC z-dpdMs<%XCk%=Qji4-W=R`LYQK{l11vO(1mD4hhhk3ug(-aRy26498P(?=F(O=Mad zmf{Pw%3CJ zjn*g^hn@Ie>-1)Cy<0bRefK2lRS7NrGq!kEe0W8&j+q0yu&ySw8d76gSor4yj(29C z==?7S2&2E~4xJZq3t*dCmZ^2R!zP8X1g%dIVe#)rOHg9bh_j2(u10=fBYMj3?wrXN zY8hO%A^9UhK3?y>aiv9(>%5oJ$o?n_ybGJd!uCM%zHQtSrPsR_d$-mrzrOyfz2*%8 zy}+-%U0a}4u!QkT4XXT;rOjn=z@1?%`GzXySz;|6jWw}K5nqpnjbY7IE9Bwk!;UN; z&j71($Um37YRYHTocXL>eA||d*3BotX1}=k7C%!^lJXhA=J5wUAIf^auhcLb9k>-O z4%!~%ILZNWz!ao9%^9Y$@@VZMcG=+cNLkb7Uk2eHGG=vuP{5j}3hxX!M^-tx+{?Ep(l zc%)mGF;qtZt7*4JcaWp_gBsK;Yy4YUSZ;k;+hk>hh?~D?#aYQ~MO09;ST|hAS zr9M%lkWm%QE?jgN@27^OZd0uR$69fZHQgAr77{)KY=pg^Ej(po`ts0S;5_Q=Jj|{x ze^ZbY-w=#&*nNRcxgyD?UAF0GKY~>(H_VMA;L0I!qsRqsn=74+Vh1N&ZupF zyxTs61IJA+w3!QzPfhg(I)G|QF1Gwjy9(>SVXX2<8D1IdE6UcsAVTrXf~GS&@VeKH z{B+quZ8)k=QzZh~iTTi#&ZcNGarfE1T%_037SbT#U>KL0f_)7!L+mXFJpor{)n&+^ zCy!C2^UXJ16`Sel0q=4fy=v?NSx3wGx>&rbg>l^PLUm?z9Ar}xtJwC|lsh$=`+Djn!-qUqg?FeS^iv?QlM`G80K;!_Ty8r77#?pe4f zVwh06bsiD3_|u$wi(SX4TZf!yfZOlCyxw;+pXEK3iwpY~wrLhehUfh6))=(4cY@GI)~Vmp z3i0^6c`!+cuR;FL?V3gwx8l%+Mh^y#q#EOy+4>f#%qJO+zSQa*5&vkzN58xuzGneU z{dAG<-xo(X-sj5Cg+7sJ9a*}`6}692YrwEUXEq9!w`SzkeS@tH`mcEq)}4wM)S*@? z>A=qP0OF($w}L{M@+y@Ua>wWS^g|`JaP_=mVa(i2o&Dod3+<8KmXx{S+ei=fP6w%L zkq0r^?L)&}X8?!T9iTJG4K4&fwtT!O_l)5@lNmD8yN-PeYno(g3W=Q@mHiytP@V4E z4tZ61V^>whDC)q_`}V-A56+sVHkTvp-`(w~2%mpjXhnDcshfJIB8-_*mBra2NWlRN z&bAjv5M~TIWswax2so#9uRfM$w6Q@&YIkCKE1eU_(_BqA)!Xz&WCfhl>)c1p-pS|K zX3|zuBJh0{{HNwj@s~!&sPf;iUcA86XThDvxk5+FL2h^U@t&JhTPHSnq*zFJq25 z#mTuNzNbVB#yJQxU|5mhq7iH4s2rOxDBk0$^=x()LBzzC(AW~Vc7>D!GD&6A4mu@ZQqIg$ydqlI}Ej~<)hg47F zORy<5906emFj}&t>hlEgIWc2g@zngop$}A7?)}D?HCKnuSSQ4>AUZz3j`@}4m@{`h zG(~fu^1E$kOy^UZ{`NKt@3=V&uwS8Hb1oD?uEn*#hu$D7cr!~y{LOsKzx_eX89@0U z2~MS-GTc>}&9rggOb9#a>=>H0lFoglfUff0@rFIkPm(q+db6y6{g63yXO)e9+qsVY`;bZ`b3D?g%q zMSTXy<9zI7{^iNF&%d0i3%`96;*|Lnt;7$`Hpw82w1AO!=9=|3kk!;FrYM7Lydb6W z!Q*_wH6nd~?9=HoVK*sSTwY#3~<+A z)*Wbc7fOBf(H}CN6d9Kp-7tpZa1fbLK+C4pVq=Nj`@m_{35t1=8&||rcYa6J1~OL! zs=qVv^*U;Z%XZPLmWyoeyZ!JIhUA{c^Sl{qog+p}TTUV4IL}s7^6Jp4yd6|eOA`f zgc}pbt7&Y)X~LR`K`525CUpff_uG%+CP`7O_OjSkxP6^()R2Z{TV}?7OW2I6BYX6} zte5AIvY{Y&p5qL_i|fqQpXCq7Cm;@Tb=s2)5)&{*Z=bTW3zvpPqJCH{Hs!U~en>2h zlcD2YB(~fNx5Qg+A4I zwRL|Fv>VNz!-)@aqY8^X2p61fOd2<-IM`xFwm9l?q&Y11%Ce5F`vEtVl-F3R3R_D* zE#7&tU8|P5uC%y6X6GilpJ_gocWUX;_IcPKd!l^jPVm3%j!*BL%Icd~4#gX0@1z$+nX>I&GmQLhcY+cC#|=h@&wsl%;#;OISUq>IbuZa2-Bt2ckUPPamR z6wK4!oB{S8U-+}{+EmR~^-?i=?X6gli_00Hl$&S9)(E(*@p?pBURIi7yxJ_1m4U^^ zHl)zh69&*n0Oul3q8bzAql-rJvtM-kMrUaq%Uk@Ydfo2$PsZ+l0A%H%=J^|^9glZu z^PbEpJZnqYGo*SSBpJzPYvV-6o-vl>58 zH5Xf#hT{Wa(w}!|L<->QUJpg-szDpNA)=Zwh)lC(weTa_Tc$-z+*2K_>tQje2&7ID zh_?hmAlG6pXt|YoBft!Mx8Dph$+JD>Jyq@LSe7^s_1Kx&qWbCyLvUsqP39FTC;EFr zxQKh1>^qsN8}VNshdmm^?RTHNZr9xO;tR_A+rX*COjWV#R7&5??sjKt*eJnFGH^Ps zo`_N1O~(k%Xn}%!mfs>L`yF94<`L%Ww2x~Az6fqFNu-RjebWV(R8e=95eldI`XUxl60wPp7zKr^1Y_m4G5@YtiIV}E2DjVt07;BXp!(Jug3x@ zln@5Jp@Pz@lDZYY?#KKT|7Qa@z}qhK0qDB1=pW{d`RZP-_8G#CdNaywGaac34=Q^`=ux-;jc zJzez(U-BUj+z`UrX)VsQHfN+0Iwzl>z==gjJ^p@Y)8t4i7Bu!MmBr`V^QcX8wb-Wb zawbF9KD}>9LLzXl0s=H)Th^JJo29#RdT*K#+zzw`E4YXc^6u;?b}zR$#sYZ+d&=M6>wOhp`&k=C(9_+uu6=Ps zNUdT9)ka7a3ucv|iba3YhtU|r8#NFqrpDlO@ZgJmEJy05cJ743a&+&E$%!a)Rs!Qz zrO-NKKI6+oop%fd0QS(fD2N@SNXSxMBoR>!mJQ2>iZ1Nj zSav}wIiHF8)@k2cVkeQ>vGjB~>(*I{FLQ!^iS?~t#Eyi=lx5cyna9u*9i#gOV)x_P z98G(6y02S8sX&BqW$9JwPdQGC$R6uGC$SKms?pd22$cM3WG{t>pWRCQD#8x#6xXYM zku&q@@XhNl)M|*)Mw%#RzPB1;!Or7$JxG}`UY}n~EB^~?Y#igr<{w`V_dh5l;6@Yp z+ixbk_@w>r0lC|YR@n8_QNKb4OT`SdpiPd_F7p=CJH?3~H13eB5q3tGSaE5oWLqb- zE*)G&7+*E3ifDxRE9|hToEa)Z)ie!)l=<|LQQ+4+tpBJ2RvUuVCR#MmPU3^S*#9k! z=RQ}vQnJ;Bs|sPw1LGc?sD%;7RNYKY8_Teg2!!v=nqn_GA7FCh)(kgA<+J@Kt%BDn zmC~PTkE&RR3s?Q%+(4n-Y(~ zl9nFk!M!OrqO@Jvd?Xw>l)R{Q26%)sJ4cp?xgnO<$z0!=pf*ftK`4F^M5l>fAHx7g zb;30C`*Y@SeFg>{n*-N(j(CW(UXw_W$^b`Jkk(dVmQ34C{x_ZV9IO0;a-A~nK3kQj zRh>Jl8GkP2RI!a;2bZ{O_M0>>mYx?B_D075Z+|m=xq=Z@&#zh^YQOm~bMK_oV}N~z zePwx1yl4j~h4P8J%NbckJuNDU$`iGAc=ywVtp_ptuP6p}jGL%xQ-T#<$}7x6XbxN_Nz8Ow9%Dtn3>={p`+U4{d`q1Ep;N^EIQSc zxGHl+zH3FLP!-4O0`%T!?jdN_Wxv#!o7^|*I*~bwoA|wYhpL!+debOGIE?^wG7M`x zN(`%64b0Ox5GAO+*0Uo^uDRU+JpCsd2o=5PF>}gjtBomD)tl6kU`L3M@8c&8j2IxS zM)8Gi$&K;JPxBY+ZVa1NLYKKQoG2}JVpyNLvf^(Wg|89p@Ontm_1yDir?x2#o}fTT z<)r@%FXgW>{rnt4b$g*vy-V9+>R5Z_BTT3$GQKdbwSEJx@nht@IyM~rIyVtdbDt@6 zcE^$x;iH3}yklz|4rwqd!Yb!@U)TQ4u(y9tCn{xkQrmn84&u)|1CaZ(t&Q$ScV#)q zizIso`X60h+WB|`C$@Yyft|HBL#eO~Z=^PGxxe{M%+BPkoODElxuJTWAgs{)a!1NT z!Y4`8g93>(4kACS*MLS-1(>sC3X)X>!`q_}JRtbF_9~_lk zi?Ox`?I%o0tVU^qpeAmK-KCf#8}k6Gpq^&3LX6Btvp_!B-6{E!&bO;ja%IDMe!NUb z{OYAfL(FL0Y#KM=jr#ZC0v9+7hJ`}lO=Ax|Fi$YDF!)o$Dj+;XSeKOR?$Q> zjVy&;FLx@j$#d|@Dp*MU1Ve1^Qs=xl_5V%!-zM{ZB5GoP(mF-OKp|aMsU^tJ-DanR zp>(18g_Cg~W!kthf5VHrFpC>b%jOMsnWDBW~n%=u?Zht za0cKEBaoZ!x#icbfSV}eo@vpgmpv>xuk4tv z{0z`8`d#1$qyAy<86YmiPMk3yrXz(d$9h!#<6PC-F~6-Tk$P#zE1{L$RU;d`rNTHB zjYXJX746<%0j6E9NZzni_^n>|OB+pHZ%6&ZO5;YOb9iOeUw*|Y1toWDR0c^bB$==z z8GRgQBKAAU&#ZwYM4ITS|NHGFaJm1kerLU|T0+g0k zc@qZ)G#EFShQyBzValHc=ao>{7$MW*JI|e~D&YN`|Tu!C= zuqZtnPh+bXC+N`#q|3_)oM=019jX@Ae*h>XdfXda-&?#NbAX72Pz7?c@7+wgg_CA_&%vNI5UH-GS99Q& zz~ZAz?iChJH+0l%q>dC2EbZ5lIVYV81WZAe!vkffjZXtz< zb$*BG+sVf=h@s8ys$MOQ&Y+Kqz1xP@X--un|Bqy;dA%1*IXVNB;ot@u0r@3^=79@RG zHE2`PAzUg!!;6^~=GMBK+#{0Ww_LTTvq_s+)F(gc9hxhs+I-FxV{mqRFO{~V4J%Eq zlOdIih>yG`i%u@^BR_J=TtXeUKFx>Jp1hq?luUG))M-qBX)MAuv$fie<=yyi`hV!> zqAFe$*6G_2tzocTr zR(%o`yWdA?QLX8XXzGd?IoKUPe|0rQzRMu7pV*KW0l}#!zrqL!su-6lgWI;h-7!%A zak5VhO!)fYo%6+I?2AukB??%zWNbP?RutVfo4#;PQLl{6p6XndLr@*3&qHSW34j{=^-k>46I( z!9#e>9PLyr%WxAyWVL?oX%+9n4prL+fnL*pX$S!EN$09{Eeooob?Piz$?4?J@XLC@ zKW-*we_rPPdQOM18qIbY;6~I?wI&2Jwf>WRBfA5-Z*fmtNB47He@r>O+6015@mA`~u1vZ2GRVoZYrl0}@M_@~}p} zyvTVj%vUNbM%6{0bnw4w#BxBsMp3_{_?H{~8b`N)J~pvPO+n4CvArGB!H_X8#7&0P z24jr^vK(M*-5r0NmrC@wpg7mc<7Aa;qrMN#`>BewyiZ&Y56yh6=dOQ@nQ%wF81zbB z=cJe63fo7HcSoh*?QtGbLQ>iRk2_3lZ=3-FRu#k^m1YDQI9PElNuPxKlKZ$Z50)N} zxEUQ8C*(M;;$EGn$Lk(a#J;od{1{q#y4K^&vGXo#GFr3_*-#m&DE*}O#pg6yJEjf= zRqH@Q=6tM?*UI{s`yjn+TY*M$bJnpb7l8@#(|)z{Ll)gpsssDLjz4<`-{%eVtyqY1cLXC zI@r6t`h&eXz)ouDUVl3hWuG(pN0|jRux8AQ4^oXd_8e6YSo-U{7{O69?q3T>GLvST z{9D~@8XpYS-#+CyO(;3J^SEE*QQ$Ez_3`*Ad)g`;#(chkI~WheAgNyy_xHl3Su-a` zN9w3hrmE3J`$C*h9{^Xt30*Ul?DL!F_hHhtabogepPrwt`n zEV_QW)~;{hsCezTQ%$nAwvJWz$qb^xR(^;t)eVPbnpVwy+I*c3eAW$fIj-eU3*Z>zej zWE<>dZI&R+S>bjDI5(sMsIc_j=74%}V@at)z~sHxfQcmg;d+gi6OJo|mNjsGE6w;B zj?GWjW8k;5CFA7MBM5isW3ynnOaDZa(wwMx?{1k>^F_ewk@Cik=RZA7U@1s%6xlhb zmY@GN#Y7mZNet_^{Of<@k$;1f#}am*OMz74I9@GKT5>>GB(XiOL*Wcy9D71(@uhD+ zQ)a6CRId9=UZ{(k+-@s9M{Xkol__$STzDZ_Km;o;@|SbtXW6% zfRwVUX!|{iq1P8PZ{-B#lD}>9#L(XY=O+~q_lvsc&_dmiRB9wU+qPa7$`d~3KjrB) zfd8WUJ-4&EWoP{(&>RL%!rjVIO5z$P4yCEe2~5^U)j-vh7F5TVdMtNJIo}uroS)1n ziuY+!g9rn{$0k`=pT|Ywk zng=gif7|aXN@X(X^?k@a={$p)F_S0Y+SyGv%;pdGkq~TcTAe#=vPXKJ%r2BsEHX;~?A^Ou^ioSNGb*JQmd`b!DKbGiMl?Ba zjjiiv6w`#rP*&{lmPdr2*0CJkeN>daMPf&bNWn`_8m`!vfF3b|rZJ3CR3&7cTql!t zZaG{=HK?%HxtW?8em9=%JNG&9y3wuY@_$nQVO$##GlD2v!cfunBt#lwReO~)`sy@> zIZe{Pq^24zxAd0(!V>d;kY+hP>uE7KHDI$uBQ)ad^s+UQ-@!;$*oSqTZ(TQEAB@bsDNlK-UR+Uyzkcb7I0Ti>(02r1<){Of< zz%n06A>zF&n&OXMkMgg3s%lLK_a8hW=FAJcZ|n=4Ht10j$JH(?JxRn336HU3R=#0eO z*KbfSqUJn!&H(3kAz2pnT|==MSz}2}EurS6ipev!$>W|aiIkdQmB9aW3gIxeF<;ca z>!O)FtQSlv{bpLX>L$2APX(hcRp-X8$W^12-`yQ)+HO;jQ|`iH7t8xJe-81r zqilM=pm?X)GAl$Jq!|wqL%<-k`L+uAsmXY08@z*p#GO~s+r@C#E%#8{m8*XY%6V>v z*7m4D^Bd9fjcMxEN#4xCek_8PgQY3&+jy%(bp+1gE#MgLCRwJfQz~c4nSK>KV;s$C zWld;G&eIEHhqGxcL1%WyEIUX=FA@auSWVm2KT2vwnDpK4(Ox&GBiOaFAwcE=B_mSh zc(9Zw{a0T7^T=XdqzID+J>oGSwl#lp{`kpVh~6*uRKT#wcEA%lOB~yu)Jkw0-V^R} z^>0p_Am~=&OQ}sogpwe)YO4_-UUXJLHa9)Uv1}1G))1b!@|D?1C*3uoB=CuP&R_0x zzJ+Osc&_*|T=_w?A`|b|!)re|z9?D=m(9ku7lUt?88(#J=KQX}9x9W+@+0C5uuKN~ zeqL1?3{trcWI={rE0lPSyulR(EvhA0XtL;x#37L=C8KQ8p-|kHlr}rUibS)Z`)*@r zW-JXVJ&Y9CO6I9z4s7%E6K!%>EK=8b)rIPQlyhNoS~~oGG3KrR;9Jnv($j5G?YN;< zuu|OJLf&|waZeSa*n*=sMXDqf4{#(oAjbM+39)a~R`u@qdWiL}365x-ZX+qw(`(by zz2+;Dx+e=pWW++e+sl6`liwN`8ltJl3^o`@x~a(G4}lwPr-4Yk&pq)L4ZT+9E=(V( z?#aIW5g2eO7<842$330iJI(m+M_8&rsTUWVT`xFsGhj%Ll%qe<9JImD*g+&K4tcbn z=a~K*PpX5ItKTMA=(wvp3*}#8)$!COvi1n`BVj1mKMr{sUKEyuN0;TwKXtXN=4s>k z%vV)glu^^*j-|dS=eCSr&cQhwVS|vGWeCTd!@DE6233K?W}?LycIFX{<8Lb|`HMUq z8I|kr+l}^wli@r%UYfd&x4MLi@})KKd8#&LE4~ORPkzPzeb(d80QWR~ffwC8Yr4p? zBHbnxOyaBOvz!m7v-ZoK=k;?^>7!!hMu#&B^|U|Cs*Cyc^%(q1*bKJbUEHByP?RVY zd_b!)zuXlZT&n?{)KNgYIH(Qq!eB5Y>G+@gvYnzdc(5f^i{pt`0{f)6wdvTN^>bs{ zb!(Cp!W%ayut_V=zO;B>l`^y3W&E)zNko~#$5#H?&pp#d>^yTK)BLx=y)>NDXV;G^vu>p17YR zZe>#BtZZ1D1cYL^4j@=BcImd!?0g64BcL7Pdg&?>-SF8giE@HLs4*;taHD?sAWREe z2XT<1sI~}4&Y=00nnLCrbF;7BzwmbE!*bp3;Jt^r{>aBvmd1l(DpKHsTet*pO1fK# znUx21?&0H`rxDDuF$SY;#1{_}SZm)Nk1XVmA*Pac;cRd=twPktyGhR6((1&f0=*oK zQuq5zu&;TwXw_JhDUG|WT+LtG38wNz!a2zY!t@DErU*!r++_T*O1soY*u8ZH5I=xlIg{R?wBod5ET0JL8$C-1hUk5^UF#5N}Xrz zjY)Nag0QkjsZnBLx^3P})i{S~eqcs0rYzb-J#IT4W3iWMipE;wu49yv=zW|tB6cSb ztM(ej0^+w@@xsIk&P%C(Z2-k?B)Y zR{hW$JNfHJ?M@Mci;b^=mGL4ut+tL2$ghls8YKj2C9kAc1LbCeEbbg> z>JBa+)?P|PGzzbkJzPADMD;q*GShk9Z@u@LbWH+x=qdh0rT zT3Y;Cm~1#QEvzfj5O!1OtI6V6u$SUcDLbiv(qq*m*kSZ96S;z3o}KAOrOPIUIOKCC zvZRFN>>(in2YUB0Zm4H({eBvrqvDZU|7`%1p0d*S*4mgu#U%f?m=fzZQS+Mm6XF7m ziTSmpiX5S$8cj54Q~25k&fAPHYnqe<1bd5Xn!{1)LfqUUuY1!x^-92x5h`N|Ih!^) z!6jdgk4%vXeiMrqFEuW;-1eYIPn+qvsTjgB@?@(UG65NEDgkF}FK0ut_U-NZdIcuf z47x5k!i3R_Xvpzurw3(kr8q$+w?VTob%u`3Jx!<9Y5)!w6fi@k4~$-LYvw%R)AEt~QtP zHa2}OdKR02s&&>2wl#@5A=3|TzFfOFhp9>85LlPgD#fhopyoJ`%VWHHJ0ILXec|>X>xX!X)eSpsD@-V8GUX=AHUjyBB|V zu4?gQSx*i1+Dymf6$=rcq59l)Y?@4|(A(@<4y1%JqfDNjO`r&V3)!GEwcmpe-LAqu zC(j%_FtyV|LKp%qus$gX$8B`V{c(0K;vg>p~2V`Kaiu`rG2_~cMl*kj9~lwY8+g!Y(8moLVR^J)pD_6Fr^C zn3-l0)6nlW`9VFSuS%r;`E@vtTRZS&e;w@zJG+#sn}b{v$2;w5)%B>75AGFA-sVb_ zi#9((f*z>zEEEMI_3G4L41PAg=C47}aGYAOw<*Kr?0$rj1zlX*tqk3z-3hnTSPbV% z%#%YXID7f`a1driv*wR6lY@Z2{@R+>SBlD}FjJ)Mc%EHVqHtMg++UIP)>qWw%V>li zUdk6}@0aQu`C?pkeWATxv;v*k&PkKFn&}6>CL~bGG1|}yp^B;lH>7#Znf=vuLl)u~ zKJb@kY5Eh;O`3?gDQm$jd`+nzYjMLeMB$@dPpka88xIWSQ;S}~I|MfFGBC4!Q7XUI ztsg1ZOXKx|y7Ycp`ybj+f-{v`SQe`GkgLgGp6*(+VGlslzWzq`a#e(|vw(AxGYTAqcHXf^QuV$Ten%Xyw#a7F z+onA7e)(dtT3}s zqyFGJZ?a35MUk3UFNPb!70xA0+nlh$8ml#&9bUJ;gK?|`VM}nVH>+8Box;^i_DtT6 z@bWa(_^jP|P<5=g8XApT*23>iiY=jNL-$d7(+-PN2{6Q%fVZj85-V%N-C3pa97zF`}i7% zkAZ+J8BJG;%@~BL@pqd!CfWQr`6*O9u_MV=NJ zzdkj5RZ#0=9>fSXJHK*9d8yaUdobA48g1z)FN@eNe1^~Xabkxw2UrI=ToVM!T2%#- zl)gK;Y2cq`?8ae5sTOMUv1(sI*b!@+Lg*EdWXSWfd2jlI0KxUo%AZ(kQj|M2Y0`XP?8U}vFW=^KC+=!zqIqpwrIiRX zz+Gu8w?`AyA5t|cW4TB+4{E0z#6CN3tfSao$lr>$cedi4daqZLybO*;vS}4H@Ky*-xB(2L@adg>o#0RXu{!d!x~a!LyslgSo`{To_26> z1bTX^CWa7*IkkF9_CCHP=$HFaca&^?`liXCL4ewq7|PW-mGbNd^j#dr?N7@HYX%4m zWTVN_3-+!>d|RV&p`I^F@vV?K?j-=0vtHL z&1N(QHG%-Mir$&0zgB;TQI3)MJL4<2S90-ADmK$sKrHk?j@I60jJQceh4pY7g>%?- z$C}G-SoGVfIsfsu=iN+}Z_h>fGNXkf1|Ganha6Gzy$!CNAx>4hj;YKLRORP$j%rx$ zi;=fe+^uyt>f{jVu&#b4+;b%q@x0HOP~sP<=!;%in@q8Is!=MO694yKfc0kN2M&wY z6aOi1-QsxqB)&46V0A_dVxzw?T+7>`984+(dt4eCkrxV5-{sQ3n0mzh%6NMs?S>4g z>i$$G0Z*#GA1ZssU65a~daa(E)+J0lbg(>XF-xoO3A3Bm#{;{(A_#2=o>UyjlKoab z-`G>XH`nTs@xczn*xWjP{)KMktkY~%E9Li^rU`o!B3u{T*O|2e@gNA-QHibYaFPN@L7Hfaa|)+WwdmDIlq5M zoeUoK-J=WsQC=|Li1ct!$@Qagl> zhx>s(+ssU4av?rNK&<@Lf5<1hWOfVR{UiUnb{2zV`R;()0d{uL#673+nWBJnRX1H= ztSSFS%GOdX>1-z&VIN+2rw};K6`fDJ3ptY5os@mmut68l=jkNV zTV|lT?qBs##c5vIU>G-Fh|lj)P|jKBKDW(F&74Um%FlQPJx;KP)guYysXT{-PN;=e z_h+k0K913jszk?dh9VTBjWdo}%LCbChB%6f>JnI*WO%vbU5l?}sNjxwa)LDPFAHT= z3e+b!k?LS_oM*Cm72+MI%k4|e{`V#!s+eub$1cHCIz>iZE9v~~r;#$JGcxV+Viw0L zkEcpb<=J_Mre5K7MA*fq@d~zulnubg58p1P^mJhoxG@YxGxjKyAM^9|JGj~plfbP< zo`yLFmE5O4mOkrrebT`hGa2`nCbslKsq;t~_^bkx;HCQ;m*wM3ZQ$$*)tDxe@`%b~;^Q6Z6S>ATd81#&no@5zFgDsJpSDk;Z^6tJAyHizrbjclV`Da|l>0i7^Pc zMhf|oW~!#98U@{p)NGrcD5xixzI9PiVhBMsdofA;F8=*a4=OuK_$opXOx7?PqsE z3x^MACc48lu{s7qb+?Bb_rmFG!G~xDY_8fV72h{ zd|NcuI1{#C!BddhYC)CFVI*g&Y?7&m!K;jgjTyz$=9@kxeEW~?xcW<|$Au+y_?Z&< z-^?qQ`v^2aSCu zX9^Xgb@k-~ttvQ@9x7DZlgs76n=#1nX{o=X&~=Q0F#VMm#e*yG05%?1!2{M0opEa41b zuNV8j$x1Wt)MD<~qy1dsjQ}}=2J2}=&lvzMv&hOQ_=8j6qy8CyH6_XILgY+Rn`Ttj7MKm*m@a+w>Eu(4F3;% z?;X|Tw)Ks&x5~Bwf`|eFLP94Xy~CECgkq>dDAEFMqk3io1*Pp?*m zWt*W!j{i(7g?jvbbgaRJ)kl$<@86WyaHHN{Vaxvft)5HYb%z2yYKg?N7v@m3$_1ls zQh;X}*Hxk@wFHFCfM2;hl!^sKm)Fss?X*_?Ev<27yUAd@AI^FVGm0kCC^y(@<@UW4C!g*u7hezp!%R=q-$BSK!ymkoxm$cO}I;1!< zVjqNW)$mIkhtMH1+2?Jz3UL%l6JN&MG8mK+W^%{wGkDebzKSJ%~90x0%@(~ z3j60#-fX9xk{tE9a8b&Y^!yREX-!zAd!4AzTx!t)cikTt=%~c5JQKU;C`Ighr!ZN+ zsAZg|Flz1!g6gafbJvV8o6p?sz|{;oY%OA|-f~#=l_f?uvP%KyqGtBlG(#G`6GY$1 z8PVa*{#k7YM~Su>aO6t4i3%`T0;f%bJLlC&dv8Udi6IF-@T~~D!Cioe$dSHP_5_=L z-uwQQGUZ+rsKd_}Z3U06opjUVb&dYPS-dDJ#iPduV;a1cbr=;kvSfpi6IoJ~2M(Z5 zT$O9Z*m_=Ow!B5qd>B%1Gbz9cY{XzNpgLM&lp=3_0**IW- zt_wj0vFapegn5`2_VBm+?#n5U*>>}qRwV4>^qdts%urrol~$XE91@1tqvM1j8G$#V z_2F?wsb-1a`6q6z?C;k&DIDDwk&tb1ekn zR7m#6q}E7m$-C`#Ss%Y)-Lou>mD38P7vHi#W{0ON9B9nlWerk9((e0@u|uh7cvD+F zGC$MED+p{u^zX?CTQKg-ov^_6Gqr}9hcAHZ-$XX-OD(>ZDx8{Me^erCOnJUJliH|?7s@LJDPfR_$+}zF@=r27&Gal`~)A}hHw7F%WgZZVV zlRzBF$C@i57o0?+nE~c!R#fHFz+_t-nb>+O9x*gFGLg?_aUa^$mhTx~U95l8@ z8h#!^vg&v#!ZrA+{I1GhWcjU~AWx}84jXF9E z3d6WNJ-SW{&C=_JZ!JKC+4Bb#XGd4s?ga6IqD&`}lhwiu&2PCHLjU0L7k{pat<%OA zBzhFIfE|)Z-Wa@qsLO8!It3*uyywS3#u_$Klkf?Qc~k)|%HKgnO}+RKxKRF4FCkBm z`aDDb1f8j4y)Kp1kO#)9=C=Gq?ZSF8?^9t*yLcb>fdrG;Mw@Z#oz|G4%$|9Nh|q{B zx^RxZsggmT^mg(1@>h}*kix9!Tv-EZ0egGVFbXi@D>M30965F$Sv6@z4@5Hc^s6-} zIyxgcw!u5-R9X}sS~RsUO3z$~Fp5d`0uKpgP~qMh>5pw#R@po&+vPTI+3Q(?mpVe@ z>uFI#O%u@Iq{fCQ?XjW0lK2}?q^N!n_3=~!J5<9O=gV}E7?;#AFEXvy>ooH0&I1!V zLiD)EWN?$F$j;+k4{fdN6%p!`kivZ*h--P$H^)WL4-r1Q9t!pnbEt{0Cp%BZ;{HH7 zBG)uOghuR1AAT$m%Q>w7!QIJDuf@6zS3lKGZh7ebdNPQJVaGAEAir zd+2l_uEjvYu`t*0KmuE8Ji44Fr)xmpJ&S7HeCB?UQQ;1yY`@G|qa09a+PkIcA=D|b zm-A6ZL{xX;h67g&LK7G<+@+*s8BW$^pX4nruH`j!!N-9lNTnalL?o747>XRIGGm$! zwj8T>GDBDI7&hmQD#&5so(KCNcSiNNd^*|Jv+(Ay8Rh26h89b%k;P{Ll>*X^372$?}?KVyf+UL=+HMQWN!(|mu0Wr zaR5W}*DN@14ja*&CvYaXu^r;J9|n)D&gPe|)bE4+nR-g)TU0K3S`4wJHD~ z!P)DwLWsVpQeD3b^Z>Z_Uk7-fcj2i{Qag8rA0w2MiCTG49R8Sai%2Hd^nj-DqDJ*-qK7vWI zzh%E)YPdr}7}^jNq?9y&v8S%ReBA2A+PTXyIb$IDAYZXkk+k*GKOWpwpA+I7^}WTr zU}eN*@aKcZ%(f){$WI7;ja|8e^*0IKfsAuz)7I1A0U#0Av{m=$#8r`6s^N!`v0O{m z!isK6A7L(sffsKa4nAT=&ngD2#pn;`_dF4G*OZC0o!S=0(5Bl))XLyAcmzOpvRbwH zy$3I@mmZPQwvwk5MHOdDk(QSguEVCNPhs^ln5-)wLlwyc(<-`aVzBL1sIhHKr*&$4 z3_^4Y*Uf5W2atbqvSueMW9Plw`JI_|s4#?~pN@dqMCy+EtYWaI^zYnx4BPBECRNPP z5dXxP`cb0~kd!I@)VHv?3a^oG3rRmgqRFQd^S_Vlx#xAGcK2crm;$;Q4LB7px&uNp z6!{el*5ftrmxxm5;}z->cKiuOcHZAoS)TGgsxBewtr;XAHE(lo>*NS^4z=>Ir6Kd* zXT#`$QzZ)v@Y=O(h3Z%MKuC$Ho6HbNKLEI5^-W^;4Z0(xuT+=?K)o-kdmggoY_8`= z+)i64vaYJ^8#=?JQ)3gD3;k<1#QhPgOS0Ye0F+_2!xR9`*5G}#ZOL9pGsn&&w!~eC z$R)W2bsq|Y-1i?e+;B3Tk=wt_7?4oqg3dP9%J_Fl)TddU)9qIG-kYXJz1X#d+f1v9 z7O3NJBXrw5kmOdG2s@CM+xqC9_vjmAi~i+#bVcq8YRB?}B^wuoq-d`Zdhf0F>S>n= zEMoxtBuiLxGP`If#Pzqd8OBeSX{iBD-&8$T&Y6pk4;~Np*qhHi&0p+HeXt$;X`M~= z6-sSUr1%#ZPu{qSl1i3*KHHV~$kw-n?wc2~s$QzxF$&zMqUzxLl(mD7T=BIy`1pk^ zSVc`PC|xrCHco%qje|$K&imP7E{Z2FEPx-eA$)88cDVjgwt-)rL_JMD=(_EcQsPGL z@fj5(za&AuERmj?PrKd)QT0ZYmB9;QrWjgt^oy*2D{*taJX>w~vk<|KxooDHcA|P> znQ7uoxvhOAmCCLB(G1aqxHwr)b9N1<{Hl`*;(;nnNBU)mC*pZ@?10VKZLD3kO=0Lm zu)o5_FEZu(yN@yZMWblsu;a zr~auNLoKdWMaY#ZdWvq>+quO=hz3xz$a4jhuUaJP#yr(x^8t&x@pTz%aCB%WinfqX z;JKzT?6_$qbin{P-B1~4yaj&02+&_=;aBqLBCpC8+rI{^H}NGcF6~s_Go3O*1f5Vr z+LUGW&=bjNkMTWghR(03=b{390CWXYi7%+^WoJBJ#4zsOuiyS9IOWhvS1KJd^vHA>Kc zEhgP(5{A~>lZxX$dFRo9q2jhusn&iD0gxhX?oFat^2wzWEFOOAVxJp|V!M75#?DhM zLOxiMQnWZ8ni60{<%L!$$S(B7+&&RD{oUD#aWj@MSAJg4cWdWqq1*^UQ#1JZtvV;VT=?g+iC zRK2z8pT(Id$qS8gh%#Z&u~}zsIs!btdBbbw=HaOe&e2lNG-0;kxQ95xqdz7HGx`A! zPJHL7i3Za+OwZf;OrK#c{+LQgX<{xhV8yO1d?Ot4Kgfx=+qUNKFz-WAmgHE`T)=A= z-b|$k7`y5*O&I#{^m+X#qV@8Dgj<}Dmbkr*3F zn=If^UQ_0O4s2=#x3-)L@5wG|F`<~Bu1R*U@1{H&c-OHxJzybOmw##lDiA^SYjT#k zkL8KJKl`Gs>wBSEAoUwO-9$j-vPi3;V6V%XDf5!8$N2Ew?*m*+AKBZ3u04n`G$Uu) zeQxAySsJ-|z>pQ|42-xVd4~-w;pdrGpGH6>#)(|1c3SED`PQ7C&==XC3;{B4^#w0j zc|=0+;_CAY-W<}*bLPQuVY;qhu|@@11N8Ae5Y6yZ-K?Z>ha#oYT{Dt@`$7wDxWc8m%5)@ zTSHA4Apgw%<15ET4Vf=<6%PdY7XY=Y!Cp&js4vXzAIc7;!t27A(mWnaO(5!s2CT)1 zqmsni45X9NXcm*lHkjWU_X%Lgt5bB`aHXos5-7iBIayB_NHc)>`e% zfY3~Wn_v%_GSAP7j4p9*(D-Z>eLKC&oej5ZksFX-WMQe)4o;XvdbCts21*l0ojU!_ zxJEk~U2xw@jST?ucwFV^ve`&wRrY?}OJ-PHC8nl)@fVqnz)||1$S<;_(pSq;txfz#@L%u8637c?%kigu6L_taWRw} zk>}R7w{?LO+Fm~GNcYLZ$m91FR0p3v z_(@?#aELt=) zJN*URj%dP+Tp#|=6#0Mu^qN$7zqrRn9vfKASx0VI{NBgkns{ch zdJVB#cpq7WbdpuIVTDj)G@JSi!a%sl16%{fY{cL!VN& zThNedC4^?%)iudTE-W+&D{Qh;^0kxXu}E9SIo03S@tJNtu-CP+6J{f-_(zS&laYu2 zmr+|AlF%@UF6r7&2!bkl?=A*{sz^D%8c!$_KlD%Q9hj7t1AFr&<~1@SQ!|vlZGXaI z^ULB~wKQmzi$J16tWv3+dx0HF{WNoNuC9m?4Sc8b{Tuokb;%-D#0QlzDhLwl4#MbO zxvf!#{#r4i0?ZtGJ&#QYAOzSKP}95MTE3yrC!+b#qAE*$?m4}Q{n6)h?#xbL3*mj! zDv_SogkrKt=a@xcQpCRVaF>B0qReuy2KF*#DZ_K|by4e}E-umvjrUPXLat#L;!KEJ zijh={z4cB-^(OAe6yl(Up@HYAxQKE86s=j^Fg0t|Mbd7hMTksG3R zyc`)j-|OQB7O@9rK#bFT-ke|96vdIWtbhp+J0SI4wu{b+`OGdFS z-rGIhaTs8L-AYu5fdtg%>Dl>Wc>rU5jj{IK>O$!mcMGe^%D=$NB|6rAm2* z^dQHEw=kBZ7!osC7q6}T=WH<~<+2G2vw`mD>@j$ChB>2oSzi4YcKUn6UW?#1G~T5* zjPi3|&(o_SCX?KQ+hQYJJ zTKJyl(+M{U#EuwE^PMswFkOaPc;lAjrNlnFHM{O^<3#*v1~k6&9w5ih2wH0I8Uxi5 zhHJVC2tLgB_jqJbZswQAB0_S&_H8u(9AEipG-!As<;EoBRw$T0;2NQVH(0F^)Zcq> z?PqPt_I0Jgq-vObZB)OkWMEb82Pw75ucNt6&Yiwt&Mf_I^ctB!~ zsXW+CUE1m$rp&v2I{3xiawAfK|99A=xoKm-LA3wa&eICxwsRFZ+u#4)`fob?|BxSm zhM|vJlrw$02h_7Kr$iBGh}fwTz9B1Y=yalVhLl7$|DW>X)62s(*!@&GoW|HynD+S! zqc$V30c^B}!!w6kkCwVeN7W#G|9l(}aVNG$2cd*8a$}P}WL`W;JefF0e!B}x)wt}`VC zQ-VoeNcNq1*vvIh0TkQJE|VfKZqqoofHV0!$^Hlc{t)>=4E%jUZA(Yt^n_<%5>rsB)mRTL=$te>31heTFBCZb8Krkay(w>K z+8?aRUxwF3mQT|F?*9R39MLkYaQC<|jhx!s054Vuen~ws>DdvCezhZP=qYJUE_sJ5 z%lL<(DDA48`hjD8|Aa7#{JSXN8Fj*u+@i>YPUn}N!7YeBL647R;_Hldn-@WALTj<; zGt=J(Pr2L4O#k)ZJP%)}g_(!;7N`#7O$(cw+}!9xQK}d<-v_E-gtXd`2~J<&M4g?aGJh#r~c1uC<{9(*yVAe_P;dl3tnBpz!l)3v{Q~YcX`=U9NiJ z%F0@2jLP>?T&vcmSg)9O zp{M1pcJc*BKScN#gH-}+1|wc1{UX!)h8;+18uAvyI__;Avamb;>kK@4&z;Eo_tzo& z>!HP~Yk#%b~s+nPKZ@(<)Mm<-D(sGBr|WMIJ8BZ zeg3OU$>squmEBHT|G7TCfMb+R7}X1wMppxb$KE`h*0`jW%w~ZuU|au{bq6+>sIU?+ zX#ggBlg17eE_Sc<3HU8+W+-JZ?Z=o(9mQlK*s;z2XwvrxOdoxb^-!?B;h{km(**2L zDuQm0XzlXCV~Pl(p;@3yGe;P45BurWqE!%i>m!iRbIbYGshU2GZTQ!%Pc`Q}pZ+c{ zzx_4%9{r3sr)H1!3fn8%LW93Qy|(?3W?mobg7&4i(l+5}sI{n3dcVTwk zCS%%z;r?PdeJnM#ujSc7cmgcaiXAtT@2!B#Wj&CBulfVD?Z&=g=)qY6!)JE>T&Zb z=nN|87b>~_jLdpmxiBg*;;^qH9&Rk1rz1+uIo*-Kq+I}Y$*_AS+K+^)ahkw*YwSGM zd~ahNoAi17tcT~EtvrB<{?a>_k$Hd7;IDy6uQ^3eO0*(O#KRkPugrDnwchm7G?f*~ z9QQzk+#@u_l;&tONNNm&%%M-4pLK_HyGZpDY7SV{3ZOnZX)QMPcG5UXBvaTp$hYY& z6wU&G(Uq{RDcQ^xrkY?|6L)KvJh3}Y1(n270pvGphrf9BFH+Ds{tQXr)`NlFQYA+0 zeG*1`N_j0lass&fM#P}#-Ja^%>6x1+K&)>uDe-rZig^ngjv`x?4LkhZB>E4BwbhN& zm}kncIS$^69w0Ewbi-^%KNy1naUiE;dZq02bnqI}Qag?FG}02jz01j+|0*(u{^B{n zmN)Ed8}Jvjr>l|uIP8wyWwkrP05}^tsNoC-&44*-7V^;{w+s%C;Qq%!)!xy zSg1jbaq%c#9ay(lcd*I|wfLj$SFtnQnf4|E)BjBKvYE+w~VVwwmM!F$i@;98h7y2c;!gEmeR_d8J2 zs#;T!^b%bY?%1dM^VmT?DZxYia$;_|=`EL`OAEseb9EmrhWo8Ze*Yx!22z$v>*-0~ zcYc%^P0>3?=Qr>U+?!x&Hb^=<>rGe!%_%@u2b)p?Qp0apNv~5umKa_3zR(w#8pQYZ zec`kcX08hEIR2A|oq8*Mzx zb@O)529mjZ(+%yG1J%&AJHAc;ez5G#9^EGERp)PY-*xM}ha%CsC5i8rIMZ9KUsGD< zkkA6wlHBaWym&ZA?QN!j;iDRAaqmFT8WV9JJoUiHZ-;cN%=nhDFu1&){whvA9yZie zfd8pmD~GWCnG)>h|DCcu>m;Y=$UgeWm@Abp_xsPtX>O?rq>iJ4BiKDPuP8Tqs4l@y zbyFn5qZe|(w3exW9sQ)QVP+GpUh7#GQp{H(iw5@jY{bk+9qs6vIK?Kg`$cdQ5qv*y z7sTv04ai#8HGuOR#Kd7IQbUloSd&CeVyHkq*22uBi19 zfmU*1W)CIx$LQ4v+B30+9zk<2GcznW5CBIf(`X>j$5=Sj1Uq?hOi9=T@0sdu-}Yr9 zu(fv-R_f){83}V^InE`NoVF(~$gL_y_Kwu7zM92kaxCx5F5VcZ=3USb8>|X36iX4x zZ%I~#!ZYF*g3B4dC(xuMgE)dyK|YUN>+(FB@GC4roZMP+i5p$w;Q=&9rYv7CAGEow zzaSbz_=bpV>kHlNZ|qP9$goXHpp|0`8l*RzWh`oi4D~ z5tdyXYu`5s0dM)T2r8CxXWg zPQqy_v|CFInKBC#W%!vkBGvK(Kq-S6ed6LKUR#+=&Mauj<6sY*Xz5f>Pl|YeF zqP3Q22~VaQQVPJ1S|8;U-1NAle}4N8dfBd8mL~WVyDP>uxzg7!wIf;^!1dLM!NTKP zpOjkPCKDBNP;pCwptdrsduvUu}`mozce7C>2gd1-EBD3oGew^D*S5<;J zOYwft1^Fh}T& zye5kFQS5o?KdVBlp9vfAbYCv%Nq()d~)ZeYYD7fm>Vsg(&Xd9 zl~dCioJsXMB{%2U966NxPL3y0MOVXC)ud;% zaj=9=YDxFeKi-aiDHrBoqhxxv;5~di#gQkgomH^TOv=6vkNE>EZ5((eoA{`lKhGFB zhI=HzPvev~;8y0*h|qL0onV_qillp{3JX0BCO1mUENTKD1t&Qvh7~zbZheCETMKnC zHc+x1+!v$u=IOl5RxoGf@&1I`@5;7JVIL-M$npyicsaTZLP{P(da3Sn zum4eQ-T7%T!ccm8t9nMkaDT!n6F=$tnI6n1O1|S%xM=LRI;}|z;B~#$DLq-{$08He z64`V0^uWt+!=o@O^y5#R5>W?XXPnsi-g@(hNH;+kDD4pX(S^svWdFLqD_ryGa{WTt zDWeAabfC|THb^R@G7g?rJfOa-S4_YC??%QNPnGrNA>-u)huZ@DL={!L(7I~Fp~My=Yc~8 z2asL#C@FiolLc>(N#4xw#DgSxw} zOVEBWyf0sz_1hw>=iMJ^~7SLY@g zm>EqP-j%Sv^_-}vKJfDUFx-DErbO1WI4M^Ine*_;Zy^xfJwA{#I8LA+>n1g`M@DA* z>Tk^NE_WZ5Yj$hJsuC9ad|lM%E+TidqK_fK0S9VIeQrJ3LJ03if`59YY0hrP_?LpS z=EE(HP>m1nYI8m`$H3nA#tshnl;bqoQp5YZaVm)Ag9R(!lysMSzpFIKbJH>O7G^(7 zG@5TtGWQOJYFVsFvMcidLvxiA9%N%vF*{3;F+Z7PPE-MS4LN34>T7QEpu`a!rt$+4 z+gC6leaoa%vaxh&SF;+*Mogw=5OWl91l@J{Oe4D6?&IaMzdiL%vnS#>zE*3na3D z^O#8mk$LsLkmm_-()+etsv6}>mpqaVGX3n9G3YWB#NW?8|19NFjM_}eYk*J4yb%I- zE(q{R*J%mwzXO0V_{2#B2+YCtR^3gj z{;B??Rj5}PKp6Z!CMkggDMpQ9ZmAs}l+v7E$jvGXqVKI#!=K;kGay0hujOh)c*?cm zr!dsi#b}}iH<+t0DsA;oL9G>ZVLz!@g!{9_y5rvV!JUz%@>EIppFGw+xNZ;GMr*-D za$?MMmff8Mf*Mfllm3day^Ve%B)(cmXhw&5=%l8`B;-=3GFp3(wgJKGWKq!@pQgbh zcChKe3+*~=$QlF~WeOXDlyE-Cj#m%RaQvkc<$K}Cc5_8$ZgB6w^qpU%N^l-hh z3_f;{P-p_4Ja3}^8OcXwUmmR7r|^LcS5I?Y`qY}P)Yb2r(yOyQ9_k{lTgcDH}i|EmuTf1 z=)C*Llzb+@E$32LK9%8-sE(u5&($zCv*&XyU!D_Er`|W~;_f6o%)cO=Hu63Yzm&Vn zBW#YAKRbHONU_uiU!We6{k#3&boe(v{Ey1TY96Y|m}ltD?IJr1PDo(xwMEODFwv=M zHyQ{4MxDLte?d>3G7^w45x)Hx@rz7odFJQ+C{jt0Z`U&7)W$P0?1l8t8jeeWtT%`S z^jpf8s%b+7*?M005Lk}d-hlcT_Dx`MoYqz7mTnUoI~#ZB{t)l$ne&TzcQO35=y;w< zS3W*a#3KGg{FBSP zs5TZw@|kkJ6SRxU54t7TLYQF3R`%^#nc0Xm>h@JkixJGL=oA(`@=I}YRY6HG>MbST zhcDN^3D7k7#ads8Y~q|5kd)?qZczEA7ufac#q0X<-U!XLFRR;KoXHu~Fwrh9v<3+tTuI6avp1W!23gE` z$Ht<|#ORGloQF>vn4bw~6~|A4hd|tp>N3mpbs@53a|kA;=hxGt(JEG^j4#7oBt!|$U zf~HywoXMeKwTU+|QWz2;^NXw^{zMz=Sa|M*<+$Kju(|&_GNb5Z2g{qkfO^62chq?w z{leX5B}TWzoA9+%1qdIIcuLf5Wi7KDfY2lsMo1kZzmY+}A+kx7C?u}l zX)=9$BCVe+Dw2r}$cV-A7XkQhYz}-Jefrg?^Wz99zz131Hwz#-(|9aS?G6TJ_;^NV zzRmkYE$EPX`wAdRp?=Xx zh^+54cZ$6fe^~e7Wu-ct@*8?V6@ps%m_o4!dX`9I?AJwTw5HAD4-%4?LvW+h%Ip=X zFdLT3XF41mj5AA*84v;()ymQsk7_E3(JnH7i;LG8seK)0q!vceo9>}B61g;Up832cv(_TdOC6{DYd003Z5NJ#M0(E(cGOtE9WM2tRz zj?noCS3Elb45hggqwMnl`r&uZB$SLBN#LRh@YP0H z^;?Dy*s~>i6t0TL`U%GN9SH%`wX2V`5&PI4|!BrcgY8|bT6$|SVU8% z?|u^2U!#deXB&@d(MhR(Es1+8POJMhBSdbL)+wWr*gJ+@oP{`QHvEWO6vY;BlOq*w zhT}onM)$VA!v#*|1=h1C4602N-Ey_JwIs497TZ!J>1@Usr3+R#%wsI^u19@o>*(Ew zHcW4uaB-JYUS1f@mnvPW%KNfaMBPO$PvDi!06X%Oj35OgKRU30Cqbg z%7AG1k}l-8sE_kuiH=Jx8sgnA36H?(u?`VVqEkhIkw<3Eo=wP{vn`p)ruGnM(9nu^ zpwK5c-PqFRl-X({>;swSQ>U%38AIuX#DS8ki?1bU;zmPS5xfT>_7}yPrC?j%vc$RfQ%WoTcjfc*=tqQ6PE9El)Gu{7fRPqyI>ANH zmq{8_B%R=YRq=(1J8tE9wfAY~!n#gSC$3DNi83T$Jda!joH~?&QTyOBP=@giu|H$`Ac$QDGcRER8py2tm#NSN-EbUy~|en_YZl(o{I49!5d=4T_+i_yuv-8LE*lB?fo&t-fGkmowB8$9K`M@HHEAG`G|OaHs0u2-NEPNd_i~t!jJH zl_*Be63^_LEs}g`YR5)Xa^WBOBO3)CdZz}3jQV}pK0!Sup;x~!STmdALP#ZIZR?qg z?isx!!Ar}EFx~zV>o42iz%b1GOmcT2rerprcRlMiz;K$oUb_IeA+`fivdRnWSpb*n zkSzU=W-JzgD9xLnQ5yOec$1d}@QhV0tKbhCQO8_wkG86ffc zT|A#wjGJ#}eTmctHqGmOjfaP)WL0n_Vg+>AVA*-Pgk;Tklc?L6kuTreNLl`OB;dnS zZJ8VL&C9-E{U;}ZD@-X;r@N2xPxbKZeUJb!(4O@UkQ)ugaBI>eDOZM%)~5pKJ+l}Z zGN9bW$4@WGvpq0WIY6*}eg{QwV&CYX!p+J2Kom6#E3x_@Ey*DB+42F5q1u%(Z^-e8 z9$%EJ?a=QkSyN@SCusxLs8J0ts3wJESl#gtDZ#UZ9ltBIsE4lGm{ zxYfRu-DZ#hv$XepX-?gcmDCyWBJE1ri!m;Gb$D^2-fBT5-9x}B;oc11s$OG?7r6ru zzu11?J=z5M>~B~&P&pXoBjP~=#~R;hGWKb}9RzF9q)Egy_SftVjrrjL5D&Iv$7PXi z=+~ScZ`QYAr2>9hLd~d&U{`OZE~IyKf03N7Z52>zxzZtB_sVlggn0=28S=}xO=+J<2&U_$gN0}vx{%2XC&C~N4a{zZVEy;V-f&8Ogq_1=tVD<)X`kpdUJ&nXmW_l=z}}%a+}J zOVWIB4?JS^lf_eAeoRWU3xNv>X%l%g5t^!OLWVR+XxR~4$Yfvs{#S;#owvV7IcQRP zm!Z#=VI8rQ8-6(o81 z$v%Em#n?g}&=M8h%KK33w&nc!)NS|GlGSpB$NE3qKV7WZ&M0j5`E3}>d@OZ6;Hi&s zK~r1}aack6bou(zwg(;8E6P%Kv^P!}D7e5RG_IzO85k56fS6=SF(Z;zDKlD z)%VXlJ2ykab!U0}dBF*sT+zww5qA_LXX^E+cPuHib{yBKAMd!0?=8PE?9#InGZkwm z>9aO}sjbb9nZra3+QOA*gJ?V&B9_1U!f3D%s+1L7x28Qy>`StrT9Jh+O!NzO`f~9! zKJ{KU^&V2sI$0)UhigAy$r4g3Ub70$@4G*5UqsJ zd>rs+Yja=Rz|X@FO8@k8joE6G=o>EouY&=yw=0}JcVe?{^qqRzON(dgNE3a*K~s}q;y9p&BaAtuJl`^K^j75(<>APi z3YL$SCLii@TLPT5;<0cWFR5WYtk_KL6pORoN%A!PkNdROk4p2(t@lEsL#}i<(kIRVw0`bo!3W&4tvJgWy77 zCtvGp4IO$NzRi}j!@T!>F}<|D?Ccf%2*Ex3HQiarTTl%`*G6}67;~&ZKtKczgr1U1 z{NroB`xY`1MgKO?o&SLq@f`QUDBB^WI0o7Jv%+&lRcLIe@dAH0pSR(~|IRNU54b@Q ztK;>1cJBRY{lrg7wpD;9)2k!Jedyo>PYfo3Dk|M)_Oy0jP$KqYW#~>?0{uz4F|sQ= z5<+qiMgJE1kG+daWIy!Fnrk$Pog8e+o~MKuduuR8K{fIxSB9d7&;a45&h(XLYlt28qGO_f62T3`+7s^Ekf>97iWihm{;?0Te>LqT{EO+2 z%)K$C%;gu^-Hxf>(+jNzOE$~a2W#Fe;19T~E!k^H3M2__Nqq!TpT=p`XlTXw{Osr^ zC2_3ySM7H>kNu=0EwR1r=YG`UTchFTjp`0~FWAn3>+EpPmm4?MEBwqy7Vxxhb>`9V zG4!(n^4i^oQJslr`S~#)e?P2{pMBB(^-3#+S#Rqe85wPp4HavW@%d_4*st!z$i|@}6vsYsS*4 z98NG3{qJjR1oA0RN}dD)$HZP&NQ!?nN1v`)3LK_<1&eRrI_~8brR=w{-aRvDXE^>5 zLNb3)bzPXRng&%?1ucJj#|Lguj7!xub{c&z3uvB-7$rPW98*X*o8DX zbv8?MwwZV~a}NYzVPk6ju86M|rk^VY(94qJ}eIr=cI zF;H`A(a~NfR2fxH%RoAxisC$~7&+n#^J&)^q-rm? zfjry{?j%jK;wTq$ z=5}M}?K4dMHLf~-7W3X-{T-e~L<&em)l6NqVObuytNYekXec7rNPB55M+JwS>>249 zWLb`dV*2xX53kkuv3KXps`!=zx5k-#g~Kd5Z=!ecX)aFO{TG|3PBEE2vQ?|N(V z*0C_G2_j2;hukH~G^_gB%{k5XDr=WK{Nvsao7W>H@E=men){+MMn9ePQtw#0eU~if zjo-Mf)M9qejzh3JY^(G|7>rIzdiJ@s0M^R_TK)`IIMvt*+uhs{dBk}yXJg1Emwj6F zEv}`G{~5zYPgTj;W1oio_Jw?iB3LYj987fF7aqfl-oAbj&C7(@WYwe@e5! zzD;``nsg=*_ogzjGo(j_H|ATi98-C>q|i$hNk}u-hE;*a{0wdR?s&DOXU5Ga#Q57Z zt+F9Ix2uOAugpsZVp*o(di+er`s{^+YG9qL$)vC| z`S@+*y(3TiqnrBiIrHVf8$!uqN>I*wp>%l$FOI1C$EC16x*fsi-xurUO3Y>FoLcbp632&v z4!33LM9x6+p=We){RuGI%~nt?#Mny@I6j?&+{qZ{hCLLXjDyt=3G3Mmb=Vz>5E2+) zJ89aK;KD!F3)(vCfa{3r(X~f9vsqe5x*{AISpiD;+|CZ>E*Y@zYw82Y7Vp^d(s zCM~D`8zKEFh*I4_F7%JLd%P@fRvp@o$NFc6lIBiF>e;f;4Sg%|YKldqsX z89k^MUsp)bI(L6B%8O!9^wfH+6JYC+Pcs^1x=}nyP*PG-exIi(l)e)vjbs=ow>&)~ zCCCc%2n|z;RX}u&`?}2fYxKSydF^bX`6u)oLFVPoe$p(;kdb3lx=3@A+7Uod3&mQ; zpOcu7>CrTI$JzBUsAL=oPGHFle=+D^Qu?&>$;Zl`uhW;L-87?2Z2vd!E3VN-hmU%8QJBK%30SI$Annbcmaj?JE|DbCvc4_jwy%y zP`!fafnMhQ3|ea^HX`2j;Bc1EL#WvgxtC=3=!A1>l4e0|hTVK`{O}2aknS22HW~Lm zA3E)R*sKwQHUTQu?<84RP}m{=bl;Gc$g>azTIG;hM1UEed>9zGo3S&^yt9u<>O%7B zdQo&G=I3&{%R-N`T_>N%-!3eUQvKANtrnqlcwzPk9%wvK{K)-nWm=e9txPqEemUYX z^xG9keJac~p~>{!7fL|qW=8Cw!n$3aMxhF35zCA?VP{%i^AB1x3O9FSP0uJ3n zU(wo!7g^xrvo1KEWiEQhSK6!Y%_~}M>`{o5is5zQ#c~0b)fRCh@x~udqtOPT_aeJL z4@8WWv4ZwqUJd^UiI=xLOt@|)Ktv_YpY?gXanQ4@>~^3Rix69}R8nCpJk9G0>0N*( zo=wP?JcO|jz~+oZsTyA2s#yorWsWZ%zRdQy)A7Pe;-zW0IptwSX%}{IK5KkTS8CRh zSv{5_ocJN+^Y|<{PY4VJ8%EU9M)*tKpEYqJ0an%J)-;PgkN4tHAA}6Cy2$1GFWC;` zWM2Yw_E8s>2hANr{W(&UU)l?|a=v0qW4>zSy=P~r3RDK8B4&juT-WLw6+W-TgTb5; z;jzIFK+hQHNX1szeFh943N-b=bAmO(rvv~YqscJGVL5xiimZXjn!$WRMoLD?4rgo+ zN)-@?LiB0Zr3y3R|FN1IJnkso%|_S+r>7z@&r?e=jn`@JE!X=i{&GGr!?yd$OQo>HHn{9DDP=4&k};8SVN<9$q%A^vWR)+^;d1JnlL~b`BeM^Y8EK8; z$rHld-1c0WDzJ7ltLw5!=R}*bK@)00y5B@RrA2tb4~Hh|%Pp){|2(xWM`pDH z@U4`S=}w%Mf2fPJ4&QMJcM zR-N$N+$qs6ZDU+Y)%8_rxO4QD14r;Tk4&=SI>*NicZVSDd&;A^{38$vq4SnaLlj1a*tRJt4S%5FGXxl$bxtq=fugx`t#TA0q1VYHqFxJ zK%ffe+KL-G6~KA=iql^*)yb|dp(aNbZ;@Nbkwsw2=*C+#1#9gX85rp_6vtcSOx0%Y z(mCVP{N~ou+TkS)Pq4EUx~s_atBtR&KT&+ADa}GyTA@-LIJ>!{RDweLs0TlbMkt~| zARy`IKu@A4V=fP!A=!{=A|mWx|2%OMJJ=@3^$1)^P-r!1XiehSK02|<+ltPc#0_Jt zD^CB6A-e7UFXv`L0T@4jaDuMum{W>sD^s0_VFKK3tO3v!sW=xYfgf450q(HQmQW~V z#U79Khb(1X0e=&>CuPLFtI zQA-2Jt??p2?PF}LN*k_C8Q4!4>9x&->sD-wn9au)%SGT(HflLTxiKvSnuD`mk$&7P zTp4YZFJm4@015>hm5O*bPX(v2FmajD4&Mpd)>HI7aLg|3TmHuVh&$m_TEA zwOrDBskJr(047MfChr4PXQ`ZqR*FJ)t<6}Wk7eB)P)eFz_Qw%wU65r@@ z3)akQSUi)&{SdwXHMk_HZ*j^GNygM~9=u#?#a8b+vnX7ZJM z>Zy*&BJ=9-QM1EOg;V4ca_WVSV#~25>QX0BV%g(O@eZ>eq{-#Qe?BD9#^D?x%^&p<Sv%$!(<_1E;kxn;P;<)O16~K|$oZ4vsDOr5)fQ#;8M}~} zkI?y7NbGoGQ@yn~%t#ofX8Rz{2c7q`kTw|T5I8vewdsgZMs0&dyZy9}$!}q*#%7jM-m8HmB8LZtj ztQXeNtQW}`5d^}|Mzn;Qr1qZYwKQmxwyxsB{+*e`fZc$w%ZrS5jPXWiTtWsWv7 ziZKD;_Lc|u7PeKZi*Tw|83#ec96hbibN6+cbG;%S`R4>baC!FA#un(@Q&IWsNBVaz z<)~bFH*!PG!Z9ET(xr$`AuHc_j2^31qAxtDo&eo;(q+VFDqE`DvTWU&3XXRdaJZi1 z39;^=Of1A(-Ze;PNo!f(T*sVA_`xcjw)2^HlTKZ}ny%KXsz7WzE%i2d?sK8nI)vvP znb*2^pMj|p72{&!0F1d_Oe}QB!sUIzG0W2x0BPe6}Y5Q53LK;<{KPe~2TCHu<3TO@Y z!O+@T-a3+9E_PZgp7WeGu_oR|MYV=3$y#VE%5XNm*=$23-d?S2RLpTyl2Yp~)-i|u zamr2#$A0Npnw}Z-80J(}_4b*Tv&T3A_7)5e4ZKj8DRes-E4FlU>V4+RCmb{# z@wh_0v<>%&0LI#DH`wlu+m%?|EJ}@b1O}i5$I&=#KEvuU+baW6GA(V##RNj?{p%-G zjVxOiZ9Wd)Uw*hwgr4Y0YuOTi7f~e}K%KMZJ*tZ1e;!^65-u`PPpgvlr|=U0_H11d z(IxE|Bk3NE*If%W3b%`Q&ruQaXR*lSO#BcN&GY$Gm*r%W)-92#wZc)k5E5~ATn#Y2 zHF_y?c$^tVuNtl34>El4;k}H@_pd5iM-(2>GV*9$KEx>Xc~m2Rj^dvlynO8CPF|Du z4y4W+cGK+h^v-2D&WS+~epa(Ip(4*Dw%wiq2b>YHXiDaVfB2RagO^W&9U;VX%c6l* z=Nm}iF~8ahfYq(aY3EagGaj$aUA2@pcAqPd#wMY5lDvw4h%H3}D#$$M%omD6NeO+~ zcBG_wV5YbBBd$WCAhMnvUm;|z@fkbSGY?=!SOXYCcPVEEUZ^#ZXJ&-myfDk`tCsB? zwlOjuZ5s?ldIz!$q;rpOv9l=;QT=Jee*o6CQ_#ZP^ z==cw?M{!Zkf+OP}S*KoU18Zv4>ub=StMBP%z)fd!)OFaucsmZF#F8ErRVw|AWWONa zIzH~A4OI>5FHuQ_g~r$zH*lDN<&AIvYAZoH!?+xd$938o5(r!Gm_D%O!KzalHfoAp zGEpi3Y@mD10=b&w8v_bI&rZ6G;%X+KOS=vD4`pC?;3T+;jIqPM+}n%lTP|U%i5@px zw%*n^rZMYOdV3t%S;RqoEOou~!NRPXm^}Oy#&fu;YO}EYQv7f#bg&H*wP^C?-Su2u zz5C1~^pv=hvDeeWxt^Gnndw|6q3;^;=IO%R+uAVK@m(C=i6K>kzcm<(Eq+&<>@F)N zQdIKMRK^xVFZHHEvJa8l8HAlYjkDpkkOo-WSJPRj^G^DNDrC`Mo^%a+0hZ_4L(vv9 zz=gCXv)i_G(I&62z=AM#Gh@xF-I7APSRqyZ6xDN^bfIyZ0_^v(Mq{l<_Qf28*Z@N7 z3qwCPN>nM-=|^8BeIVmqNmK&Fw1AL(#JzAUkan;tmW)dgk>{nRXjK?ROH-v+&F`gw z)0ihBdh#z?^MA9H{&?EL>KA-i`{a4=o zD^a4yw`Hb%d&+7~RD>jEL#;L4VqoWm#G=I=F^Ud@au(E}U% zQnS|L1$UZ6fX3XdfUax8T&o#~R36WTvsb+} zI0V`(fh>;0w0Z(%zhymAwL^Ox5;E*4D6OIQgoJ%;R`2FF5r5I5#`a9lZu~WuOG7wa zsNy>Bnt`f9_{auc-kHaF1g*k^%qTO5u<(tOkzMq3m+(Vm0Hag%2nSY~Uk<%q3=y~= zj2Pqkdx(oJPi0}jrtzHPM^%D?K+@#ZFYMgHMYmm^Mq(mp&MNh**?LQ{?o1Jt5uTEGW;WX zNj@ES(Rx?r`)`y|Qq}f_0t>0PU3>d{gQwwqBG!q!g)Bm1rAQV+gr z>34gj;2x!P*;;-z-8xW+Ichpl&r+889N9jvSfa*(9RvR9@)woywaNOK_q6*vqT6@8 zl~U(dlYb~EneZkT_s}4u!`bnaQ_T736U(}uK?xwSG}N**g^5|XWLvTWhij6kPfM@~ z42h42Ra_yA*x@l?$Y7B(X^Fi&8W=v0=98Bfw|>a1-^a^$f9xIso3pjPx+40iTo?Q6 zF`Y@BZp0syGOMTZC5#sOeaf4`j$A1%T15@)!)g|VtRlCLZ+PuuQ$>Korh(w5eurZs10Wp|$ z8AiYROIdc?e^=LKP6jYd(gp7Ixi83p191FdQOL35H z8aM;J49mdK07V~b8wMy=_Oq=+EA(~TDa)~flVa^8H8mqEsxdnh_=t#*a5GQtX51xh2RI`h&lM1*<73BTw zh@Pr7g~G@TbG)d?;N#+4mG^oJuE*6#oXzC(EEk_+Lx?gM*gjTuK}`UYUOIlZsQJpsggLtZW$tj~#b<~shrsgI;TwYzoM zc@&dzZO%iKS*3-c(XyM@2q{Hv)e~HGuuyE%s#I_&$L4Id^vh$q9Lw>V!+|KXZtXS+ z@imcodAG3HJ7JN!v0uCGWADM(j!n=zsrDo(!$?dD1-w?0lq?^G?46q5Nx-M3r4`8G8mEH58b7}|C zYPqRvd0kE-Up8Tde(Bl(*HlP^zROZv^%1L^G>J!^t~un^PRb^`fdO|cZ&zK&W%e-p z;Lt|wL|tj#V5qQlvp88qk{891P*ygCWHoNqPx6@AoeFa6Ok;;w@sc%dL>`%vdFs_RWunB~3CVZ4G^XF#w3!eZpAd!q5@paru_%njg)rXo z&_jJ5BW>P%tG!bhp~L-7DXX6|_3|J;He`|fdaDc12jr8&#*p!r+ndMXN!B}f zhuo3~e^*qPSBt*pXIv4T%DU>QLtd{`59y+L6()~SW@o>R;>=m3!mOflKug6W^q8DY=d~bFEWPny#MDw-NI374Hla65_<|7k7A!6pYk`4jO?rGwW<2W3M9@uJrG*ihF{fB~6GX2VE+Nk`5hVbk2bHJw&pZ+t z`a_$m0)i(|Clg9|8mj3C5(KJ-R~i|+kG>2^6YDN1r8^iL>+QN5;?mxeFufS?F(G&!bI=@f|85mdK1ZDYk@UzxA#cWuz9T znhg%#|M8cX^r7^P4B}W|gKyV5GHe?*_=`qOu(i7b)0h`!RaX}(n{|d8H?dB}FmOZ7=o@z+ z^xdB4#F&}ZD>tbi+E}tllJV3vux1jxH02^7C_fiVJJ8TjkE-<}6@CZ)x(2q!9F+Be zQR6)-YH$r%17*C`T-grkM6kwq2{tHj%)0m;5z&_6on^37 zW?1}m<5=UE-gkZOp@FeN*?@i<)OobAF~nLY<#@cOX+_s^6wa2~c~$$cYfUzlHZjm!2PGqgnffEfD5({LKX&1nK%cSf7p!dV(T2-h%X+h4Rf__FoFOF8oQcMM{ zf*dd85hX_}2E_p%U0o6{>f8tqW>QL#_4bVnEA|y&4n?$m zK`5KCiV)vOVTaxJQZ;(xLP0d$$h~o^zAg7uXL!16!>E=KYv^&bm=;oAYF28PtX6=p zkLc!*=zg|_PzOlRcq$aSJh3TdSTr`R5tn~f7z;BJ2qD)BemW_#JJ(RgYUGB=?RQ5s zP^SeZI$|R@Jhb(-3hPNMJKaEf1kbqXUk6r$Kssfgb+OqDSvC5^8L}O#UNE1m=tww! zb{qf6L*5~6=x6MXx_m?L*evn_y;_HPfo^B_Ueq#Df(&Jgiz5M;V1s@bpP7=@c0fRhQ_j;UwnY?M6&fAsDmYdf z6WsC5*c{Z)P{uu84Z1Gmmo+^{&qgFe@!Px+S07pJEwig+!Pi|aTuevN@6@i-OKoY5K zYIGpK8~)|)blJ4|GpkzZ_=({tWbBm8iNo`egGe$CzsAx0ct1^Rufz;pho;Z>W?z5G z9G}#`HGX&#G4lfzTpK&CE9=Y~l%09Z9jj ze`)qFu!R${nWnPgN`y*@1!%6S8r9KNG~q~*e=gWRJ+>1$i3+t>ktS9|pJoIOK*T6V zWgN-sGUHb~?NbD(jwVT-vgdW>ieO5CK!qrXY~%w`27RhR&103SJ-WgLmKQ}b#6LL@ zi%R~?Jp#+sk7q(0DPaXp+|Kg)so;Gq8Vc==1xQB2K(q}RaoShRUwF%-o_m z7ocbPvhGl*loI_`~+oFMe=~@46qYn4orN{KzJwbG% zabiQv^8+Ch+IAA*g83Mh<5_8Ex};%SiKeQZN)U*J&nx!wkzIiV(-HskNb!U7i3xhi zU`~36jSV(R?ytnMjp+=wp@0(k6_I^G)~Qok_mH>VXD&1yc-O*A0Bmb95Tio<@U4BFDq)l}KN>g-T3 z%3YJ4nZFCE@sd&n*-35!u{LO^1QB?9Z9k=08i6!pHj~*6!tT~eGWFRz-(TTs+evqT z#}1MJ6o#E&_l1mG&RST!nBFD{dD}us&q&wR64NdY*VpPUYslpdB`L}60E-}0fYA~V z{q08Rv2ySO5gZ|SlDfCQ0$+c&f*UGma_?S7Why_G9?g;JwYBCo{~AcuuyN-{@j&@! zGagBuQQV5YZM{|#!poK+_-&9W)V_LS8RZbS4~UzN#8sSJKK~%9+KwN zNzDRU^ckocJ8x5E+R<8C3O<$(zDr=<<`G=(a}n1^NJnT(J2WVFyZtIb;n8>op#zAG z%G2tO3l$K{=79CS2VKU*I)+Lt4LaF3#x%Yct4roQ%Fry#p@r?0SHrjfGFkb|#?$#2 zOJrzGjd%4KjmZg=(@^Zq)k`$vKbu1Kruq+$vlVTE275s(I9E4Kl6LrLFE=#fmHmWT zEVH5wp8?yju_EA^|E&|dM)r2((1*5k8oQe1alP4gHz}}+ek@tm1cyR?rl9(#I0v#6 zoK{JG5Ia*q^2_nU)>hKB;J;fDk}}km2?>dbeckqqStZGJAO}pq&ZjH_gf~+3#~$l= zcDa1q})}}94x9ZG%%diWb-UpXQPIRC>G5JuI4FTQc#tU(Z$%alnQRK0+k0J5}aGtvfqgB|1I}_iYDYW z5qrx&7yrNP1LnVoQvSQS1QOl0{rl1XDFhY@BI2@t|H1#UaQ_z3-yfF$ckCh-WOqc# z;}s7E2A@B4Ctrfezsl#6Olctv1B3{47p!dL3!ss37|P(HYNbsnd4szj)19gX4POky z;(B@q7m&;jJpQK0GJR~?FUh~iz7VmDpYkDyh~`$N^MXv#x|&}g%IOjiCt7h0KoELm zSpK^c0{qH$Q}CIaw&MgGQtA;?FOX@7I(x`LZmSZ>Q4JOZP~{OwI9|SRm@L2doK!aA zsC7IJB1&-6<~RPhqe&>qO111J;mATmtv=luxS#JLeetrF|2S=QEi>WTIQF!_`Dvre zV=6p=M~jK&G;@c@SGG zEnnW?>-4^3jb486&8PeS(4cHWQZHM3j~ajN)G^HH%g#CKiv_1HF;o}FB%~rrcGOo{ zgaPadg*KK2r@dlMu`5qF0dqQOir-Do+nQVIs~h%wyU-{#fMN|E5jQdko+n^=C%tGl zm=~C=js3(J+2uKYAO|sU6`bG_+}me-^B>BZh_tF=g%I_WjjXzH?y09fOTP2%{?hAZ z0t%GvPQ>X_)sNk+aqv&4w)vv)+F|Gl#lgZ%CE50n64LrrUQk?pC#INiSE`ZfKnMW? z^c~r2#)6PvVIVm^-kyO7be!~~Mx|A!4Ai|`q3Yb~2{0H#mHvtJ1!G9qNn~oMWv7Td z!j=saOXil*x+r(1$pqfAt5d4=7s}kY^e-Za?wv1IvS_9{uc%m>EL-)QY3KBVc|)yQ zRaLc^!OjOTCNaI~9TSo0Ov^*h_S7@z+H3D6MB+2F9jd@xaS_Q^Q_=$sPoat%GlCeA z%8dsFy!hP7UU~k1KKkE3x4)WFCo}4fR#e{V6%6{kedIzztpS{^hmmONk|Zk6vBw~x z=gh_`iBUs~W^F6YpwIdO1j@vU$S_KydE}GzHO+jf`a{+Z8%YNmIVZ_n4xHdlnsgWk zX&;c(fLSeQaZO~MXkZ_kwfrjhRlxxIB9KjLDsnU4D|#T;y#gv$gY*Gp&SrKGbi*+OV$bP}qKH z%uu>^fOQ>-Y*Q`f7wPIHX;l|FYo&`2%&j;)X~gu>Lyz(dzTZTA{vbpf<4ej2k}gJ( zPbH-*F{^#T)LQatH94<74XVJpR9HsNvSj`_gG-Y~F4Pn@#*)_6Oh}wWM7IRj4ZWg? zKmQn~Fl@G4&xhY|tr^r{ZklkhuTWWUWQc38Pqmx!+Ok0Ip@_BrzgMMgUs2^;$-e=7ZV zGO;TuDJ6Ez%)FPbRxo)yyvo38?JV<9p@Ua&`{$57_xl%yeYx3?puX8E=T}Oojb8OV zIJZXp2Tih1XrJH=eY^%)gL{aourIfk{!@VqJFGc>7R~*)pVc4$G%=jwA~*UtK;D9o z+IjJy_wS8x|L=)Na8TOwI&GF}PsdTfEgj;@?o36q zFACJYG8gzs635S$z_u6@U*#Xa6Ccv6Qz+eGSrwu^6hD(FidmcfRYleO!w+aG@McZd zQrviM!ED$&F-Fu-j0xC~Ry_)j$QyXk(B(zPT|NbWVynB0m?SoBuzT>DDA%~nenb@xud9DNBrLk@E_b8|IL#Tg};mc`|ewRUqJMKF+S0s{~rG5 zzlHOU57z&aJLirzDgwTW7&Xx8D-%V{ERfKOBImHppGe+Jn^(|;#^}6!-peb@l~;!_ zh-FJu^uHkSAQ^>a=RtcSRX$#lk~1;|Iq^Ji&BT1e+Z;0j%xkK*Lfy&Mvzy7&;ALVT zbl2c)b?F@?LL+A^EcD(1C{*2dDrZ~lv7K6xYEZ@ISAD)&CFmPHcUfp(#=N(Aq5WC2 zK~fgu8i9;UJBD8SK?*WEl^bT5l83so8XsTnVp3d!Bn91VfuiaaOoBb&X%0sAwsr>P zmc;oFxar?6MJ{w@TSCK+6)OQLk$)iP5sHGPb%RPlOY0ToKG{J40s|G5LO|cS9{YMB zSsD+u!3kHKyU`BVPTKQWSK|4dX5j|7_v}6tCrvM>KajSQZ+p6vB?W_}pPcDSB}8vI zi}i*ZZjhT)^$8RTY>Rz9!k^(;;xu<8gEZM@`4r~6swxa_Obf<_VVm^JzloF`u7ul^ z>)EYzPLW5)_dr@hegdeFokh-qc&mYMn1&AI8t&5;Mzlgm88{4F<~wq!M%j<==rkV8 z?Thehu2plq=TmW3)vFU)CsdvrD|tU^jAZEvFrSWLzl_ z+Ci^~&`|sVS|Y{urLdySu@d%AUWW|k`5*PW{Hl_kC9m~!uTNm*$HYasIOogB{LU62eu%14?!bU zAg1BJ$ZC$3XOvV|eFyetrF|XdcCb%-^%_xE<~asOhH7a+SeZXt=%=&QT)ubj{Hhz} zQe{44`c1S)miGZnbHF;Qa4~;e@fb9@UefKb3o5I!FC{MJe#-YXpO~sh&Y~t<+d0M? zqUGn$_f_#7m%Y}Gd!w&glfBzNDxCJV{m%}J=2vqCV=MebT470(PhWGsQRr+;_(4)L znV%K6`6N8HK1(c*i?$$wKym1NwxUEfzcQZje1-DINJUWo3dQiamQG&M6w9g}_BWAB zR<UzJnxtGBC}tpr{NF;U9{^5NlE=_n;P^9oaq@ zvT-OY+7yMj7FVc-9_hCf7s}#B#_T&d#su@*?@HT;#o|I2!ktq7vP)%u}A&6WKIjF0&mb?Wg5y`%RaG2i$-J**t zB>l})t5$~cO@akA`98Pzb~{g#+|yEm4|egh!9KGP!Nx|28e%i_nID(eP3G0ixGcu8 zVV-13*`&&ucV12E@FvDZHoE}(RxBmq2QDbZMf?eeLke}yE5_Kk+5T?kk!Im3uH_zQ zEe|Vf*TP#gMBlHHjjgpV=(F=ogLe!0kAtDAx*W^zj=ylWH@c@pL_&WNjO3f3w;aIb zb@y8@Ix9iq3c)b5Nex{dmXeQT7Z6y3yO!>(R`7ULss%npMp0g&s;B#8;I4zUtK+YI zA8BGHbdg)b(HXytf)n|#9Br54b=T>Ntnt4h@3jCr?fUxfk6dclWYtA&M{-s#?gr!B z@-h3;_edZAEPVCF;A)J&|G2K!N1>-egyrlf-J!`0IScw%|Lja&73f#ctT{p*|+Q55rBEhx~Y=6Y8a@CP` zh|Y&j)@7?`^%g17j3-s+j7Q5cS0*0!Mhl>%DTp9E1MEH z5kRT;+i0~=UuzYrWxq<5Cnh&|TJ1fp1s;N%k?z^ymH057P1*28xr@|~vZYuLkc`ON zADJv)Yzj(n$;$&*#|LWje|CIacvu$Uba_ghn7n~lL4O?OX3J!q>t8m67~fu$+N7v3 zwB@Z$O_b=P)R$pfv1N**oMbC1< zW5g*qcOb&cvR+NQIO2fmahbKi4L7}k7H+cbrr7}!H7dPe0fVY_?}iM1bd9~}_;~f5 z(_fm%enDgf|G`z7AS6AE|w)SA0{?h&Rnl8O675SzP za!nqh60RYVVr}sF5fL*kt^1E8`J~T8w^ZN#eQx_l1L7iFC|2{wC}(d@f#=vOCg7$i zkiA4&f`AT^1=u2E<{f;gc*u0l+WISE^y$Cc`9IY814kF;5;(a9ja0T-AhhDx` ziwlZlJAmcdXtJHM=md!2CbUM4ALK5v75Z@tKQ@@hZ>)RMi``8OZqJ}Dd)TsKo{W8y z=JxOftre}GGhDbgCZD`k#RP`SVM~$5Dzge>$TG2Sd>O^yL9-?=-Hl?5Ci$iqBH@&c zYzlsYB2JUXz3V6yKuL?d0YGAVSWU^s8l|EyyDN*BG`{ z+m_0kkn)qvSuD1&?!y5!qMk~vq46G!^1R`63lGjac+-!xG2p=qC%4}95KI$zU;C84 zcNc&tFB$2Z@S5u>btY#J5>uF8b2tkMH~?eFY`h+W`x1)S8+!3LBT_^uPi{V*M(v$J z<<`YJ=KL4!;eMVU-l8_{$8j4xAT`i@tob7O+C;>SOmq^JTD!XeyuWDq<$%`XW%4*) zXYo~Ud9g$*w{}%HTSG@eL-K+PgOUy$RvZb<*3T&1?6EJ%csgg}W!Gv4RzX3Fdb?2& zBb<>+_MN)J-CJ#3Kh+oQQ}ByxxX&%$DZF+(Lqq2~)bctT^5|@Z#k@meqU61JjFe!V z5}?E7xu|C=lQ9+bRP!p%rfrqGbJ2_PqFmGafJu@(R4G|?Jzkgt$rzZG_-RaCnpz}} zFKOOo4lb$jum#-ilM=ZudCPJ4Qu1(g=1Qi`q_oAIqu*(&_=+{$ENV6El9HKAsKTD{ zN5u4mqAl>xehu*m2xHVU62E&btR*^tkVWl@&hC}8?A^!P=MD90T;3=s4Le(zns{tZ zCRS4PYqz*i_|cHokut}s+KK7kXBi2s2hINQp9^0kSn1`Z zP!1YBDjdtt)*OV_SX(R@NK~Q8Beko!WW2}05T`cyO-)i z;yWhiQKu#C8d@JXNO8xp10j(TZpq~qi;pE6bW>spV0t$Ulv z`o_I|9$QOEz_oI*5~#1%`JvV_pKh>Ai}nJ^E6vrHkZ|;b^bbQ_^1iqTHEA1C>gc-L zE)0`6qOg6U8KFzr?#S(tAogu50@F9t-wM|{=Bf?#6g!wo$hH1LWW7e@eiNzD7=HLG zT)&$4*-0hsgX#2rXLLElwDUhCZMzSHjqbW7o{evR8R$?stu(VSk@q^bMf@oG;wsX>oR4fDo$Mj$&qkNDfb)Z zv876HP8PPa?I>i2L|nLRN$Gxby9Mtux%u(6&w0B(%26Z|^V&+}>sQObipU+d3X5Hw zsPgHtGCEU3K${O&h)v!1c|Bz_;-bcvUw}E(B%=D5$1WK5<_hp-t7tdReHPFqM=muX zg?=S(Hu|HNk1e(+iZSFkQeGY`V|bx`)P1^ZQm7z2_7Qw0G^U0nx;wC(ufRv6_co5S zy8k#Np=L(gu#rvG`GPF{<3!20P-0f_g6lq5s4|M%7-R}?9N-VJN0sdnb&|`DIvo#X|AGe zhXj#pxV=MWU~et) z$(P7BwPM$cJhw2#+tZ7#K5cb^`e`rIH5V)@xm`q#wv=(@s*34hC)}Qe_gYE@-9^Aq z<$A`I%`BIh^>oIc`faq}W9J7EnY_@-V5fpj_?wU+VTu(+Uv5nl1R#^qtWYbJLlFTPV8*&cj{Grmh#Eg*BBu$A&di3})+3p<`sM zov05SYUo$rUGWDCjiC}4%teUx+m?rK!$V^~>B$!LI9Y3~IhbB+2c|GV*P1CenfvC| zSdnoh06tbZ^(bf1h;w${Wjt=H!N#k@6OvirAC6o~AAVuhx#Z=cpJqRq2UASuREBm@H6*`PeiELvTv8^BE^aa+l7&KxmKFr&yxA>y8pn1#Z471ejdK%&JSNDCl2aWFK=Nc6o z^b`f(ZNL$@8R*!6T;F|Mj(bAF-Sd+ia^BywrvgG2<96Z*?%_ZF)iy{(WafB~{)%0? z?y?RIMIbLl-~A@4XZTGtj|fTr*GvCrdVnYX6Ol{5R_@;fe5EE2dAZZ|{rc$6Zz9<@ zxA|mJoz%nEFL5jvs$3&0wo$zsCQ~O^W+#D?~pZ7;ge-H%Qqrb*h9c^WD zAwm3{XEJiIJ5d*BmI+cnFXYF;1*!qEI)xx4f5G^r>zD!nfv{m6kNE4IV7dueJcDox zdLHb^5$B^Xxs%S$@?2te)?Rah=01*QheX=ABU32fk#$4vm1XAu;ph=iUr#Xuwc{wa z;_B8VFl3@Z$o6Y%01b6=NQeAgwPIn<6MuMYN$%L!n?+tV%qqgQqYwn|&1b$4>1+11 z$ewVCd}z1E#6K)lS1~n`^Gw?i+RsI){MOO3Qvw>J-8CD2Q!*7BH|OcE8eigHtnf~i zL}XzxH}mJO;j2G*CEN7CevZGIVxs%n!C!TT0u`Ud2VQ82@RCmJC z#Q|$|p;~4F#|D^agRVNG$g>?@`k>W6T1x726KO}P^)YqJB{rjMRy~1Qx0u$|``Z>$ zY#ZbQ-CH`)NulC065}GfeCx9Ek}P#mYCP(trW{BqlOI)U!u5Pgrb$cc4K=X~IVmC%t*H?;AaUNP+VwCctTgwGaxESI47vH)gS;m$b23cP7K~{ z&q%0E_9rX$2y`2m&lFXISC5ZPna+>yx__=w9t^0lSa}jmYHNYTE6V$HKO(YklpgIJ zxNz@UpNbA}ut2ywmrrgPSNhHdZPp`_!*mL`AhNG(eI+9~!8A8nrgZ{i;sbY6i7~9= z+LLlDORegv#XV2SFyHN2W&nAg4+0Ba;EI7XG_lFWM8wrWpMM%Xaql)rRH=L4(YVMq zE6KeGP3(4qU>IpNjy1IbXEHmljdB&MX->#q-wH!&K@0Q$&{^-llV#TYT5>LCDx8Q|GA{4Q`UKuOk#D*xZJNmE0(rFH2>E-CXE<#{G?wp@L>E+vKSqPQRYStHIb+aM zXMZN&X^|Kws-Mj~k z?v@xTh`?UdAbaSE1+9^I2#7^UB7rOqNuW$0V+H;h)W-o;CutzR(d(; z?Ct8td}fujkV0h7FY@ItWbE$?66u8W&@~NA5ud?C8^uUZ3u`7aENEM`tI&}m~c56Ia_u7-`Y`KYlLB66q%pU%}T zTg5u^Lt?i6B3@Sd8|pVvIdAjm@Vh&&?>PRm=fU8=2jO>h`9?HOH23pAJB;^Dev$Vt z(ElV7CK9>%&(8h#Q#TzI=Sng}B!7{#{%7abKL`8w(EpImzXkIDza8>lEv(W*0s!rF zqVd+|7l%b02HXyz+GAKMcP7>nysAq%MZSP?YF#dp} zKezV4bEFW|FmKZ*<*C@0=?vRNO2dN!?bS<)v}tS%l9(TNi@g$+SwSd7jf+!ZDZv8? zeD?7lMV`x9qtT!->hN-ItmJ=tlKmuv}~TbCy!t zh7Lx(ls&p&WX#9_E*iQD)h<$;;bMyu90_VG%UYB@>)fOBNoD9xDatf4##CB}VcGed zDeA&3=Ij-%4V0suV#$@SN`$0evO4`HB8t{_3QKy(R{Kt`D@3KtoP|w4etJhAYk;MP z$;M|pA%#$Xq+<=NEeo#uymWW~3D&tQgKa_A&AdOIQqycWV~Y%6KqX;1uM!p6a9wd1 zR_Ufweg$rfO)ni`DI+A+z8<*B`1_TQ&6j+PNK*w7jZxPVigC6rf4IrPEK zcRDxS+44z*YuNCMJL%9=FpZtPpMfY_B)dWM^fd|Z-pWL?cIFBduHFn@wfFfdzWz;J zp?;4&TMfiCzep#jIkyz9dPehB!u*_aqBHmd-LKVj@su;)ld+;DS~=;#GT5;C*21Lo z)q=>$cuuksIQov^a@!T%!SJ0i8v}Q@q1}V=y38AzGjECEU+}7%sGP7QwWGjEic@E| zGE1!=e0is8-P-rxpX&CEg`Rin3Wc^x2=+oKMW>b+f1uS6M~Oql37`L0d*>b1^wREo z-0p1!lqy|;&4NsHvF$zWTZhW`pim2UU{AT9AUzD9pbrkYfqE}A7_-|m z6(=if+?zbbH@6o-UJl?nS_H3s3z!c8&a1z*=)?_c1!ayr=2GVFuQSYQOBs0UR-X}? zW8EC!V6~EP+{&5ri$UdZbDAwc0_%KQT(hcK!&qB*FQ<2+#a!cSaU>6s_0X?&rB^LJ zW8YmpL~8n@nSpUbA_0z8Nf>UzVXllN6{Lj#8aI#t=0oKHf(^B+xU;WbWS}A4RPH-G z7p91FR-7>un_hL>zrIwV%+?}SxP=aj`rRSf|Ix{^e(&dKiLLkR%DF0#=xm_b(kgS>io!1=!S`E6Bf-7mRV24aW z9<^OF70y&yp7I1vOZ4SbIQu55`lcq{{F)zYE>M#}%8#DGZFC1JStn{Gz$)>c(+1w^ z3nmppV|a16#hhCDiv=*wb-!86IDp5qt(aLmZZ+tb6>P&Sd^yL1g)@26{{=y+WK*P4QEoKae~0sist?-e$j}6St<;7h+A`2sD%GaVfJzY zYncwNq;+`bTE^!+78~~4-d1;vIn$_JPBhF$DBFa<3-xlLiDy(6`Ez6_y`!Z9Hta zIk0Tq4KG_r`vaUG%Wu==E8V+BK<1UbGWU!psd?0BzL{?D%FSpoF!u0qjMpy~@)CBmMHRAeZK{iI!|iUzCh9wg$Hz=%T4nem><&Cy zXY;O-7yrcOXjV}!6pN`7(GI3EVbhbAVlK=#xy%OLAN8IcF6%j(cJgVo1RB4M=R;h2 zyB}*(OXj}gn;R2FMK`it3xF-z`cBzr2S?ruAS!*yxw&GZ_-vKj$@7&T5?2R|;!gDn zlzBDNF@UXfCQL!)40ganG@Ziy1CpgRoIQTSQ-q8V}E%)uaYuj ze|ehD<+&&dx)0AYclWX7;rE6M3C|eo`_!)9eAnLR>*(e+<7pBQK3+P?)$IYSuFv&q z8t7v5P%0ONAwYN1k3L|AceNFY56UmwR7(iU121*A75`$;KQY>kX$f~g;rd)|^x+mn z-Z|ILr*NNMmSMX+Ha~AHrIX=qhQG1VL`2avwYKfeR-A?|-^b);l;|aK92!wLggQuq z)at%f13NziGZ{idoAS`bPQ|jOGodXiR=n~&%l5)&>d%zE493a?byo@a{7y3y&^&5P zrOm8g^VxA%++EuqnNx24L9*qTDA8@{>;4J>0_@Z@c6A*y{P>_E@ic}Sy1J&hZUET96OkM5`m1mggt^L zIT+-r?mRc4T7A5F?Wa=F?buT&hrs^I+FZXhYU5~yh>MfC+bG7prcnl;MVoJobJSyG95wT+wcZ~&V2s1lI zO=7;`(dWR-^?kc3n<+l>>e1Bxw3)v-rP}r7Ug_Kv+;VpEPO@Q&hL^{0SCD-EDm&Mo zOApV`!+)+r72Re*@3s<>rv}L>J}Ik*6A0BSG<)k<6im=;w_lo#|P;X6M)%JoT1DB{dpRRsSc*vln$ub`b_imS6 z5%_yT@ioKjcQEE7r1SHum`8t4%rKmHUgmEmiJo@-6#RQa*p7iI>F?H}|DO17v;SSU z|JQdgIn+q12C@ajpIijIY*Ix#C`!S*m zG;ld@G%@<6<#%|+Bggszmr<(u@l2>&UtZd?RKp#d;9ROq2jvvdr!X!q=4=l zKl3VjT}Xc3HpOGyaCTVZ6g+66tGQZK#MCTp%r1}7R__`=m=QV8K1%*InUqh zvCHf7O5ty)L`3b;H1ts+#u+GG+c{Xg&kPeugt}7%z(AE#xcA5WC6}VZVpmeP4Dd1bRD z?}5EH#wVZW-t8+vf3{4wTB-HeTS&K-gk8yQu3T-tl{Q~82N-K?Y3!e@u&^&xHRXKd z6t~*qaOKC&P_u7f@x}|jFN}7A+1|1%6+Pn^=yE?BV_01kLiG=R+4mqn{{?5djN^RjVO@${wChhyhcx_xC zXQ%JjMO!(7HCZ~+?~T2#qg27<>+{$Inw&P6Vyf!*voJH0Q#*S{x2BP)22}AjgY2k` zvpZqOpnPiY{q*a93~Zl&jSc1#?goiheFL^Lx^TyoR*pp~i@+^!=LL5Gb_n>!Bq_&T zs$r4;?PdQvS?pg^LC*)sLm0Z#w+Ht|6yywcqpGi1ELCa&_1uI_+S?R16H1zL0v#nn z3x6j4=;Zy1Vms$sD-*}3w4#U%8xg5~F2EPZF=zNB*pE}H_tTtk73Gh!wz3S zhKS{_$PKjKHFr7_|I~R73w@T_n+oC|LVVx1Nh5!(P{l2mPY>#6TXnjdM1~q30u3t8 zIKDgzNV?pKpeaIWZ>>xh1Ky9Y|MvOas)~&Dn?xQ<4c3)rfs(flgCC0STEHXTjO>P} zrpT5rwoesiQ{AEWaQgv3J%h>O8pd+67S2a*3OPFYt+N7U41qea+SoOt>PtG8n~;qJ zA6$_FL9OI)GN0ga#$o(EH)zh@7Fm2iuxNlBLB4yGn={$C`7OXttd0~xJl*1nnWgCBGWT(ufm zpA-zWEqI#-gbHfy*w=hpGsjlt9yHH9bByA@s`{BdweV#y<&K3}%vwB2*XNXF8dA5N@qorm4TF~?ZycTh=?CKi#;PEf_Kk&5M zVePsHzU);sG(Fc^-Ho&_p+{4dU*8Dx?w-UhShn%hXK+}2&kIwvV6)F;wS}P5fl@&O z!Xhjs{r!!g4=gk_k3w@i#L)pYz9L=(kI*;vz#$el2xewSr1Qm^_eG=Hu@@t*?{F6@+_Sdos(i^xBcb0D( zDYCVjIptuiXnI+88mlAfVAN{%*QzOYLQpzVSQ9SbCQ zOK!oJIhLaaDQSf5yrAH>XAMUDk-fQunbR9I0W^Ed>u^lFx|`)kmkH0L-Qn08XWrvS zBGhR>B{P=``nhVlf2}Se(YO_oatuYnHkN=FGwGVK555}N;eEP?0@4O)D(`h=$v?XJ zaM1jMv|*bH#<)pLG0gaz{Ce?69IGBqOp7pR8Qjb7$k9;JlVs-j7>x;#TW;V~8yc-d zO{u+g69R@sa}ogYBgMR(+!JGWU)N(tzUI~od>x4WP0_EWx-`K%Aj(U63|Tx5#Ivwu z%FEu(7+*g6ZVFS^`~#vE*IeaMMpxeOLKwGaECM7P{7il*9&FJu^MD7fMR64i+WXAO zVfqy5B9pp-GF1={Qm~AT)Bx#xhB@K}#9OMQg5XKnqzz|#HY`5PQ0_(03G66kxu`{P zLcv#}2iD;RDory;7p~gJVp|Ga=RTJfaJ8@aXU*9~**u#XunSx_RQaGKjw?o$*~{*` z!W)IHk2nd$;>v>Goi3Xu7$5J`tkPB2{L&4i8gN91P8l`1O%>Z<9?qL(Y<#;Rf&o!mxVE#v`f2Z0 zELByPR3(Z7<@eu9)_t{6*)!CILIIUJ=Go%d3CQGF@fgHgjiaB`Imlm8XK!aQKigCM z^q~N5KA|tbRi;(j*TufgkS_0+sd1s=45g`2yw=s4o)%Q_EZ*GzuN5WrCQHXGn_i_q z>ul#l79lKnv+|67EVfZ64PBthy?^t{Y8i{@oK9LWs&cY;PQ!zLggoq;wGUxdFF5sp z!VMzAFL^$4N}SN?IpC`is`#|x+J=ug@(9K>?(Vg^P4x&5(9#It{z3D}EXuHtM2B3REK1SgF_%8mtp~WdUi1}fwmuPD?!`OGc6ap_jac2?c3KPYR9K}T?D&RvV&8+ zv;!HjQqN{T8FDFpy4P!RRjYHLI?LV}w~~D)eRFJuwRp)uuM3AF(_J{&j`MMqUg_r4 z9}R5?Q2qB4uoz)w$th{peE3PS(F1)wMe8n&H8(UUjUYky&haoV*kl`FlYEklQn7fV zqX12#6Rd}Aw#nA|F<}N(jrfEOk2+mgF!_p_ z#3gsI#nKTkk^AGHX(0slX_qpPVdmTS%~$q3Zf7^V@|BLgKQ!NBj;{(OCa+v?00umq zfSB|I1N2-Png@VNpJ3inUjrUby@H!#WlfTcyYwSolj|}b_L!^lw1kjaBJ(?NP&>?9 zQw8y{dWAi@>!Mmw_yccLh@KCB983~7_`&i@k9Uipj0((#j&#{SwL-OMxEwa&y$%wR zzHl~!9qt<*HOtvgH0GVzD^6MiWZ%hQ7@N1OsyX4(e zk=tu;>ly;Pb0XAOVSD~viRoU!5n%eQ>p{$o&q}}J==YJ8{QzrlK`YRrmoVJ|?6YVrH>P9FS+-p1^`! zTtrLjS^sQ3^(}B6(<=@TMethRf0u?T_mTmRnz#-5oP>K-1-z>g(PCZ?Ik#lHRj<#+ zZ`9tY&I`L_I&Ttlcu~pf&7X^6ONdC94v12$gf?e|Vy43O%b6vA&j7CZ%-Lz3X>%JP zDL)UUEEsA#;Tzab-GGK2zKWUD<_RkT+0dE1OfFaNpJ^ufu$ie1t_P^pURAakd)hnN zmOO0|Enw9AUR&kJt9|fOy`D)bvE*y*Q=9C|U}9;i#-*r+%Ud6{1)5-Z8H0g+#gzuo z1)pvMAJM3`JP)UlEn%7m71!WxIy{q4c!5`LF-4yVL)0oPsuqHhWZx6ttBs!j5(bto z8*yH|n?BaC}5=wzdPIK3;5DAsMDVHm;Y~X90EDk3}=NROnumyZ==S`^&P-2ixsR zl$3zqq#-rzL93GLYgII#W`wzi@N7xtfkhZa52@t+#Tes!{0GoL)i`NpskS?~`i-gy zPi)!5K6`5THzu(b0JsdOPCMX{mc7`o5}({x*ekK&XxA?y@$xp0vb~LnD1jT$k{@Z{ z@?EzrmiUpYH+zWvYM10hF& znkrth9$C@X938N0BT;s>+?PkKi8FzCL!&hXM-rwU-vBBx9p(-d(;R2}B)?C}5Le&z z`;d1uaNO@2lMRZ6w2$J1A-gpt(Gua&LY=EJFXjVC^r`EH0-k=ZR`d6~mm!18{(5te z<;t2M>dvPxKTK$Kf-0KQF>aW{SC`F}{D|hXfOxTt?e@8he6UsH2P#D!do;HA#kWsr z1!*_YMXW$(4LDa+ho=(-dhewv(QqCMjVXd^duI6Up;5C7=ggzB`-$7tn-=AUE08HDy)iGWX;a$K(wv&Zxh&BPU>e))q{D*6kL2nvRj|WCB_md9Bq{ zR_ow~0*4J6Hb@fg@U&uicp4h*tb6`uLt|WvoYXobZoayexI2meCf}5l->4zmDzZf13>A-AXMF_h)kWL?p;DgvF zK5-Xrs+_uXp05=#(AzlXt)scNHxle7I~qP{qPBRgPR!85)021KoinSba%@0JI@wnD zg^Y^0v{?%Bd$h3)8gJ56xh|DM(+yy|%l?uNzh;;~Z z+8usmadrYBwVIs1ThU&!H(PzxB5CFX>Od)NNo=(RAQ1AB(v_t`<>k!sg6L*=NgI zm`5SsE~z;y@AIdUM2&<8V~b>uP8&wa?a%iW&FL&RF-jK|$7wKH zJ-p~Y{fi;y%b(Rz#CI8#%xm9s#Lg$Uog2vh@HAr_nASR|S4FeUvD%n6piR&bRW%^Z zw;)XeCGs~;hx9(lux}z$Bc&8PjyG}~kW6E2)HS5W#Ng#@7SB-IUcNxyoUR<5a}#VY(SNv)98p!3>KSIOM!ss$SOvd{WkYQ_yZE_(?kI+ z&QfDx1*XPte(Ww&9gEh}M-{f|h1n;=0YZGa7aq*3It}bD9;sQHSCk=oS~da=^Tg{f zaTtRi;L@tD@I7LltG-sg$>rpVFr;}MxsA<3cyKsH7S2*lw0~X)*M!?b>J7qdJ1#=6=U^1R!JF=5u1qw@mNL1hk#tTio#zx9q%yL*XPxaCzraOc0KF>s*)JyT6i7Dj$g$}?g znwt#-_&>$HH5GxmrrAfL+RSAl@*dyMeGo3RCnx?;t%B!r6Unqc)!bWD^0f4~C9`sL zDwHC_B0dZeW9=vnixy|-(H1n?wHD>m7%Xy@8n9H)_pwUDRnn9upl6jif!QR`Sc^c} z=t*??O}3i4TpfoyX5zcTu&jldbv@g897*sB^qvPaz8}~d>i=;==r1WhFZ;E)w46$% z%|^}(vd{}+4^LJEAPH8;mftC#Z%%|V^97}HKXa2Iy9G!p2MfbxMfAYsej4G@YSypB zZuI3xB-VeDi}rBxTz{UXJJ0kj?H!za$_L?Q5wR_FQ`pHE)dg{E{T@_4RmJ*MAIdq> zaI9uwlFY5{fem@%`b@;)Xw>59T0KD-B;UK-8a)`4-d1hKo$*dCBg!_R8Vp$|;2sk9 z7WdeZ6dKp@?QF%Da*{C$B%T>^MuY9NhsFj;e2A*QHRj83t@B|2q+?Zlnob|1%5^5+ zV9z=Tu;Nful`z1a^@C)1eR_I=KdMcROF`{LxV>mQR*04@P-O_jVaAzozP-`cGY&uW zh`0u}8I(LCKz3mIxT|hbIu46878ZO)Nm$x>@##;Tia?8s(VLHzk6LcM^$TU>K6UX( z?wWG35heWWxKJeOs9lK8V{EjF?mzy;U>3`t#Ld_IddCNnRn1oGPDA(z=#)0Da5hYt z@|cMLt0y(o3Aau!wO!R7W#FfRq*VXQW_!_eeg ztRz(VCP?P(npXB*#gm%vap^506J^$!0?$p@L%x|2#SXuMnN(B})&2HF@vWfq8@%@T%uGL5NvO$nGU1qy+Lt5yMV{pH(ViPw8rxG8f#>rboEGnN4_o zKe@riC&TCKfvjR-7Sd=~-B|>3yPs0n+6~3U%c> zyIt{bDcOI{nztK;R4i0W$dyS!GP1=KB2R=>>f?4#1I9_>3Ho$+!`Nfl=$ZS~bQjwv zLXF%&wlHwZ*Ad!t5RrS1&s5#&6grJ>~2Q;R=Wx;Z`ggs#6$+18m?C^y+5 zvEasvZDRrGabIrV+Ud>&58D;%lt%RjqaB#pQi&EOjKt46l1S*i^bd;SF-H(Ddhl4h z(!xIQvr3F z&^N`QC=Qfo>ZC8SHDkgRBr{@!JN(6P#OV9v5zl$anzN&u)SB-`53m)l4ycdeua<9L z5KkR{*e5|LpK-H+3E8oK0+16*ygY$c#w9~(8_(x!leD?`9XOU0li|xrqZAQmoLv8i zeuRR7o${hgOW(aUR7nDW6p&*y?KU=917ai*^@i5Z%k2cG?5x_TyeVbRPmpNz@$TNf8?XC6~}5{sr#3~Y{a59 zo2hE7NN?bs9qrT~QS zwfJyF%aR=sl@J!K(o$ORv4Q^3*WI#QSN+tERZ+pT&8=5lRf8b<532 zQhFI{(wX#`ZbAz%*r0tl;%5F3`}Awb@^F&QQ1BPDE(${u>H(G)d03l<3=O-Qz3gue zP{Ute$(hTn4u9aGVmq_+hx@s&2ZPB6iR9`LpaDVrY$I$ajOibVVGN*Ztd5VH6)hido9HYCVMg8O@XNh`Xxs81bMTFVjJ2QR@3K}j?pWi{ z9XY*u!pLXU@_!vLy>)|W69}!50ofl#Tq8LnQO7^cuw^Q3Q5m`-v^lv|ZsPDgq#^de zvllU90Ot(78WJSZ46{hVdu`bDTGH%zw=+TYq`ywX5$}9#aU`>R0x^2@DCx;vZaSEu zxae`?t7As9v|kJ-YGS9bUku6T-|_cJ*AK=VS+d+v>oVA@xPwMy78!#}%eI7Xn#tur zsDa9iIz9pEyO2Jm!iK2ey9QLE0CGO3w5--09u7`07UM za|{>rvAb(yrP5{;{2M?k+^EHVps9_-A__PmsXzZ9alv4Plg>zrZGgDD&gfEW9acDR zhzTUv{a7Ml?iW%UyG4W`Wo=9z=GECugr9~-A}Iv>7=_3hbAHk_qR74*77)PqohM4BYmA%fM}!Zhqwf<4UW3QQN)8dc9Tu^AqO3na18@ zKn0b0S9E-3yz@8Ha(B!*Id+ZzXv9Z+_sv#LRd_%X0*v4ZmnS zee9{F(<}RL>PIVd=8o6WMyR(}j?n{#^HP5+x6^;9W(WTH@Bef4JoxKBVg^*TvmxnM zYL*9DpM7?Ced<5-<$r12zn5Vkg6K5UBeM|%haUl>Pon