diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 5f89f4c1..db3fba66 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -14,6 +14,7 @@ on: permissions: contents: write + id-token: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -27,6 +28,9 @@ env: BUILD_X64_SC: 'bld/x64/Text-Grab-Self-Contained' BUILD_ARM64: 'bld/arm64' BUILD_ARM64_SC: 'bld/arm64/Text-Grab-Self-Contained' + ARTIFACT_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net/' + ARTIFACT_SIGNING_ACCOUNT_NAME: 'JoeFinAppsSigningCerts' + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: 'JoeFinApps' jobs: build: @@ -77,7 +81,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-x64 - --self-contained false + --no-self-contained -c Release -v minimal -o ${{ env.BUILD_X64 }} @@ -91,7 +95,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-x64 - --self-contained true + --self-contained -c Release -v minimal -o ${{ env.BUILD_X64_SC }} @@ -105,7 +109,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-arm64 - --self-contained false + --no-self-contained -c Release -v minimal -o ${{ env.BUILD_ARM64 }} @@ -118,7 +122,7 @@ jobs: run: >- dotnet publish ${{ env.PROJECT_PATH }} --runtime win-arm64 - --self-contained true + --self-contained -c Release -v minimal -o ${{ env.BUILD_ARM64_SC }} @@ -137,6 +141,73 @@ jobs: Rename-Item "${{ env.BUILD_ARM64_SC }}/${{ env.PROJECT }}.exe" 'Text-Grab-arm64.exe' } + - name: Validate Azure Trusted Signing configuration + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + run: | + $requiredSecrets = @{ + AZURE_TENANT_ID = $env:AZURE_TENANT_ID + AZURE_CLIENT_ID = $env:AZURE_CLIENT_ID + AZURE_SUBSCRIPTION_ID = $env:AZURE_SUBSCRIPTION_ID + } + + $missingSecrets = @( + $requiredSecrets.GetEnumerator() | + Where-Object { [string]::IsNullOrWhiteSpace($_.Value) } | + ForEach-Object { $_.Key } + ) + + if ($missingSecrets.Count -gt 0) { + throw "Configure these repository secrets before running the release workflow: $($missingSecrets -join ', ')" + } + + $signingConfig = @{ + ARTIFACT_SIGNING_ENDPOINT = '${{ env.ARTIFACT_SIGNING_ENDPOINT }}' + ARTIFACT_SIGNING_ACCOUNT_NAME = '${{ env.ARTIFACT_SIGNING_ACCOUNT_NAME }}' + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME = '${{ env.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}' + } + + $missing = @( + $signingConfig.GetEnumerator() | + Where-Object { + [string]::IsNullOrWhiteSpace($_.Value) -or + $_.Value.StartsWith('REPLACE_WITH_') -or + $_.Value.Contains('REPLACE_WITH_') + } | + ForEach-Object { $_.Key } + ) + + if ($missing.Count -gt 0) { + throw "Update the Azure Trusted Signing placeholders in .github/workflows/Release.yml before running the release workflow: $($missing -join ', ')" + } + + - name: Azure login for Trusted Signing + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign release executables + uses: azure/artifact-signing-action@v1 + with: + endpoint: ${{ env.ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: | + ${{ github.workspace }}\${{ env.BUILD_X64 }}\${{ env.PROJECT }}.exe + ${{ github.workspace }}\${{ env.BUILD_X64_SC }}\${{ env.PROJECT }}.exe + ${{ github.workspace }}\${{ env.BUILD_ARM64 }}\Text-Grab-arm64.exe + ${{ github.workspace }}\${{ env.BUILD_ARM64_SC }}\Text-Grab-arm64.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Text Grab + description-url: https://github.com/TheJoeFin/Text-Grab + - name: Create self-contained archives shell: pwsh run: | diff --git a/Tests/CaptureLanguageUtilitiesTests.cs b/Tests/CaptureLanguageUtilitiesTests.cs index f54a88eb..b992513d 100644 --- a/Tests/CaptureLanguageUtilitiesTests.cs +++ b/Tests/CaptureLanguageUtilitiesTests.cs @@ -1,10 +1,27 @@ +using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Properties; using Text_Grab.Utilities; namespace Tests; -public class CaptureLanguageUtilitiesTests +[Collection("Settings isolation")] +public class CaptureLanguageUtilitiesTests : IDisposable { + private readonly bool _originalUiAutomationEnabled; + + public CaptureLanguageUtilitiesTests() + { + _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + } + + public void Dispose() + { + Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + [Fact] public void MatchesPersistedLanguage_MatchesByLanguageTag() { @@ -28,7 +45,7 @@ public void MatchesPersistedLanguage_MatchesLegacyTesseractDisplayName() [Fact] public void FindPreferredLanguageIndex_PrefersPersistedMatchBeforeFallbackLanguage() { - List languages = + List languages = [ new UiAutomationLang(), new WindowsAiLang(), @@ -43,6 +60,30 @@ public void FindPreferredLanguageIndex_PrefersPersistedMatchBeforeFallbackLangua Assert.Equal(0, index); } + [WpfFact] + public async Task GetCaptureLanguagesAsync_ExcludesUiAutomationByDefault() + { + Settings.Default.UiAutomationEnabled = false; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.DoesNotContain(languages, language => language is UiAutomationLang); + } + + [WpfFact] + public async Task GetCaptureLanguagesAsync_IncludesUiAutomationWhenEnabled() + { + Settings.Default.UiAutomationEnabled = true; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.Contains(languages, language => language is UiAutomationLang); + } + [Fact] public void SupportsTableOutput_ReturnsFalseForUiAutomation() { diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 24b980a6..18808438 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -1,5 +1,6 @@ using System.Drawing; using Text_Grab; +using Text_Grab.Models; using Text_Grab.Utilities; namespace Tests; @@ -19,6 +20,35 @@ public async Task CanSaveImagesWithHistory() Assert.True(couldSave); } + [WpfFact] + public async Task SaveImageFile_SucceedsAfterClearTransientImage() + { + // Reproduces the race condition: SaveImageFile returns a Task that + // may still be running when ClearTransientImage nulls the bitmap. + // The save must complete successfully even when ClearTransientImage + // is called immediately after the fire-and-forget pattern used by + // HistoryService.SaveToHistory. + string path = FileUtilities.GetPathToLocalFile(fontSamplePath); + Bitmap bitmap = new(path); + + HistoryInfo historyInfo = new() + { + ID = "save-race-test", + ImageContent = bitmap, + ImagePath = $"race_test_{Guid.NewGuid()}.bmp", + }; + + Task saveTask = FileUtilities.SaveImageFile( + historyInfo.ImageContent, historyInfo.ImagePath, FileStorageKind.WithHistory); + + // Mirrors what HistoryService.SaveToHistory does right after the + // fire-and-forget call — must not cause saveTask to fail. + historyInfo.ClearTransientImage(); + + bool couldSave = await saveTask; + Assert.True(couldSave); + } + [WpfFact] public async Task CanSaveTextFilesWithExe() { diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs index c4d93e36..59610c3a 100644 --- a/Tests/GrabTemplateManagerTests.cs +++ b/Tests/GrabTemplateManagerTests.cs @@ -1,28 +1,53 @@ using System.IO; +using System.Text.Json; using Text_Grab.Models; +using Text_Grab.Properties; using Text_Grab.Utilities; namespace Tests; +[Collection("Settings isolation")] public class GrabTemplateManagerTests : IDisposable { - // Use a temp file so tests don't pollute each other or real user data private readonly string _tempFilePath; + private readonly string _tempImagesFolder; + private readonly string _originalGrabTemplatesJson; + private readonly bool _originalEnableFileBackedManagedSettings; + private readonly bool? _originalTestPreferFileBackedMode; public GrabTemplateManagerTests() { _tempFilePath = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Test_{Guid.NewGuid()}.json"); + _tempImagesFolder = Path.Combine(Path.GetTempPath(), $"GrabTemplateImages_Test_{Guid.NewGuid()}"); + _originalGrabTemplatesJson = Settings.Default.GrabTemplatesJSON; + _originalEnableFileBackedManagedSettings = Settings.Default.EnableFileBackedManagedSettings; + _originalTestPreferFileBackedMode = GrabTemplateManager.TestPreferFileBackedMode; + GrabTemplateManager.TestFilePath = _tempFilePath; + GrabTemplateManager.TestImagesFolderPath = _tempImagesFolder; + GrabTemplateManager.TestPreferFileBackedMode = false; + + Settings.Default.GrabTemplatesJSON = string.Empty; + Settings.Default.EnableFileBackedManagedSettings = false; + Settings.Default.Save(); } public void Dispose() { GrabTemplateManager.TestFilePath = null; + GrabTemplateManager.TestImagesFolderPath = null; + GrabTemplateManager.TestPreferFileBackedMode = _originalTestPreferFileBackedMode; + + Settings.Default.GrabTemplatesJSON = _originalGrabTemplatesJson; + Settings.Default.EnableFileBackedManagedSettings = _originalEnableFileBackedManagedSettings; + Settings.Default.Save(); + if (File.Exists(_tempFilePath)) File.Delete(_tempFilePath); - } - // ── GetAllTemplates ─────────────────────────────────────────────────────── + if (Directory.Exists(_tempImagesFolder)) + Directory.Delete(_tempImagesFolder, true); + } [Fact] public void GetAllTemplates_WhenEmpty_ReturnsEmptyList() @@ -31,6 +56,49 @@ public void GetAllTemplates_WhenEmpty_ReturnsEmptyList() Assert.Empty(templates); } + [Fact] + public void GetAllTemplates_BackfillsLegacyFromSidecarWhenLegacyMissing() + { + GrabTemplate template = CreateSampleTemplate("Recovered"); + File.WriteAllText(_tempFilePath, JsonSerializer.Serialize(new[] { template })); + + List templates = GrabTemplateManager.GetAllTemplates(); + + GrabTemplate recoveredTemplate = Assert.Single(templates); + Assert.Equal(template.Id, recoveredTemplate.Id); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + } + + [Fact] + public void GetAllTemplates_FileBackedModePrefersFileAndBackfillsLegacy() + { + GrabTemplateManager.TestPreferFileBackedMode = true; + GrabTemplate legacyTemplate = CreateSampleTemplate("Legacy"); + GrabTemplate sidecarTemplate = CreateSampleTemplate("Sidecar"); + + Settings.Default.GrabTemplatesJSON = JsonSerializer.Serialize(new[] { legacyTemplate }); + Settings.Default.Save(); + File.WriteAllText(_tempFilePath, JsonSerializer.Serialize(new[] { sidecarTemplate })); + + List templates = GrabTemplateManager.GetAllTemplates(); + + GrabTemplate preferredTemplate = Assert.Single(templates); + Assert.Equal(sidecarTemplate.Id, preferredTemplate.Id); + Assert.Contains(sidecarTemplate.Id, Settings.Default.GrabTemplatesJSON); + } + + [Fact] + public void SaveTemplates_WritesBothFileAndLegacySetting() + { + GrabTemplate template = CreateSampleTemplate("Invoice"); + + GrabTemplateManager.SaveTemplates([template]); + + Assert.True(File.Exists(_tempFilePath)); + Assert.Contains(template.Id, File.ReadAllText(_tempFilePath)); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + } + [Fact] public void GetAllTemplates_AfterAddingTemplate_ReturnsSavedTemplate() { @@ -42,8 +110,6 @@ public void GetAllTemplates_AfterAddingTemplate_ReturnsSavedTemplate() Assert.Equal("Invoice", templates[0].Name); } - // ── GetTemplateById ─────────────────────────────────────────────────────── - [Fact] public void GetTemplateById_ExistingId_ReturnsTemplate() { @@ -64,8 +130,6 @@ public void GetTemplateById_NonExistentId_ReturnsNull() Assert.Null(found); } - // ── AddOrUpdateTemplate ─────────────────────────────────────────────────── - [Fact] public void AddOrUpdateTemplate_AddNew_IncrementsCount() { @@ -90,8 +154,6 @@ public void AddOrUpdateTemplate_UpdateExisting_ReplacesByIdNotDuplicate() Assert.Equal("Updated Name", templates[0].Name); } - // ── DeleteTemplate ──────────────────────────────────────────────────────── - [Fact] public void DeleteTemplate_ExistingId_RemovesTemplate() { @@ -110,12 +172,9 @@ public void DeleteTemplate_NonExistentId_DoesNotThrow() GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("Keeper")); GrabTemplateManager.DeleteTemplate("does-not-exist"); - // Should still have the original template Assert.Single(GrabTemplateManager.GetAllTemplates()); } - // ── DuplicateTemplate ───────────────────────────────────────────────────── - [Fact] public void DuplicateTemplate_ValidId_CreatesNewTemplateWithCopyPrefix() { @@ -137,8 +196,6 @@ public void DuplicateTemplate_NonExistentId_ReturnsNull() Assert.Null(copy); } - // ── CreateButtonInfoForTemplate ─────────────────────────────────────────── - [Fact] public void CreateButtonInfoForTemplate_SetsTemplateId() { @@ -151,8 +208,6 @@ public void CreateButtonInfoForTemplate_SetsTemplateId() Assert.Equal(template.Name, button.ButtonText); } - // ── Corrupt JSON robustness ─────────────────────────────────────────────── - [Fact] public void GetAllTemplates_CorruptJson_ReturnsEmptyList() { @@ -162,8 +217,6 @@ public void GetAllTemplates_CorruptJson_ReturnsEmptyList() Assert.Empty(templates); } - // ── GrabTemplate model ──────────────────────────────────────────────────── - [Fact] public void GrabTemplate_IsValid_TrueWhenNameRegionsAndOutputTemplateSet() { @@ -199,8 +252,6 @@ public void GrabTemplate_GetReferencedRegionNumbers_ParsesPlaceholders() Assert.Equal(2, referenced.Count); } - // ── Helper ──────────────────────────────────────────────────────────────── - private static GrabTemplate CreateSampleTemplate(string name) { return new GrabTemplate diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs new file mode 100644 index 00000000..c2965c80 --- /dev/null +++ b/Tests/HistoryServiceTests.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Services; +using Text_Grab.Utilities; + +namespace Tests; + +[Collection("History service")] +public class HistoryServiceTests +{ + private static readonly JsonSerializerOptions HistoryJsonOptions = new() + { + AllowTrailingCommas = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + [WpfFact] + public async Task TextHistory_LazyLoadsAgainAfterRelease() + { + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "text-1", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), + TextContent = "first text history", + SourceMode = TextGrabMode.EditText + } + ]); + + HistoryService historyService = new(); + + Assert.Equal("first text history", historyService.GetLastTextHistory()); + + historyService.ReleaseLoadedHistories(); + + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "text-2", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), + TextContent = "second text history", + SourceMode = TextGrabMode.EditText + } + ]); + + Assert.Equal("second text history", historyService.GetLastTextHistory()); + } + + [WpfFact] + public async Task ImageHistory_LazyLoadsAgainAfterRelease() + { + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-1", + CaptureDateTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), + TextContent = "first image history", + ImagePath = "one.bmp", + SourceMode = TextGrabMode.GrabFrame + } + ]); + + HistoryService historyService = new(); + + Assert.Equal("one.bmp", Assert.Single(historyService.GetRecentGrabs()).ImagePath); + + historyService.ReleaseLoadedHistories(); + + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-2", + CaptureDateTime = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), + TextContent = "second image history", + ImagePath = "two.bmp", + SourceMode = TextGrabMode.Fullscreen + } + ]); + + Assert.Equal("two.bmp", Assert.Single(historyService.GetRecentGrabs()).ImagePath); + Assert.Equal("image-2", historyService.GetLastFullScreenGrabInfo()?.ID); + } + + [WpfFact] + public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarStorage() + { + string inlineWordBorderJson = JsonSerializer.Serialize( + new List + { + new() + { + Word = "hello", + BorderRect = new Rect(1, 2, 30, 40), + LineNumber = 1, + ResultColumnID = 2, + ResultRowID = 3 + } + }, + HistoryJsonOptions); + + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "image-with-borders", + CaptureDateTime = new DateTimeOffset(2024, 1, 3, 12, 0, 0, TimeSpan.Zero), + TextContent = "history with borders", + ImagePath = "borders.bmp", + SourceMode = TextGrabMode.GrabFrame, + WordBorderInfoJson = inlineWordBorderJson + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetRecentGrabs()); + + Assert.Equal(inlineWordBorderJson, historyItem.WordBorderInfoJson); + Assert.Equal("image-with-borders.wordborders.json", historyItem.WordBorderInfoFileName); + + List wordBorderInfos = await historyService.GetWordBorderInfosAsync(historyItem); + WordBorderInfo wordBorderInfo = Assert.Single(wordBorderInfos); + Assert.Equal("hello", wordBorderInfo.Word); + Assert.Equal(30d, wordBorderInfo.BorderRect.Width); + Assert.Equal(40d, wordBorderInfo.BorderRect.Height); + + historyService.ReleaseLoadedHistories(); + + string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); + Assert.Contains("\"WordBorderInfoJson\"", savedHistoryJson); + Assert.Contains("\"WordBorderInfoFileName\"", savedHistoryJson); + + string savedWordBorderJson = await FileUtilities.GetTextFileAsync(historyItem.WordBorderInfoFileName!, FileStorageKind.WithHistory); + Assert.Contains("hello", savedWordBorderJson); + } + + [WpfFact] + public async Task ImageHistory_NormalizesPreviewUiAutomationEntriesToRollbackSafeValues() + { + await SaveHistoryFileAsync( + "HistoryWithImage.json", + [ + new HistoryInfo + { + ID = "uia-preview", + CaptureDateTime = new DateTimeOffset(2024, 1, 4, 12, 0, 0, TimeSpan.Zero), + TextContent = "direct text history", + ImagePath = "uia.bmp", + SourceMode = TextGrabMode.Fullscreen, + LanguageTag = UiAutomationLang.Tag, + LanguageKind = LanguageKind.UiAutomation, + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetRecentGrabs()); + + Assert.True(historyItem.UsedUiAutomation); + Assert.Equal(LanguageKind.Global, historyItem.LanguageKind); + Assert.NotEqual(UiAutomationLang.Tag, historyItem.LanguageTag); + Assert.IsNotType(historyItem.OcrLanguage); + + historyService.WriteHistory(); + historyService.ReleaseLoadedHistories(); + + string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); + Assert.DoesNotContain("\"LanguageKind\": \"UiAutomation\"", savedHistoryJson); + Assert.DoesNotContain($"\"LanguageTag\": \"{UiAutomationLang.Tag}\"", savedHistoryJson); + Assert.Contains("\"UsedUiAutomation\": true", savedHistoryJson); + } + + private static Task SaveHistoryFileAsync(string fileName, List historyItems) + { + string historyJson = JsonSerializer.Serialize(historyItems, HistoryJsonOptions); + return FileUtilities.SaveTextFile(historyJson, fileName, FileStorageKind.WithHistory); + } +} + +[CollectionDefinition("History service", DisableParallelization = true)] +public class HistoryServiceCollectionDefinition +{ +} diff --git a/Tests/LanguageServiceTests.cs b/Tests/LanguageServiceTests.cs index aa1164ed..17109145 100644 --- a/Tests/LanguageServiceTests.cs +++ b/Tests/LanguageServiceTests.cs @@ -1,36 +1,50 @@ using Text_Grab; +using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Properties; using Text_Grab.Services; using Text_Grab.Utilities; using Windows.Globalization; namespace Tests; -public class LanguageServiceTests +[Collection("Settings isolation")] +public class LanguageServiceTests : IDisposable { + private readonly string _originalLastUsedLang; + private readonly bool _originalUiAutomationEnabled; + + public LanguageServiceTests() + { + _originalLastUsedLang = Settings.Default.LastUsedLang; + _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + } + + public void Dispose() + { + Settings.Default.LastUsedLang = _originalLastUsedLang; + Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + } + [Fact] public void GetLanguageTag_WithGlobalLang_ReturnsCorrectTag() { - // Arrange GlobalLang globalLang = new("en-US"); - // Act string tag = LanguageService.GetLanguageTag(globalLang); - // Assert Assert.Equal("en-US", tag); } [Fact] public void GetLanguageTag_WithWindowsAiLang_ReturnsWinAI() { - // Arrange WindowsAiLang windowsAiLang = new(); - // Act string tag = LanguageService.GetLanguageTag(windowsAiLang); - // Assert Assert.Equal("WinAI", tag); } @@ -47,52 +61,40 @@ public void GetLanguageTag_WithUiAutomationLang_ReturnsUiAutomationTag() [Fact] public void GetLanguageTag_WithTessLang_ReturnsRawTag() { - // Arrange TessLang tessLang = new("eng"); - // Act string tag = LanguageService.GetLanguageTag(tessLang); - // Assert Assert.Equal("eng", tag); } [Fact] public void GetLanguageTag_WithLanguage_ReturnsLanguageTag() { - // Arrange Language language = new("en-US"); - // Act string tag = LanguageService.GetLanguageTag(language); - // Assert Assert.Equal("en-US", tag); } [Fact] public void GetLanguageKind_WithGlobalLang_ReturnsGlobal() { - // Arrange GlobalLang globalLang = new("en-US"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(globalLang); - // Assert Assert.Equal(LanguageKind.Global, kind); } [Fact] public void GetLanguageKind_WithWindowsAiLang_ReturnsWindowsAi() { - // Arrange WindowsAiLang windowsAiLang = new(); - // Act LanguageKind kind = LanguageService.GetLanguageKind(windowsAiLang); - // Assert Assert.Equal(LanguageKind.WindowsAi, kind); } @@ -109,69 +111,79 @@ public void GetLanguageKind_WithUiAutomationLang_ReturnsUiAutomation() [Fact] public void GetLanguageKind_WithTessLang_ReturnsTesseract() { - // Arrange TessLang tessLang = new("eng"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(tessLang); - // Assert Assert.Equal(LanguageKind.Tesseract, kind); } [Fact] public void GetLanguageKind_WithLanguage_ReturnsGlobal() { - // Arrange Language language = new("en-US"); - // Act LanguageKind kind = LanguageService.GetLanguageKind(language); - // Assert Assert.Equal(LanguageKind.Global, kind); } [Fact] public void GetLanguageKind_WithUnknownType_ReturnsGlobal() { - // Arrange object unknownLang = "some string"; - // Act LanguageKind kind = LanguageService.GetLanguageKind(unknownLang); - // Assert - Assert.Equal(LanguageKind.Global, kind); // Default fallback + Assert.Equal(LanguageKind.Global, kind); + } + + [Fact] + public void GetPersistedLanguageIdentity_ForUiAutomationUsesRollbackSafeGlobalLanguage() + { + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageService.GetPersistedLanguageIdentity(new UiAutomationLang()); + + Assert.True(usedUiAutomation); + Assert.Equal(LanguageKind.Global, languageKind); + Assert.NotEqual(UiAutomationLang.Tag, languageTag); + } + + [Fact] + public void GetOCRLanguage_WhenUiAutomationWasLastUsedButFeatureIsDisabled_FallsBack() + { + Settings.Default.UiAutomationEnabled = false; + Settings.Default.LastUsedLang = UiAutomationLang.Tag; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + ILanguage language = Singleton.Instance.GetOCRLanguage(); + + Assert.IsNotType(language); } [Fact] public void LanguageService_IsSingleton() { - // Act LanguageService instance1 = Singleton.Instance; LanguageService instance2 = Singleton.Instance; - // Assert Assert.Same(instance1, instance2); } [Fact] public void LanguageUtilities_DelegatesTo_LanguageService() { - // This test ensures backward compatibility - static methods should work - // Arrange & Act GlobalLang globalLang = new("en-US"); string tag = LanguageUtilities.GetLanguageTag(globalLang); LanguageKind kind = LanguageUtilities.GetLanguageKind(globalLang); - // Assert Assert.Equal("en-US", tag); Assert.Equal(LanguageKind.Global, kind); } [Fact] - public void HistoryInfo_OcrLanguage_RehydratesUiAutomationLanguage() + public void HistoryInfo_OcrLanguage_FallsBackForUiAutomationPersistence() { HistoryInfo historyInfo = new() { @@ -179,6 +191,6 @@ public void HistoryInfo_OcrLanguage_RehydratesUiAutomationLanguage() LanguageKind = LanguageKind.UiAutomation, }; - Assert.IsType(historyInfo.OcrLanguage); + Assert.IsNotType(historyInfo.OcrLanguage); } } diff --git a/Tests/SettingsImportExportTests.cs b/Tests/SettingsImportExportTests.cs index 2e37f487..c2191879 100644 --- a/Tests/SettingsImportExportTests.cs +++ b/Tests/SettingsImportExportTests.cs @@ -1,9 +1,14 @@ +using System; using System.IO; using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Services; using Text_Grab.Utilities; namespace Tests; +[Collection("Settings isolation")] public class SettingsImportExportTests { [WpfFact] @@ -145,4 +150,250 @@ public async Task RoundTripSettingsExportImportPreservesAllValues() if (Directory.Exists(modifiedTempDir)) Directory.Delete(modifiedTempDir, true); if (Directory.Exists(reimportedTempDir)) Directory.Delete(reimportedTempDir, true); } + + [WpfFact] + public async Task ManagedJsonSettingWithDataSurvivesRoundTrip() + { + SettingsService settingsService = AppUtilities.TextGrabSettingsService; + StoredRegex[] originalRegexes = settingsService.LoadStoredRegexes(); + + StoredRegex[] testRegexes = + [ + new StoredRegex + { + Id = "export-roundtrip-1", + Name = "Date Pattern", + Pattern = @"\d{4}-\d{2}-\d{2}", + Description = "ISO date for export round-trip test", + } + ]; + settingsService.SaveStoredRegexes(testRegexes); + + string zipPath = string.Empty; + string verifyDir = string.Empty; + + try + { + // Export and confirm the managed setting's file content appears in settings.json + zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + + verifyDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Verify_{Guid.NewGuid()}"); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, verifyDir); + string exportedJson = await File.ReadAllTextAsync(Path.Combine(verifyDir, "settings.json")); + Assert.Contains("export-roundtrip-1", exportedJson); + + // Clear the managed setting to simulate import on a clean machine + settingsService.SaveStoredRegexes([]); + Assert.Empty(settingsService.LoadStoredRegexes()); + + // Import from the previously exported ZIP + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(zipPath); + + // The regex must be restored from the imported data + StoredRegex[] restoredRegexes = settingsService.LoadStoredRegexes(); + StoredRegex restored = Assert.Single(restoredRegexes); + Assert.Equal("export-roundtrip-1", restored.Id); + Assert.Equal(@"\d{4}-\d{2}-\d{2}", restored.Pattern); + } + finally + { + settingsService.SaveStoredRegexes(originalRegexes); + + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (Directory.Exists(verifyDir)) + Directory.Delete(verifyDir, true); + } + } + + [WpfFact] + public async Task ExportedSettingsJsonIncludesManagedSettingKeys() + { + string zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + string tempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Test_{Guid.NewGuid()}"); + + try + { + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir); + string jsonContent = await File.ReadAllTextAsync(Path.Combine(tempDir, "settings.json")); + + // All six managed-JSON setting names must appear as keys in the export + Assert.True(jsonContent.Contains("regexList", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("shortcutKeySets", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("bottomButtonsJson", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("webSearchItemsJson", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("postGrabJSON", StringComparison.OrdinalIgnoreCase)); + Assert.True(jsonContent.Contains("postGrabCheckStates", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + /// + /// Simulates importing a ZIP that was produced by the old (memory-inefficient) app, + /// where managed JSON settings were stored as inline strings inside Properties.Settings + /// rather than in sidecar files. The new import pipeline must route those inline blobs + /// to the correct sidecar files so the SettingsService can load them normally. + /// + [WpfFact] + public async Task LegacyExportWithInlineManagedSettingsIsImportedToSidecarFiles() + { + SettingsService settingsService = AppUtilities.TextGrabSettingsService; + + StoredRegex[] originalRegexes = settingsService.LoadStoredRegexes(); + Dictionary originalCheckStates = settingsService.LoadPostGrabCheckStates(); + + // Build a legacy-style settings.json: managed JSON blobs stored directly as + // string values under camelCase keys, exactly as the old export produced them. + StoredRegex legacyRegex = new() + { + Id = "legacy-regex-001", + Name = "Legacy Invoice", + Pattern = @"INV-\d{5}", + Description = "Imported from legacy export", + }; + string regexArrayJson = JsonSerializer.Serialize(new[] { legacyRegex }); + + Dictionary legacyCheckStates = new() { ["Legacy Action"] = true }; + string checkStatesJson = JsonSerializer.Serialize(legacyCheckStates); + + // The old export wrote settings with camelCase keys and plain string values + // for what are now managed-JSON settings. + Dictionary legacySettings = new() + { + // managed settings stored inline (old behaviour) + ["regexList"] = regexArrayJson, + ["postGrabCheckStates"] = checkStatesJson, + // a normal boolean setting to confirm regular settings still import + ["correctErrors"] = false, + }; + + string legacyJson = JsonSerializer.Serialize(legacySettings, new JsonSerializerOptions { WriteIndented = true }); + + string legacyDir = Path.Combine(Path.GetTempPath(), $"TextGrab_LegacyDir_{Guid.NewGuid()}"); + string legacyZipPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Legacy_{Guid.NewGuid()}.zip"); + Directory.CreateDirectory(legacyDir); + + try + { + await File.WriteAllTextAsync(Path.Combine(legacyDir, "settings.json"), legacyJson); + System.IO.Compression.ZipFile.CreateFromDirectory(legacyDir, legacyZipPath); + + // Start from a clean state so the assertion is unambiguous + settingsService.SaveStoredRegexes([]); + settingsService.SavePostGrabCheckStates(new Dictionary()); + Assert.Empty(settingsService.LoadStoredRegexes()); + Assert.Empty(settingsService.LoadPostGrabCheckStates()); + + // Act: import the legacy ZIP + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(legacyZipPath); + + // Assert – array-type managed setting + StoredRegex[] importedRegexes = settingsService.LoadStoredRegexes(); + StoredRegex importedRegex = Assert.Single(importedRegexes); + Assert.Equal("legacy-regex-001", importedRegex.Id); + Assert.Equal(@"INV-\d{5}", importedRegex.Pattern); + + // Assert – dictionary-type managed setting + Dictionary importedCheckStates = settingsService.LoadPostGrabCheckStates(); + Assert.True(importedCheckStates.ContainsKey("Legacy Action")); + Assert.True(importedCheckStates["Legacy Action"]); + + // Assert – a plain (non-managed) setting came through too + Assert.False(AppUtilities.TextGrabSettings.CorrectErrors); + } + finally + { + // Restore originals regardless of pass/fail + settingsService.SaveStoredRegexes(originalRegexes); + settingsService.SavePostGrabCheckStates(originalCheckStates); + AppUtilities.TextGrabSettings.CorrectErrors = true; + + if (File.Exists(legacyZipPath)) + File.Delete(legacyZipPath); + if (Directory.Exists(legacyDir)) + Directory.Delete(legacyDir, true); + } + } + + [WpfFact] + public async Task ExportImportRoundTripsGrabTemplatesAndTemplateImages() + { + string tempTemplateFile = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Export_{Guid.NewGuid():N}.json"); + string tempImagesFolder = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Images_{Guid.NewGuid():N}"); + string zipPath = string.Empty; + string originalGrabTemplatesJson = Settings.Default.GrabTemplatesJSON; + string? originalTestFilePath = GrabTemplateManager.TestFilePath; + string? originalTestImagesFolderPath = GrabTemplateManager.TestImagesFolderPath; + bool? originalTestPreferFileBackedMode = GrabTemplateManager.TestPreferFileBackedMode; + + GrabTemplateManager.TestFilePath = tempTemplateFile; + GrabTemplateManager.TestImagesFolderPath = tempImagesFolder; + GrabTemplateManager.TestPreferFileBackedMode = false; + + try + { + Directory.CreateDirectory(tempImagesFolder); + + string referenceImagePath = Path.Combine(tempImagesFolder, "reference.png"); + await File.WriteAllBytesAsync(referenceImagePath, [1, 2, 3, 4]); + + GrabTemplate template = new() + { + Id = "template-export-1", + Name = "Invoice Template", + OutputTemplate = "{1}", + SourceImagePath = referenceImagePath, + Regions = + [ + new TemplateRegion + { + RegionNumber = 1, + Label = "Amount", + RatioLeft = 0.1, + RatioTop = 0.1, + RatioWidth = 0.3, + RatioHeight = 0.1, + } + ] + }; + + GrabTemplateManager.SaveTemplates([template]); + + zipPath = await SettingsImportExportUtilities.ExportSettingsToZipAsync(includeHistory: false); + + GrabTemplateManager.SaveTemplates([]); + + if (File.Exists(referenceImagePath)) + File.Delete(referenceImagePath); + + await SettingsImportExportUtilities.ImportSettingsFromZipAsync(zipPath); + + GrabTemplate restoredTemplate = Assert.Single(GrabTemplateManager.GetAllTemplates()); + Assert.Equal(template.Id, restoredTemplate.Id); + Assert.Equal(template.Name, restoredTemplate.Name); + Assert.Contains(template.Id, Settings.Default.GrabTemplatesJSON); + Assert.True(File.Exists(referenceImagePath)); + } + finally + { + GrabTemplateManager.TestFilePath = originalTestFilePath; + GrabTemplateManager.TestImagesFolderPath = originalTestImagesFolderPath; + GrabTemplateManager.TestPreferFileBackedMode = originalTestPreferFileBackedMode; + Settings.Default.GrabTemplatesJSON = originalGrabTemplatesJson; + Settings.Default.Save(); + + if (File.Exists(zipPath)) + File.Delete(zipPath); + if (File.Exists(tempTemplateFile)) + File.Delete(tempTemplateFile); + if (Directory.Exists(tempImagesFolder)) + Directory.Delete(tempImagesFolder, true); + } + } } diff --git a/Tests/SettingsIsolationCollection.cs b/Tests/SettingsIsolationCollection.cs new file mode 100644 index 00000000..06d87e14 --- /dev/null +++ b/Tests/SettingsIsolationCollection.cs @@ -0,0 +1,6 @@ +namespace Tests; + +[CollectionDefinition("Settings isolation", DisableParallelization = true)] +public class SettingsIsolationCollectionDefinition +{ +} diff --git a/Tests/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs new file mode 100644 index 00000000..bab8d29e --- /dev/null +++ b/Tests/SettingsServiceTests.cs @@ -0,0 +1,154 @@ +using System.IO; +using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Services; + +namespace Tests; + +public class SettingsServiceTests : IDisposable +{ + private readonly string _tempFolder; + + public SettingsServiceTests() + { + _tempFolder = Path.Combine(Path.GetTempPath(), $"TextGrab_SettingsService_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempFolder); + } + + public void Dispose() + { + if (Directory.Exists(_tempFolder)) + Directory.Delete(_tempFolder, true); + } + + [Fact] + public void LoadStoredRegexes_DefaultModePrefersLegacyAndKeepsLegacyPopulated() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false, + RegexList = SerializeRegexes("legacy-regex") + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("sidecar-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("legacy-regex", loadedRegex.Id); + Assert.Contains("legacy-regex", settings.RegexList); + Assert.Contains("legacy-regex", File.ReadAllText(regexFilePath)); + } + + [Fact] + public void LoadStoredRegexes_DefaultModeBackfillsLegacyFromSidecarWhenNeeded() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false, + RegexList = string.Empty + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("recovered-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("recovered-regex", loadedRegex.Id); + Assert.Contains("recovered-regex", settings.RegexList); + Assert.Equal(File.ReadAllText(regexFilePath), settings.RegexList); + } + + [Fact] + public void LoadStoredRegexes_FileBackedModePrefersSidecarAndBackfillsLegacy() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = true, + RegexList = SerializeRegexes("legacy-regex") + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("sidecar-regex")); + + SettingsService service = CreateService(settings); + + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); + + Assert.Equal("sidecar-regex", loadedRegex.Id); + Assert.Contains("sidecar-regex", settings.RegexList); + Assert.Contains("sidecar-regex", File.ReadAllText(regexFilePath)); + } + + [Fact] + public void SavePostGrabCheckStates_FileBackedModeWritesBothStores() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = true + }; + SettingsService service = CreateService(settings); + + service.SavePostGrabCheckStates(new Dictionary + { + ["Fix GUIDs"] = true + }); + + string filePath = Path.Combine(_tempFolder, "PostGrabCheckStates.json"); + Assert.Contains("Fix GUIDs", settings.PostGrabCheckStates); + Assert.True(File.Exists(filePath)); + Assert.Contains("Fix GUIDs", File.ReadAllText(filePath)); + Assert.True(service.LoadPostGrabCheckStates()["Fix GUIDs"]); + } + + [Fact] + public void ClearingManagedSettingClearsLegacyAndSidecar() + { + Settings settings = new() + { + EnableFileBackedManagedSettings = false + }; + SettingsService service = CreateService(settings); + + service.SaveStoredRegexes( + [ + new StoredRegex + { + Id = "clear-me", + Name = "Clear Me", + Pattern = ".*" + } + ]); + + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + Assert.NotEmpty(settings.RegexList); + Assert.True(File.Exists(regexFilePath)); + + settings.RegexList = string.Empty; + + Assert.Equal(string.Empty, settings.RegexList); + Assert.False(File.Exists(regexFilePath)); + Assert.Empty(service.LoadStoredRegexes()); + } + + private SettingsService CreateService(Settings settings) => + new( + settings, + localSettings: null, + managedJsonSettingsFolderPath: _tempFolder, + saveClassicSettingsChanges: false); + + private static string SerializeRegexes(string id) => + JsonSerializer.Serialize(new[] + { + new StoredRegex + { + Id = id, + Name = $"{id} name", + Pattern = @"INV-\d+", + Description = "transition test pattern" + } + }); +} diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 230fa998..a1c719d6 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -199,8 +199,11 @@ False + + False + - True + False True @@ -240,4 +243,4 @@ - \ No newline at end of file + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index bfbd56b8..85738b95 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -347,8 +347,6 @@ private async void appStartup(object sender, StartupEventArgs e) // Register COM server and activator type bool handledArgument = false; - await Singleton.Instance.LoadHistories(); - ToastNotificationManagerCompat.OnActivated += toastArgs => { LaunchFromToast(toastArgs); diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index c6af55aa..851735d3 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -620,27 +620,12 @@ private bool IsPatternAlreadySaved(string pattern) if (string.IsNullOrWhiteSpace(pattern)) return false; - try - { - Settings settings = AppUtilities.TextGrabSettings; - string regexListJson = settings.RegexList; - - if (string.IsNullOrWhiteSpace(regexListJson)) - return false; - - StoredRegex[]? savedPatterns = JsonSerializer.Deserialize(regexListJson); - - if (savedPatterns is null || savedPatterns.Length == 0) - return false; - - // Check if any saved pattern matches the current pattern exactly - return savedPatterns.Any(p => p.Pattern == pattern); - } - catch (Exception) - { - // If there's any error loading patterns, assume it's not saved + StoredRegex[] savedPatterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (savedPatterns.Length == 0) return false; - } + + // Check if any saved pattern matches the current pattern exactly + return savedPatterns.Any(p => p.Pattern == pattern); } internal void FindByPattern(ExtractedPattern pattern, int? precisionLevel = null) diff --git a/Text-Grab/Controls/RegexManager.xaml.cs b/Text-Grab/Controls/RegexManager.xaml.cs index 3748c3dc..5cf6b564 100644 --- a/Text-Grab/Controls/RegexManager.xaml.cs +++ b/Text-Grab/Controls/RegexManager.xaml.cs @@ -1,12 +1,10 @@ -using Humanizer; +using Humanizer; using System; using System.Collections.ObjectModel; using System.Linq; -using System.Text.Json; using System.Text.RegularExpressions; using System.Windows; using Text_Grab.Models; -using Text_Grab.Properties; using Text_Grab.Utilities; using Wpf.Ui.Controls; @@ -14,8 +12,6 @@ namespace Text_Grab.Controls; public partial class RegexManager : FluentWindow { - private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - public EditTextWindow? SourceEditTextWindow; private ObservableCollection RegexPatterns { get; set; } = []; @@ -33,26 +29,9 @@ private void Window_Loaded(object sender, RoutedEventArgs e) private void LoadRegexPatterns() { RegexPatterns.Clear(); - - // Load from settings - string regexListJson = DefaultSettings.RegexList; - - if (!string.IsNullOrWhiteSpace(regexListJson)) - { - try - { - StoredRegex[]? loadedPatterns = JsonSerializer.Deserialize(regexListJson); - if (loadedPatterns is not null) - { - foreach (StoredRegex pattern in loadedPatterns) - RegexPatterns.Add(pattern); - } - } - catch (JsonException) - { - // If deserialization fails, start fresh - } - } + StoredRegex[] loadedPatterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + foreach (StoredRegex pattern in loadedPatterns) + RegexPatterns.Add(pattern); // Add default patterns if list is empty if (RegexPatterns.Count == 0) @@ -66,16 +45,7 @@ private void LoadRegexPatterns() private void SaveRegexPatterns() { - try - { - string json = JsonSerializer.Serialize(RegexPatterns.ToArray()); - DefaultSettings.RegexList = json; - DefaultSettings.Save(); - } - catch (Exception) - { - // Handle save error silently or show message - } + AppUtilities.TextGrabSettingsService.SaveStoredRegexes(RegexPatterns); } private void RegexDataGrid_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 5c422553..69f3e52c 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -41,6 +41,9 @@ public HistoryInfo() public LanguageKind LanguageKind { get; set; } = LanguageKind.Global; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool UsedUiAutomation { get; set; } + public bool HasCalcPaneOpen { get; set; } = false; public int CalcPaneWidth { get; set; } = 0; @@ -50,15 +53,18 @@ public ILanguage OcrLanguage { get { - if (string.IsNullOrWhiteSpace(LanguageTag)) + (string normalizedLanguageTag, LanguageKind normalizedLanguageKind, _) = + LanguageUtilities.NormalizePersistedLanguageIdentity(LanguageKind, LanguageTag, UsedUiAutomation); + + if (string.IsNullOrWhiteSpace(normalizedLanguageTag)) return new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")); - return LanguageKind switch + return normalizedLanguageKind switch { - LanguageKind.Global => new GlobalLang(new Language(LanguageTag)), - LanguageKind.Tesseract => new TessLang(LanguageTag), + LanguageKind.Global => new GlobalLang(new Language(normalizedLanguageTag)), + LanguageKind.Tesseract => new TessLang(normalizedLanguageTag), LanguageKind.WindowsAi => new WindowsAiLang(), - LanguageKind.UiAutomation => new UiAutomationLang(), + LanguageKind.UiAutomation => CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(), _ => new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")), }; } @@ -85,7 +91,11 @@ public Rect PositionRect public string TextContent { get; set; } = string.Empty; - public string WordBorderInfoJson { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WordBorderInfoJson { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WordBorderInfoFileName { get; set; } public string RectAsString { get; set; } = string.Empty; @@ -93,6 +103,21 @@ public Rect PositionRect #region Public Methods + public void ClearTransientImage() + { + // Do not Dispose() here — the bitmap may still be in use by a + // fire-and-forget SaveImageFile task (the packaged path is async). + // Nulling the reference lets the GC collect once all consumers finish. + // The HistoryService.DisposeCachedBitmap() path handles deterministic + // cleanup of the captured fullscreen bitmap via its GDI handle. + ImageContent = null; + } + + public void ClearTransientWordBorderData() + { + WordBorderInfoJson = null; + } + public static bool operator !=(HistoryInfo? left, HistoryInfo? right) { return !(left == right); diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab/Models/UiAutomationLang.cs index e7cc18d3..a819e64d 100644 --- a/Text-Grab/Models/UiAutomationLang.cs +++ b/Text-Grab/Models/UiAutomationLang.cs @@ -6,20 +6,21 @@ namespace Text_Grab.Models; public class UiAutomationLang : ILanguage { public const string Tag = "Direct-Txt"; + public const string BetaDisplayName = "Direct Text (Beta)"; public string AbbreviatedName => "DT"; - public string DisplayName => "Direct Text"; + public string DisplayName => BetaDisplayName; public string CurrentInputMethodLanguageTag => string.Empty; - public string CultureDisplayName => "Direct Text"; + public string CultureDisplayName => BetaDisplayName; public string LanguageTag => Tag; public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr; - public string NativeName => "Direct Text"; + public string NativeName => BetaDisplayName; public string Script => string.Empty; } diff --git a/Text-Grab/Models/WebSearchUrlModel.cs b/Text-Grab/Models/WebSearchUrlModel.cs index 7053d22f..e70caaa8 100644 --- a/Text-Grab/Models/WebSearchUrlModel.cs +++ b/Text-Grab/Models/WebSearchUrlModel.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using System.Text.Json; using Text_Grab.Utilities; namespace Text_Grab.Models; @@ -79,11 +78,8 @@ private static List GetDefaultWebSearchUrls() public static List GetWebSearchUrls() { - string json = AppUtilities.TextGrabSettings.WebSearchItemsJson; - if (string.IsNullOrWhiteSpace(json)) - return GetDefaultWebSearchUrls(); - List? webSearchUrls = JsonSerializer.Deserialize>(json); - if (webSearchUrls is null || webSearchUrls.Count == 0) + List webSearchUrls = AppUtilities.TextGrabSettingsService.LoadWebSearchUrls(); + if (webSearchUrls.Count == 0) return GetDefaultWebSearchUrls(); return webSearchUrls; @@ -91,8 +87,6 @@ public static List GetWebSearchUrls() public static void SaveWebSearchUrls(List webSearchUrls) { - string json = JsonSerializer.Serialize(webSearchUrls); - AppUtilities.TextGrabSettings.WebSearchItemsJson = json; - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveWebSearchUrls(webSearchUrls); } } diff --git a/Text-Grab/Pages/DangerSettings.xaml b/Text-Grab/Pages/DangerSettings.xaml index c665046e..95ccd2ad 100644 --- a/Text-Grab/Pages/DangerSettings.xaml +++ b/Text-Grab/Pages/DangerSettings.xaml @@ -70,6 +70,39 @@ ButtonText="Import Settings" Click="ImportSettingsButton_Click" /> + + + + + + + Backup your settings + + + + + Enable experimental file-backed settings storage (restart required) + + + + + Check CPU Architecture before enabling Windows Local AI model features diff --git a/Text-Grab/Pages/DangerSettings.xaml.cs b/Text-Grab/Pages/DangerSettings.xaml.cs index 945a1f67..b04e99c6 100644 --- a/Text-Grab/Pages/DangerSettings.xaml.cs +++ b/Text-Grab/Pages/DangerSettings.xaml.cs @@ -2,6 +2,7 @@ using Microsoft.Win32; using System; using System.Diagnostics; +using System.Threading.Tasks; using System.Windows; using Text_Grab.Properties; using Text_Grab.Services; @@ -15,6 +16,7 @@ namespace Text_Grab.Pages; public partial class DangerSettings : System.Windows.Controls.Page { private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private bool _loadingDangerSettings; public DangerSettings() { @@ -23,7 +25,10 @@ public DangerSettings() private void Page_Loaded(object sender, RoutedEventArgs e) { + _loadingDangerSettings = true; OverrideArchCheckWinAI.IsChecked = DefaultSettings.OverrideAiArchCheck; + EnableFileBackedManagedSettingsToggle.IsChecked = DefaultSettings.EnableFileBackedManagedSettings; + _loadingDangerSettings = false; } private async void ExportBugReportButton_Click(object sender, RoutedEventArgs e) @@ -92,6 +97,11 @@ private async void ClearHistoryButton_Click(object sender, RoutedEventArgs e) } private async void ExportSettingsButton_Click(object sender, RoutedEventArgs e) + { + await ExportSettingsAsync(); + } + + private async Task ExportSettingsAsync() { try { @@ -123,6 +133,11 @@ private async void ExportSettingsButton_Click(object sender, RoutedEventArgs e) } } + private async void BackupSettingsHyperlink_Click(object sender, RoutedEventArgs e) + { + await ExportSettingsAsync(); + } + private async void ImportSettingsButton_Click(object sender, RoutedEventArgs e) { try @@ -193,4 +208,28 @@ private void OverrideArchCheckWinAI_Click(object sender, RoutedEventArgs e) DefaultSettings.OverrideAiArchCheck = ts.IsChecked ?? false; DefaultSettings.Save(); } + + private async void EnableFileBackedManagedSettingsToggle_Checked(object sender, RoutedEventArgs e) + { + if (_loadingDangerSettings) + return; + + bool isEnabled = EnableFileBackedManagedSettingsToggle.IsChecked is true; + if (DefaultSettings.EnableFileBackedManagedSettings == isEnabled) + return; + + DefaultSettings.EnableFileBackedManagedSettings = isEnabled; + DefaultSettings.Save(); + + string message = isEnabled + ? "Experimental file-backed settings storage will be preferred after you restart Text Grab. Restart is required because Text Grab applies this storage preference when it starts so it can safely keep the legacy strings and file-backed copies in sync. Backup your settings before using it if you have not already." + : "Legacy settings storage will be preferred again after you restart Text Grab."; + + await new Wpf.Ui.Controls.MessageBox + { + Title = "Restart Required", + Content = message, + CloseButtonText = "OK" + }.ShowDialogAsync(); + } } diff --git a/Text-Grab/Pages/LanguageSettings.xaml b/Text-Grab/Pages/LanguageSettings.xaml index bd668561..cc473f19 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml +++ b/Text-Grab/Pages/LanguageSettings.xaml @@ -57,70 +57,6 @@ Content="Learn more about Windows AI Foundry" NavigateUri="https://learn.microsoft.com/en-us/windows/ai/apis/" /> - - - When the Direct Text language is selected, Text Grab will try to read native accessibility text from live application controls before falling back to OCR. - - - - Show Direct Text as a language option - - - - - Fall back to OCR when UI Automation returns no text - - - - - Prefer the focused UI element before scanning the rest of the window - - - - - Include offscreen Direct Text elements - - - - - - - - + + + When the Direct Text (Beta) language is selected, Text Grab will try to read native accessibility text from live application controls before falling back to OCR. + + + + + + + Show Direct Text (Beta) as a language option + + + + + + Fall back to OCR when UI Automation returns no text + + + + + Prefer the focused UI element before scanning the rest of the window + + + + + Include offscreen Direct Text elements + + + + + + + + diff --git a/Text-Grab/Pages/LanguageSettings.xaml.cs b/Text-Grab/Pages/LanguageSettings.xaml.cs index 0f2bf8d3..83f3628a 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml.cs +++ b/Text-Grab/Pages/LanguageSettings.xaml.cs @@ -134,6 +134,8 @@ private void LoadUiAutomationSettings() UiAutomationTraversalModeComboBox.SelectedItem = traversalMode; else UiAutomationTraversalModeComboBox.SelectedItem = UiAutomationTraversalMode.Balanced; + + UpdateUiAutomationControlState(); } private async void InstallButton_Click(object sender, RoutedEventArgs e) @@ -169,6 +171,7 @@ private void UiAutomationEnabledToggle_Checked(object sender, RoutedEventArgs e) DefaultSettings.UiAutomationEnabled = UiAutomationEnabledToggle.IsChecked is true; DefaultSettings.Save(); LanguageUtilities.InvalidateAllCaches(); + UpdateUiAutomationControlState(); } private void UiAutomationFallbackToggle_Checked(object sender, RoutedEventArgs e) @@ -208,6 +211,13 @@ private void UiAutomationTraversalModeComboBox_SelectionChanged(object sender, S DefaultSettings.Save(); } + private void UpdateUiAutomationControlState() + { + UiAutomationAdvancedOptionsPanel.Visibility = DefaultSettings.UiAutomationEnabled + ? Visibility.Visible + : Visibility.Collapsed; + } + public async Task CopyFileWithElevatedPermissions(string sourcePath, string destinationPath) { string arguments = $"/c copy \"{sourcePath}\" \"{destinationPath}\""; diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 6d174672..073f399f 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -793,7 +793,19 @@ public bool OverrideAiArchCheck { [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("True")] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool EnableFileBackedManagedSettings { + get { + return ((bool)(this["EnableFileBackedManagedSettings"])); + } + set { + this["EnableFileBackedManagedSettings"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] public bool UiAutomationEnabled { get { return ((bool)(this["UiAutomationEnabled"])); diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index ec6032ba..be4eb667 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -194,8 +194,11 @@ False + + False + - True + False True @@ -234,4 +237,4 @@ False - \ No newline at end of file + diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index a3f42fcf..20e81efb 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -24,6 +24,9 @@ public class HistoryService private static readonly int maxHistoryTextOnly = 100; private static readonly int maxHistoryWithImages = 10; + private const string WordBorderInfoFileSuffix = ".wordborders.json"; + private static readonly TimeSpan historyCacheCheckInterval = TimeSpan.FromMinutes(1); + private static readonly TimeSpan historyCacheIdleLifetime = TimeSpan.FromMinutes(2); private static readonly JsonSerializerOptions HistoryJsonOptions = new() { AllowTrailingCommas = true, @@ -33,7 +36,12 @@ public class HistoryService private List HistoryTextOnly = []; private List HistoryWithImage = []; private readonly DispatcherTimer saveTimer = new(); + private readonly DispatcherTimer historyCacheReleaseTimer = new(); private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private bool _textHistoryLoaded; + private bool _imageHistoryLoaded; + private bool _hasPendingWrite; + private DateTimeOffset _lastHistoryAccessUtc = DateTimeOffset.MinValue; #endregion Fields #region Constructors @@ -42,6 +50,9 @@ public HistoryService() { saveTimer.Interval = new(0, 0, 0, 0, 500); saveTimer.Tick += SaveTimer_Tick; + + historyCacheReleaseTimer.Interval = historyCacheCheckInterval; + historyCacheReleaseTimer.Tick += HistoryCacheReleaseTimer_Tick; } #endregion Constructors @@ -57,49 +68,47 @@ public HistoryService() public void CacheLastBitmap(Bitmap bmp) { - if (_cachedBitmapHandle is nint bmpH) - { - NativeMethods.DeleteObject(bmpH); - _cachedBitmapHandle = null; - } - - CachedBitmap?.Dispose(); + DisposeCachedBitmap(); CachedBitmap = bmp; _cachedBitmapHandle = bmp.GetHbitmap(); } public void DeleteHistory() { - HistoryWithImage.Clear(); - HistoryTextOnly.Clear(); - - if (_cachedBitmapHandle is nint bmpH) - { - NativeMethods.DeleteObject(bmpH); - CachedBitmap = null; - _cachedBitmapHandle = null; - } + saveTimer.Stop(); + historyCacheReleaseTimer.Stop(); + _hasPendingWrite = false; + ReleaseLoadedHistoriesCore(); + DisposeCachedBitmap(); FileUtilities.TryDeleteHistoryDirectory(); } public List GetEditWindows() { - return HistoryTextOnly; + EnsureTextHistoryLoaded(); + TouchHistoryCache(); + return [.. HistoryTextOnly]; } public HistoryInfo? GetLastFullScreenGrabInfo() { + EnsureImageHistoryLoaded(); + TouchHistoryCache(); return HistoryWithImage.Where(h => h.SourceMode == TextGrabMode.Fullscreen).LastOrDefault(); } public bool HasAnyFullscreenHistory() { + EnsureImageHistoryLoaded(); + TouchHistoryCache(); return HistoryWithImage.Any(h => h.SourceMode == TextGrabMode.Fullscreen); } public bool GetLastHistoryAsGrabFrame() { + EnsureImageHistoryLoaded(); + TouchHistoryCache(); HistoryInfo? lastHistoryItem = HistoryWithImage.LastOrDefault(); if (lastHistoryItem is not HistoryInfo historyInfo) @@ -114,6 +123,8 @@ public bool GetLastHistoryAsGrabFrame() public string GetLastTextHistory() { + EnsureTextHistoryLoaded(); + TouchHistoryCache(); HistoryInfo? lastHistoryItem = HistoryTextOnly.LastOrDefault(); if (lastHistoryItem is not HistoryInfo historyInfo) @@ -124,18 +135,41 @@ public string GetLastTextHistory() public List GetRecentGrabs() { - return HistoryWithImage; + EnsureImageHistoryLoaded(); + TouchHistoryCache(); + return [.. HistoryWithImage]; } public bool HasAnyHistoryWithImages() { + EnsureImageHistoryLoaded(); + TouchHistoryCache(); return HistoryWithImage.Count > 0; } public async Task LoadHistories() { - HistoryTextOnly = await LoadHistory(nameof(HistoryTextOnly)); - HistoryWithImage = await LoadHistory(nameof(HistoryWithImage)); + saveTimer.Stop(); + historyCacheReleaseTimer.Stop(); + _hasPendingWrite = false; + ReleaseLoadedHistoriesCore(); + + HistoryTextOnly = await LoadHistoryAsync(nameof(HistoryTextOnly)); + _textHistoryLoaded = true; + NormalizeHistoryIds(HistoryTextOnly); + if (NormalizeHistoryCompatibilityData(HistoryTextOnly)) + MarkHistoryDirty(); + + HistoryWithImage = await LoadHistoryAsync(nameof(HistoryWithImage)); + _imageHistoryLoaded = true; + NormalizeHistoryIds(HistoryWithImage); + if (NormalizeHistoryCompatibilityData(HistoryWithImage)) + MarkHistoryDirty(); + + if (EnsureWordBorderSidecarFiles(HistoryWithImage)) + MarkHistoryDirty(); + + TouchHistoryCache(); } public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) @@ -160,9 +194,18 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) continue; MenuItem menuItem = new(); + string historyId = history.ID; menuItem.Click += (object sender, RoutedEventArgs args) => { - GrabFrame grabFrame = new(history); + HistoryInfo? selectedHistory = GetImageHistoryById(historyId); + + if (selectedHistory is null) + { + menuItem.IsEnabled = false; + return; + } + + GrabFrame grabFrame = new(selectedHistory); try { grabFrame.Show(); } catch { menuItem.IsEnabled = false; } }; @@ -177,6 +220,8 @@ public void SaveToHistory(GrabFrame grabFrameToSave) if (!DefaultSettings.UseHistory) return; + EnsureImageHistoryLoaded(); + TouchHistoryCache(); HistoryInfo historyInfo = grabFrameToSave.AsHistoryItem(); string imgRandomName = Guid.NewGuid().ToString(); HistoryInfo? prevHistory = string.IsNullOrEmpty(historyInfo.ID) @@ -196,18 +241,23 @@ public void SaveToHistory(GrabFrame grabFrameToSave) ? $"{imgRandomName}.bmp" : prevHistory.ImagePath; HistoryWithImage.Remove(prevHistory); + prevHistory.ClearTransientImage(); + prevHistory.ClearTransientWordBorderData(); } if (string.IsNullOrEmpty(historyInfo.ID)) historyInfo.ID = Guid.NewGuid().ToString(); + NormalizeHistoryCompatibilityData(historyInfo); + PersistWordBorderData(historyInfo); + if (historyInfo.ImageContent is not null && !string.IsNullOrWhiteSpace(historyInfo.ImagePath)) FileUtilities.SaveImageFile(historyInfo.ImageContent, historyInfo.ImagePath, FileStorageKind.WithHistory); + historyInfo.ClearTransientImage(); HistoryWithImage.Add(historyInfo); - saveTimer.Stop(); - saveTimer.Start(); + MarkHistoryDirty(); } public void SaveToHistory(HistoryInfo infoFromFullscreenGrab) @@ -215,23 +265,26 @@ public void SaveToHistory(HistoryInfo infoFromFullscreenGrab) if (!DefaultSettings.UseHistory || infoFromFullscreenGrab.ImageContent is null) return; + EnsureImageHistoryLoaded(); + TouchHistoryCache(); + + if (string.IsNullOrWhiteSpace(infoFromFullscreenGrab.ID)) + infoFromFullscreenGrab.ID = Guid.NewGuid().ToString(); + string imgRandomName = Guid.NewGuid().ToString(); FileUtilities.SaveImageFile(infoFromFullscreenGrab.ImageContent, $"{imgRandomName}.bmp", FileStorageKind.WithHistory); infoFromFullscreenGrab.ImagePath = $"{imgRandomName}.bmp"; + NormalizeHistoryCompatibilityData(infoFromFullscreenGrab); + PersistWordBorderData(infoFromFullscreenGrab); + infoFromFullscreenGrab.ClearTransientImage(); HistoryWithImage.Add(infoFromFullscreenGrab); - if (_cachedBitmapHandle is nint bmpH) - { - NativeMethods.DeleteObject(bmpH); - CachedBitmap = null; - _cachedBitmapHandle = null; - } + DisposeCachedBitmap(); - saveTimer.Stop(); - saveTimer.Start(); + MarkHistoryDirty(); } public void SaveToHistory(EditTextWindow etwToSave) @@ -239,7 +292,10 @@ public void SaveToHistory(EditTextWindow etwToSave) if (!DefaultSettings.UseHistory) return; + EnsureTextHistoryLoaded(); + TouchHistoryCache(); HistoryInfo historyInfo = etwToSave.AsHistoryItem(); + NormalizeHistoryCompatibilityData(historyInfo); foreach (HistoryInfo inHistoryItem in HistoryTextOnly) { @@ -249,49 +305,148 @@ public void SaveToHistory(EditTextWindow etwToSave) if (inHistoryItem.TextContent == historyInfo.TextContent) { inHistoryItem.CaptureDateTime = DateTimeOffset.Now; + MarkHistoryDirty(); return; } } HistoryTextOnly.Add(historyInfo); - saveTimer.Stop(); - saveTimer.Start(); + MarkHistoryDirty(); } public void WriteHistory() { - if (HistoryTextOnly.Count > 0) + if (!_hasPendingWrite) + return; + + if (_textHistoryLoaded) + { + NormalizeHistoryCompatibilityData(HistoryTextOnly); WriteHistoryFiles(HistoryTextOnly, nameof(HistoryTextOnly), maxHistoryTextOnly); + } - if (HistoryWithImage.Count > 0) + if (_imageHistoryLoaded) { ClearOldImages(); + NormalizeHistoryCompatibilityData(HistoryWithImage); + PersistWordBorderData(HistoryWithImage); WriteHistoryFiles(HistoryWithImage, nameof(HistoryWithImage), maxHistoryWithImages); + DeleteUnusedWordBorderFiles(HistoryWithImage); } + + _hasPendingWrite = false; } public void RemoveTextHistoryItem(HistoryInfo historyItem) { + EnsureTextHistoryLoaded(); + TouchHistoryCache(); HistoryTextOnly.Remove(historyItem); - saveTimer.Stop(); - saveTimer.Start(); + MarkHistoryDirty(); } public void RemoveImageHistoryItem(HistoryInfo historyItem) { + EnsureImageHistoryLoaded(); + TouchHistoryCache(); HistoryWithImage.Remove(historyItem); + historyItem.ClearTransientImage(); + historyItem.ClearTransientWordBorderData(); + DeleteHistoryArtifacts(historyItem); - saveTimer.Stop(); - saveTimer.Start(); + MarkHistoryDirty(); + } + + public HistoryInfo? GetImageHistoryById(string historyId) + { + if (string.IsNullOrWhiteSpace(historyId)) + return null; + + EnsureImageHistoryLoaded(); + TouchHistoryCache(); + return HistoryWithImage.FirstOrDefault(history => history.ID == historyId); + } + + public HistoryInfo? GetTextHistoryById(string historyId) + { + if (string.IsNullOrWhiteSpace(historyId)) + return null; + + EnsureTextHistoryLoaded(); + TouchHistoryCache(); + return HistoryTextOnly.FirstOrDefault(history => history.ID == historyId); + } + + public async Task> GetWordBorderInfosAsync(HistoryInfo history) + { + TouchHistoryCache(); + + if (!string.IsNullOrWhiteSpace(history.WordBorderInfoFileName)) + { + // Sanitize the persisted file name to prevent path traversal outside the history directory + string sanitizedFileName = Path.GetFileName(history.WordBorderInfoFileName); + + if (!string.IsNullOrWhiteSpace(sanitizedFileName) + && string.Equals(Path.GetExtension(sanitizedFileName), ".json", StringComparison.OrdinalIgnoreCase)) + { + try + { + string historyBasePath = await FileUtilities.GetPathToHistory(); + string wordBorderInfoPath = Path.Combine(historyBasePath, sanitizedFileName); + + if (File.Exists(wordBorderInfoPath)) + { + await using FileStream wordBorderInfoStream = File.OpenRead(wordBorderInfoPath); + List? wordBorderInfos = + await JsonSerializer.DeserializeAsync>(wordBorderInfoStream, HistoryJsonOptions); + + if (wordBorderInfos is not null) + return wordBorderInfos; + } + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read word border info file for history item '{history.ID}': {ex}"); + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize word border info file for history item '{history.ID}': {ex}"); + } + } + } + + if (string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) + return []; + + try + { + List? inlineWordBorderInfos = + JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); + + return inlineWordBorderInfos ?? []; + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize inline word border info for history item '{history.ID}': {ex}"); + return []; + } + } + + public void ReleaseLoadedHistories() + { + if (_hasPendingWrite) + WriteHistory(); + + ReleaseLoadedHistoriesCore(); } #endregion Public Methods #region Private Methods - private static async Task> LoadHistory(string fileName) + private static async Task> LoadHistoryAsync(string fileName) { string rawText = await FileUtilities.GetTextFileAsync($"{fileName}.json", FileStorageKind.WithHistory); @@ -335,24 +490,300 @@ private void ClearOldImages() HistoryWithImage.RemoveAt(0); foreach (HistoryInfo infoItem in imagesToRemove) - { - if (File.Exists(infoItem.ImagePath)) - File.Delete(infoItem.ImagePath); - } + DeleteHistoryArtifacts(infoItem); + + ClearTransientHistoryPayloads(imagesToRemove); } - private void SaveTimer_Tick(object? sender, EventArgs e) + private void DisposeCachedBitmap() { - saveTimer.Stop(); - WriteHistory(); - if (_cachedBitmapHandle is nint bmpH) { NativeMethods.DeleteObject(bmpH); _cachedBitmapHandle = null; } + + CachedBitmap?.Dispose(); CachedBitmap = null; } + private static void ClearTransientHistoryPayloads(IEnumerable historyItems) + { + foreach (HistoryInfo historyItem in historyItems) + { + historyItem.ClearTransientImage(); + historyItem.ClearTransientWordBorderData(); + } + } + + private void EnsureImageHistoryLoaded() + { + if (_imageHistoryLoaded) + return; + + HistoryWithImage = LoadHistoryBlocking(nameof(HistoryWithImage)); + _imageHistoryLoaded = true; + NormalizeHistoryIds(HistoryWithImage); + if (NormalizeHistoryCompatibilityData(HistoryWithImage)) + MarkHistoryDirty(); + + if (EnsureWordBorderSidecarFiles(HistoryWithImage)) + MarkHistoryDirty(); + } + + private void EnsureTextHistoryLoaded() + { + if (_textHistoryLoaded) + return; + + HistoryTextOnly = LoadHistoryBlocking(nameof(HistoryTextOnly)); + _textHistoryLoaded = true; + NormalizeHistoryIds(HistoryTextOnly); + if (NormalizeHistoryCompatibilityData(HistoryTextOnly)) + MarkHistoryDirty(); + } + + private void HistoryCacheReleaseTimer_Tick(object? sender, EventArgs e) + { + if (_hasPendingWrite) + return; + + if (_lastHistoryAccessUtc == DateTimeOffset.MinValue) + return; + + if (DateTimeOffset.UtcNow - _lastHistoryAccessUtc < historyCacheIdleLifetime) + return; + + ReleaseLoadedHistoriesCore(); + } + + private static List LoadHistoryBlocking(string fileName) + { + return Task.Run(() => LoadHistoryAsync(fileName)).GetAwaiter().GetResult(); + } + + private static string GetHistoryPathBlocking() + { + return Task.Run(async () => await FileUtilities.GetPathToHistory()).GetAwaiter().GetResult(); + } + + private static string GetWordBorderInfoFileName(string historyId) + { + return $"{historyId}{WordBorderInfoFileSuffix}"; + } + + private static bool SaveHistoryTextFileBlocking(string textContent, string fileName) + { + return Task.Run(async () => await FileUtilities.SaveTextFile(textContent, fileName, FileStorageKind.WithHistory)) + .GetAwaiter() + .GetResult(); + } + + private void DeleteHistoryArtifacts(HistoryInfo historyItem) + { + DeleteHistoryFile(historyItem.ImagePath); + DeleteHistoryFile(historyItem.WordBorderInfoFileName); + } + + private static void DeleteHistoryFile(string? historyFileName) + { + if (string.IsNullOrWhiteSpace(historyFileName)) + return; + + string historyBasePath = GetHistoryPathBlocking(); + string filePath = Path.Combine(historyBasePath, Path.GetFileName(historyFileName)); + + if (!File.Exists(filePath)) + return; + + try + { + File.Delete(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to delete history file '{filePath}': {ex}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Access denied when deleting history file '{filePath}': {ex}"); + } + } + + private void DeleteUnusedWordBorderFiles(IEnumerable historyItems) + { + string historyBasePath = GetHistoryPathBlocking(); + + if (!Directory.Exists(historyBasePath)) + return; + + HashSet expectedFileNames = [.. historyItems + .Select(historyItem => historyItem.WordBorderInfoFileName) + .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) + .Select(fileName => Path.GetFileName(fileName!))]; + + string[] wordBorderInfoFiles = Directory.GetFiles(historyBasePath, $"*{WordBorderInfoFileSuffix}"); + + foreach (string wordBorderInfoFile in wordBorderInfoFiles) + { + string fileName = Path.GetFileName(wordBorderInfoFile); + + if (!expectedFileNames.Contains(fileName)) + { + try + { + File.Delete(wordBorderInfoFile); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to delete word border info file '{wordBorderInfoFile}': {ex}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Access denied when deleting word border info file '{wordBorderInfoFile}': {ex}"); + } + } + } + } + + private void MarkHistoryDirty() + { + _hasPendingWrite = true; + TouchHistoryCache(); + saveTimer.Stop(); + saveTimer.Start(); + } + + private bool EnsureWordBorderSidecarFiles(IEnumerable historyItems) + { + bool migratedAnyWordBorderData = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (PersistWordBorderData(historyItem)) + migratedAnyWordBorderData = true; + } + + return migratedAnyWordBorderData; + } + + private static bool NormalizeHistoryCompatibilityData(IEnumerable historyItems) + { + bool normalizedAnyHistoryItems = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (NormalizeHistoryCompatibilityData(historyItem)) + normalizedAnyHistoryItems = true; + } + + return normalizedAnyHistoryItems; + } + + private static bool NormalizeHistoryCompatibilityData(HistoryInfo historyItem) + { + (string normalizedLanguageTag, LanguageKind normalizedLanguageKind, bool usedUiAutomation) = + LanguageUtilities.NormalizePersistedLanguageIdentity( + historyItem.LanguageKind, + historyItem.LanguageTag, + historyItem.UsedUiAutomation); + + if (string.Equals(historyItem.LanguageTag, normalizedLanguageTag, StringComparison.Ordinal) + && historyItem.LanguageKind == normalizedLanguageKind + && historyItem.UsedUiAutomation == usedUiAutomation) + { + return false; + } + + historyItem.LanguageTag = normalizedLanguageTag; + historyItem.LanguageKind = normalizedLanguageKind; + historyItem.UsedUiAutomation = usedUiAutomation; + return true; + } + + private void PersistWordBorderData(IEnumerable historyItems) + { + foreach (HistoryInfo historyItem in historyItems) + PersistWordBorderData(historyItem); + } + + private bool PersistWordBorderData(HistoryInfo historyItem) + { + if (string.IsNullOrWhiteSpace(historyItem.WordBorderInfoJson)) + return false; + + if (string.IsNullOrWhiteSpace(historyItem.ID)) + historyItem.ID = Guid.NewGuid().ToString(); + + string wordBorderInfoFileName = GetWordBorderInfoFileName(historyItem.ID); + bool couldSaveWordBorderInfo = SaveHistoryTextFileBlocking(historyItem.WordBorderInfoJson, wordBorderInfoFileName); + + if (!couldSaveWordBorderInfo) + { + historyItem.WordBorderInfoFileName = null; + return false; + } + + historyItem.WordBorderInfoFileName = wordBorderInfoFileName; + + // When file-backed settings are enabled, the sidecar file is the authority + // for word border data, so drop the inline JSON to reduce memory/disk usage. + if (DefaultSettings.EnableFileBackedManagedSettings) + historyItem.ClearTransientWordBorderData(); + + return true; + } + + private void NormalizeHistoryIds(List historyItems) + { + HashSet seenIds = []; + bool updatedAnyIds = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (!string.IsNullOrWhiteSpace(historyItem.ID) && seenIds.Add(historyItem.ID)) + continue; + + string nextId; + do + { + nextId = Guid.NewGuid().ToString(); + } + while (!seenIds.Add(nextId)); + + historyItem.ID = nextId; + updatedAnyIds = true; + } + + if (updatedAnyIds) + MarkHistoryDirty(); + } + + private void ReleaseLoadedHistoriesCore() + { + ClearTransientHistoryPayloads(HistoryWithImage); + HistoryWithImage.Clear(); + HistoryTextOnly.Clear(); + _imageHistoryLoaded = false; + _textHistoryLoaded = false; + _lastHistoryAccessUtc = DateTimeOffset.MinValue; + historyCacheReleaseTimer.Stop(); + } + + private void SaveTimer_Tick(object? sender, EventArgs e) + { + saveTimer.Stop(); + WriteHistory(); + DisposeCachedBitmap(); + } + + private void TouchHistoryCache() + { + _lastHistoryAccessUtc = DateTimeOffset.UtcNow; + + if (_textHistoryLoaded || _imageHistoryLoaded) + historyCacheReleaseTimer.Start(); + } + #endregion Private Methods } diff --git a/Text-Grab/Services/LanguageService.cs b/Text-Grab/Services/LanguageService.cs index b1d8e1ce..c4232dd1 100644 --- a/Text-Grab/Services/LanguageService.cs +++ b/Text-Grab/Services/LanguageService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Windows.Input; using Text_Grab.Interfaces; @@ -42,7 +43,7 @@ public class LanguageService /// public ILanguage GetCurrentInputLanguage() { - string currentInputLangTag = InputLanguageManager.Current.CurrentInputLanguage.Name; + string currentInputLangTag = GetCurrentInputLanguageTag(); lock (_cacheLock) { @@ -124,6 +125,32 @@ public static LanguageKind GetLanguageKind(object language) }; } + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) GetPersistedLanguageIdentity(object language) + { + if (language is UiAutomationLang) + { + ILanguage fallbackLanguage = CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(); + return (fallbackLanguage.LanguageTag, LanguageKind.Global, true); + } + + return (GetLanguageTag(language), GetLanguageKind(language), false); + } + + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) NormalizePersistedLanguageIdentity( + LanguageKind languageKind, + string languageTag, + bool usedUiAutomation = false) + { + if (languageKind == LanguageKind.UiAutomation + || string.Equals(languageTag, _uiAutomationLangTag, StringComparison.OrdinalIgnoreCase)) + { + ILanguage fallbackLanguage = CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(); + return (fallbackLanguage.LanguageTag, LanguageKind.Global, true); + } + + return (languageTag, languageKind, usedUiAutomation); + } + /// /// Gets the OCR language to use based on settings and available languages. /// Cached based on LastUsedLang setting. @@ -158,15 +185,22 @@ public ILanguage GetOCRLanguage() return _cachedOcrLanguage; } - try + if (lastUsedLang == _uiAutomationLangTag) { - selectedLanguage = new GlobalLang(lastUsedLang); + selectedLanguage = CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(); } - catch (Exception ex) + else { - Debug.WriteLine($"Failed to parse LastUsedLang: {lastUsedLang}\n{ex.Message}"); - // if the language tag is invalid, reset to current input language - selectedLanguage = GetCurrentInputLanguage(); + try + { + selectedLanguage = new GlobalLang(lastUsedLang); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to parse LastUsedLang: {lastUsedLang}\n{ex.Message}"); + // if the language tag is invalid, reset to current input language + selectedLanguage = GetCurrentInputLanguage(); + } } } @@ -219,7 +253,7 @@ public bool IsCurrentLanguageLatinBased() /// public string GetSystemLanguageForTranslation() { - string currentInputLangTag = InputLanguageManager.Current.CurrentInputLanguage.Name; + string currentInputLangTag = GetCurrentInputLanguageTag(); lock (_cacheLock) { @@ -310,4 +344,26 @@ public void InvalidateAllCaches() } #endregion Public Methods + + private static string GetCurrentInputLanguageTag() + { + string? currentInputLangTag = null; + try + { + currentInputLangTag = InputLanguageManager.Current?.CurrentInputLanguage?.Name; + } + catch (NullReferenceException) + { + currentInputLangTag = null; + } + + if (!string.IsNullOrWhiteSpace(currentInputLangTag)) + return currentInputLangTag; + + currentInputLangTag = CultureInfo.CurrentUICulture.Name; + if (!string.IsNullOrWhiteSpace(currentInputLangTag)) + return currentInputLangTag; + + return "en-US"; + } } diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index 73bc7d24..04c52f40 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -1,8 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Configuration; using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using Text_Grab.Models; using Text_Grab.Utilities; using Windows.Storage; @@ -10,19 +15,58 @@ namespace Text_Grab.Services; internal class SettingsService : IDisposable { + private const string ManagedJsonSettingsFolderName = "settings-data"; + + private static readonly Dictionary ManagedJsonSettingFiles = new(StringComparer.Ordinal) + { + [nameof(Properties.Settings.RegexList)] = "RegexList.json", + [nameof(Properties.Settings.ShortcutKeySets)] = "ShortcutKeySets.json", + [nameof(Properties.Settings.BottomButtonsJson)] = "BottomButtons.json", + [nameof(Properties.Settings.WebSearchItemsJson)] = "WebSearchItems.json", + [nameof(Properties.Settings.PostGrabJSON)] = "PostGrabActions.json", + [nameof(Properties.Settings.PostGrabCheckStates)] = "PostGrabCheckStates.json", + }; + private readonly ApplicationDataContainer? _localSettings; + private readonly string _managedJsonSettingsFolderPath; + private readonly bool _saveClassicSettingsChanges; + private readonly bool _preferFileBackedManagedSettings; + private readonly Lock _managedJsonLock = new(); + private bool _suppressManagedJsonPropertyChanged; + private StoredRegex[]? _cachedRegexPatterns; + private List? _cachedShortcutKeySets; + private List? _cachedBottomBarButtons; + private List? _cachedWebSearchUrls; + private List? _cachedPostGrabActions; + private Dictionary? _cachedPostGrabCheckStates; // relevant discussion https://github.com/microsoft/WindowsAppSDK/discussions/1478 - public Properties.Settings ClassicSettings = Properties.Settings.Default; + public Properties.Settings ClassicSettings; + + internal bool IsFileBackedManagedSettingsEnabled => _preferFileBackedManagedSettings; + + internal string ManagedJsonSettingsFolderPath => _managedJsonSettingsFolderPath; public SettingsService() + : this( + Properties.Settings.Default, + AppUtilities.IsPackaged() ? ApplicationData.Current.LocalSettings : null) { - if (!AppUtilities.IsPackaged()) - return; + } - _localSettings = ApplicationData.Current.LocalSettings; + internal SettingsService( + Properties.Settings classicSettings, + ApplicationDataContainer? localSettings, + string? managedJsonSettingsFolderPath = null, + bool saveClassicSettingsChanges = true) + { + ClassicSettings = classicSettings; + _localSettings = localSettings; + _managedJsonSettingsFolderPath = managedJsonSettingsFolderPath ?? GetManagedJsonSettingsFolderPath(); + _saveClassicSettingsChanges = saveClassicSettingsChanges; + _preferFileBackedManagedSettings = ClassicSettings.EnableFileBackedManagedSettings; - if (ClassicSettings.FirstRun && _localSettings.Values.Count > 0) + if (ClassicSettings.FirstRun && _localSettings is not null && _localSettings.Values.Count > 0) MigrateLocalSettingsToClassic(); // copy settings from classic to local settings @@ -56,6 +100,15 @@ private void ClassicSettings_PropertyChanged(object? sender, PropertyChangedEven if (e.PropertyName is not string propertyName) return; + if (IsManagedJsonSetting(propertyName)) + { + if (_suppressManagedJsonPropertyChanged) + return; + + HandleManagedJsonSettingChanged(propertyName); + return; + } + SaveSettingInContainer(propertyName, ClassicSettings[propertyName]); } @@ -64,6 +117,18 @@ public void Dispose() ClassicSettings.PropertyChanged -= ClassicSettings_PropertyChanged; } + internal static bool IsManagedJsonSetting(string propertyName) => + ManagedJsonSettingFiles.ContainsKey(propertyName); + + internal string GetManagedJsonSettingValueForExport(string propertyName) + { + if (!IsManagedJsonSetting(propertyName)) + return ClassicSettings[propertyName] as string ?? string.Empty; + + // Use the read-only path so that an export never mutates existing settings. + return ReadManagedJsonSettingTextForExport(propertyName); + } + public T? GetSettingFromContainer(string name) { // if running as packaged try to get from local settings @@ -112,4 +177,391 @@ public void SaveSettingInContainer(string name, T value) #endif } } + + public StoredRegex[] LoadStoredRegexes() => + LoadManagedJson( + nameof(Properties.Settings.RegexList), + static () => [], + CloneStoredRegexes, + ref _cachedRegexPatterns); + + public void SaveStoredRegexes(IEnumerable storedRegexes) + { + StoredRegex[] materialized = CloneStoredRegexes(storedRegexes); + SaveManagedJson( + nameof(Properties.Settings.RegexList), + materialized, + CloneStoredRegexes, + ref _cachedRegexPatterns); + } + + public List LoadShortcutKeySets() => + LoadManagedJson( + nameof(Properties.Settings.ShortcutKeySets), + static () => [], + CloneShortcutKeySets, + ref _cachedShortcutKeySets); + + public void SaveShortcutKeySets(IEnumerable shortcutKeySets) + { + List materialized = CloneShortcutKeySets(shortcutKeySets); + SaveManagedJson( + nameof(Properties.Settings.ShortcutKeySets), + materialized, + CloneShortcutKeySets, + ref _cachedShortcutKeySets); + } + + public List LoadBottomBarButtons() => + LoadManagedJson( + nameof(Properties.Settings.BottomButtonsJson), + static () => [], + CloneButtonInfos, + ref _cachedBottomBarButtons); + + public void SaveBottomBarButtons(IEnumerable buttonInfos) + { + List materialized = CloneButtonInfos(buttonInfos); + SaveManagedJson( + nameof(Properties.Settings.BottomButtonsJson), + materialized, + CloneButtonInfos, + ref _cachedBottomBarButtons); + } + + public List LoadWebSearchUrls() => + LoadManagedJson( + nameof(Properties.Settings.WebSearchItemsJson), + static () => [], + CloneWebSearchUrls, + ref _cachedWebSearchUrls); + + public void SaveWebSearchUrls(IEnumerable webSearchUrls) + { + List materialized = CloneWebSearchUrls(webSearchUrls); + SaveManagedJson( + nameof(Properties.Settings.WebSearchItemsJson), + materialized, + CloneWebSearchUrls, + ref _cachedWebSearchUrls); + } + + public List LoadPostGrabActions() => + LoadManagedJson( + nameof(Properties.Settings.PostGrabJSON), + static () => [], + CloneButtonInfos, + ref _cachedPostGrabActions); + + public void SavePostGrabActions(IEnumerable actions) + { + List materialized = CloneButtonInfos(actions); + SaveManagedJson( + nameof(Properties.Settings.PostGrabJSON), + materialized, + CloneButtonInfos, + ref _cachedPostGrabActions); + } + + public Dictionary LoadPostGrabCheckStates() => + LoadManagedJson( + nameof(Properties.Settings.PostGrabCheckStates), + static () => [], + CloneCheckStates, + ref _cachedPostGrabCheckStates); + + public void SavePostGrabCheckStates(IReadOnlyDictionary checkStates) + { + Dictionary materialized = CloneCheckStates(checkStates); + SaveManagedJson( + nameof(Properties.Settings.PostGrabCheckStates), + materialized, + CloneCheckStates, + ref _cachedPostGrabCheckStates); + } + + private void HandleManagedJsonSettingChanged(string propertyName) + { + InvalidateManagedJsonCache(propertyName); + + string managedJsonValue = ClassicSettings[propertyName] as string ?? string.Empty; + PersistManagedJsonSetting(propertyName, managedJsonValue); + } + + private void PersistManagedJsonSetting(string propertyName, string managedJsonValue) + { + if (string.IsNullOrWhiteSpace(managedJsonValue)) + { + DeleteManagedJsonSettingFile(propertyName); + SaveSettingInContainer(propertyName, string.Empty); + return; + } + + TryWriteManagedJsonSettingText(propertyName, managedJsonValue); + SaveSettingInContainer(propertyName, managedJsonValue); + } + + private T LoadManagedJson( + string propertyName, + Func emptyFactory, + Func clone, + ref T? cachedValue) + where T : class + { + lock (_managedJsonLock) + { + if (cachedValue is not null) + return clone(cachedValue); + } + + T loadedValue = emptyFactory(); + string json = ReadManagedJsonSettingText(propertyName); + if (!string.IsNullOrWhiteSpace(json)) + { + try + { + loadedValue = JsonSerializer.Deserialize(json) ?? emptyFactory(); + } + catch (JsonException) + { + loadedValue = emptyFactory(); + } + } + + T cachedCopy = clone(loadedValue); + lock (_managedJsonLock) + { + cachedValue = cachedCopy; + return clone(cachedCopy); + } + } + + private void SaveManagedJson( + string propertyName, + T value, + Func clone, + ref T? cachedValue) + where T : class + { + T cachedCopy = clone(value); + string json = JsonSerializer.Serialize(cachedCopy); + + lock (_managedJsonLock) + { + cachedValue = clone(cachedCopy); + } + + // Three storage targets are written independently: + // 1. ClassicSettings (in-memory) — via SetManagedJsonSettingValue (suppressed to avoid re-entry) + // 2. Sidecar JSON file + ApplicationDataContainer — via PersistManagedJsonSetting + // 3. ClassicSettings (disk) — via ClassicSettings.Save() + SetManagedJsonSettingValue(propertyName, json); + PersistManagedJsonSetting(propertyName, json); + + if (_saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + private string ReadManagedJsonSettingText(string propertyName) + { + string classicValue = ClassicSettings[propertyName] as string ?? string.Empty; + string fileValue = TryReadManagedJsonSettingText(propertyName); + string preferredValue = _preferFileBackedManagedSettings ? fileValue : classicValue; + string secondaryValue = _preferFileBackedManagedSettings ? classicValue : fileValue; + string selectedValue = string.IsNullOrWhiteSpace(preferredValue) + ? secondaryValue + : preferredValue; + + if (string.IsNullOrWhiteSpace(selectedValue)) + return string.Empty; + + bool classicNeedsBackfill = !string.Equals(classicValue, selectedValue, StringComparison.Ordinal); + bool fileNeedsBackfill = !string.Equals(fileValue, selectedValue, StringComparison.Ordinal); + + if (classicNeedsBackfill) + BackfillClassicManagedJsonSetting(propertyName, selectedValue); + + if (fileNeedsBackfill) + TryWriteManagedJsonSettingText(propertyName, selectedValue); + + return selectedValue; + } + + private string TryReadManagedJsonSettingText(string propertyName) + { + string filePath = GetManagedJsonSettingFilePath(propertyName); + if (!File.Exists(filePath)) + return string.Empty; + + try + { + return File.ReadAllText(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read managed setting file '{propertyName}': {ex.Message}"); + return string.Empty; + } + } + + private void BackfillClassicManagedJsonSetting(string propertyName, string value) + { + SetManagedJsonSettingValue(propertyName, value); + SaveSettingInContainer(propertyName, value); + + if (_saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + /// + /// Reads the best available value for a managed JSON setting without writing + /// back to either store. Safe to call during export so that existing settings + /// are never mutated as a side effect. + /// + private string ReadManagedJsonSettingTextForExport(string propertyName) + { + string classicValue = ClassicSettings[propertyName] as string ?? string.Empty; + string fileValue = TryReadManagedJsonSettingText(propertyName); + string preferredValue = _preferFileBackedManagedSettings ? fileValue : classicValue; + string secondaryValue = _preferFileBackedManagedSettings ? classicValue : fileValue; + + string selectedValue = string.IsNullOrWhiteSpace(preferredValue) + ? secondaryValue + : preferredValue; + + return selectedValue ?? string.Empty; + } + + private bool TryWriteManagedJsonSettingText(string propertyName, string value) + { + try + { + Directory.CreateDirectory(_managedJsonSettingsFolderPath); + File.WriteAllText(GetManagedJsonSettingFilePath(propertyName), value); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to persist managed setting '{propertyName}' to disk: {ex.Message}"); + return false; + } + } + + private void DeleteManagedJsonSettingFile(string propertyName) + { + string filePath = GetManagedJsonSettingFilePath(propertyName); + if (!File.Exists(filePath)) + return; + + try + { + File.Delete(filePath); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to delete managed setting file '{propertyName}': {ex.Message}"); + } + } + + private void SetManagedJsonSettingValue(string propertyName, string value) + { + _suppressManagedJsonPropertyChanged = true; + try + { + ClassicSettings[propertyName] = value; + } + finally + { + _suppressManagedJsonPropertyChanged = false; + } + } + + private string GetManagedJsonSettingFilePath(string propertyName) => + Path.Combine(_managedJsonSettingsFolderPath, ManagedJsonSettingFiles[propertyName]); + + private static string GetManagedJsonSettingsFolderPath() + { + if (AppUtilities.IsPackaged()) + return Path.Combine(ApplicationData.Current.LocalFolder.Path, ManagedJsonSettingsFolderName); + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", ManagedJsonSettingsFolderName); + } + + private void InvalidateManagedJsonCache(string propertyName) + { + lock (_managedJsonLock) + { + switch (propertyName) + { + case nameof(Properties.Settings.RegexList): + _cachedRegexPatterns = null; + break; + case nameof(Properties.Settings.ShortcutKeySets): + _cachedShortcutKeySets = null; + break; + case nameof(Properties.Settings.BottomButtonsJson): + _cachedBottomBarButtons = null; + break; + case nameof(Properties.Settings.WebSearchItemsJson): + _cachedWebSearchUrls = null; + break; + case nameof(Properties.Settings.PostGrabJSON): + _cachedPostGrabActions = null; + break; + case nameof(Properties.Settings.PostGrabCheckStates): + _cachedPostGrabCheckStates = null; + break; + } + } + } + + private static StoredRegex[] CloneStoredRegexes(IEnumerable storedRegexes) => + [.. storedRegexes.Select(static regex => new StoredRegex + { + Id = regex.Id, + Name = regex.Name, + Pattern = regex.Pattern, + IsDefault = regex.IsDefault, + Description = regex.Description, + CreatedDate = regex.CreatedDate, + LastUsedDate = regex.LastUsedDate, + })]; + + private static List CloneShortcutKeySets(IEnumerable shortcutKeySets) => + [.. shortcutKeySets.Select(static shortcut => new ShortcutKeySet + { + Modifiers = [.. shortcut.Modifiers], + NonModifierKey = shortcut.NonModifierKey, + IsEnabled = shortcut.IsEnabled, + Name = shortcut.Name, + Action = shortcut.Action, + })]; + + private static List CloneButtonInfos(IEnumerable buttonInfos) => + [.. buttonInfos.Select(static button => new ButtonInfo + { + OrderNumber = button.OrderNumber, + ButtonText = button.ButtonText, + SymbolText = button.SymbolText, + Background = button.Background, + Command = button.Command, + ClickEvent = button.ClickEvent, + IsSymbol = button.IsSymbol, + SymbolIcon = button.SymbolIcon, + IsRelevantForFullscreenGrab = button.IsRelevantForFullscreenGrab, + IsRelevantForEditWindow = button.IsRelevantForEditWindow, + DefaultCheckState = button.DefaultCheckState, + TemplateId = button.TemplateId, + })]; + + private static List CloneWebSearchUrls(IEnumerable webSearchUrls) => + [.. webSearchUrls.Select(static url => new WebSearchUrlModel + { + Name = url.Name, + Url = url.Url, + })]; + + private static Dictionary CloneCheckStates(IReadOnlyDictionary checkStates) => + checkStates.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal); } diff --git a/Text-Grab/Utilities/AppUtilities.cs b/Text-Grab/Utilities/AppUtilities.cs index e1228dcd..73a49926 100644 --- a/Text-Grab/Utilities/AppUtilities.cs +++ b/Text-Grab/Utilities/AppUtilities.cs @@ -19,7 +19,9 @@ internal static bool IsPackaged() } } - internal static Settings TextGrabSettings => Singleton.Instance.ClassicSettings; + internal static SettingsService TextGrabSettingsService => Singleton.Instance; + + internal static Settings TextGrabSettings => TextGrabSettingsService.ClassicSettings; internal static string GetAppVersion() { diff --git a/Text-Grab/Utilities/CustomBottomBarUtilities.cs b/Text-Grab/Utilities/CustomBottomBarUtilities.cs index 279540f3..c646c2dc 100644 --- a/Text-Grab/Utilities/CustomBottomBarUtilities.cs +++ b/Text-Grab/Utilities/CustomBottomBarUtilities.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Windows; using System.Windows.Input; @@ -15,23 +14,14 @@ namespace Text_Grab.Utilities; public class CustomBottomBarUtilities { - private static readonly JsonSerializerOptions ButtonInfoJsonOptions = new(); private static readonly Dictionary> _methodCache = []; private static readonly Lock _methodCacheLock = new(); private static readonly BrushConverter BrushConverter = new(); public static List GetCustomBottomBarItemsSetting() { - string json = AppUtilities.TextGrabSettings.BottomButtonsJson; - - if (string.IsNullOrWhiteSpace(json)) - return ButtonInfo.DefaultButtonList; - - List? customBottomBarItems = []; - - customBottomBarItems = JsonSerializer.Deserialize>(json, ButtonInfoJsonOptions); - - if (customBottomBarItems is null || customBottomBarItems.Count == 0) + List customBottomBarItems = AppUtilities.TextGrabSettingsService.LoadBottomBarButtons(); + if (customBottomBarItems.Count == 0) return ButtonInfo.DefaultButtonList; // SymbolIcon is not serialized (marked with [JsonIgnore]), so reconstruct it from ButtonText @@ -58,9 +48,7 @@ public static void SaveCustomBottomBarItemsSetting(List botto public static void SaveCustomBottomBarItemsSetting(List bottomBarButtons) { - string json = JsonSerializer.Serialize(bottomBarButtons, ButtonInfoJsonOptions); - AppUtilities.TextGrabSettings.BottomButtonsJson = json; - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveBottomBarButtons(bottomBarButtons); } public static List GetBottomBarButtons(EditTextWindow editTextWindow) diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs index 52ead768..c4f58077 100644 --- a/Text-Grab/Utilities/GrabTemplateExecutor.cs +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -282,18 +282,8 @@ internal static Dictionary ResolvePatternRegexes( private static StoredRegex[] LoadSavedPatterns() { - try - { - string json = Properties.Settings.Default.RegexList; - if (string.IsNullOrWhiteSpace(json)) - return StoredRegex.GetDefaultPatterns(); - - return JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns(); - } - catch - { - return StoredRegex.GetDefaultPatterns(); - } + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + return patterns.Length == 0 ? StoredRegex.GetDefaultPatterns() : patterns; } // ── Private helpers ─────────────────────────────────────────────────────── diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs index 2c45ad8d..087dffca 100644 --- a/Text-Grab/Utilities/GrabTemplateManager.cs +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -12,11 +12,15 @@ namespace Text_Grab.Utilities; /// -/// Provides CRUD operations for objects, persisted as -/// a JSON file on disk. Previously stored in application settings, but moved to -/// file-based storage because ApplicationDataContainer has an 8 KB per-value limit. -/// Pattern follows . +/// Provides CRUD operations for objects, keeping the +/// legacy settings string and the file-backed JSON representation in sync during +/// the transition release. Pattern follows . /// +/// +/// TODO: This class has no thread-safety guards. All current callers are UI-thread +/// methods so this is safe today, but if templates are ever read/written from +/// background threads a lock (like SettingsService._managedJsonLock) should be added. +/// public static class GrabTemplateManager { private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; @@ -28,10 +32,16 @@ public static class GrabTemplateManager }; private const string TemplatesFileName = "GrabTemplates.json"; - private static bool _migrated; - // Allow tests to override the file path + // Allow tests to override the file path. + // TODO: If more test seams are needed, consider consolidating these into a small + // options/config object instead of individual static properties. internal static string? TestFilePath { get; set; } + internal static string? TestImagesFolderPath { get; set; } + internal static bool? TestPreferFileBackedMode { get; set; } + + private static bool PreferFileBackedTemplates => + TestPreferFileBackedMode ?? AppUtilities.TextGrabSettingsService.IsFileBackedManagedSettingsEnabled; // ── File path ───────────────────────────────────────────────────────────── @@ -94,6 +104,15 @@ private static string GetTemplatesFilePath() /// Returns the folder where template reference images are stored alongside the templates JSON. public static string GetTemplateImagesFolder() { + if (TestImagesFolderPath is not null) + return TestImagesFolderPath; + + if (TestFilePath is not null) + { + string? testDir = Path.GetDirectoryName(TestFilePath); + return Path.Combine(testDir ?? Path.GetTempPath(), "template-images"); + } + if (AppUtilities.IsPackaged()) { string localFolder = Windows.Storage.ApplicationData.Current.LocalFolder.Path; @@ -104,61 +123,14 @@ public static string GetTemplateImagesFolder() return Path.Combine(exeDir ?? "c:\\Text-Grab", "template-images"); } - // ── Migration from settings ─────────────────────────────────────────────── - - private static void MigrateFromSettingsIfNeeded() - { - if (_migrated) - return; - - _migrated = true; - - string filePath = GetTemplatesFilePath(); - if (File.Exists(filePath)) - return; - - try - { - string settingsJson = DefaultSettings.GrabTemplatesJSON; - if (string.IsNullOrWhiteSpace(settingsJson)) - return; - - // Validate the JSON before migrating - List? templates = JsonSerializer.Deserialize>(settingsJson, JsonOptions); - if (templates is null || templates.Count == 0) - return; - - string? dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - File.WriteAllText(filePath, settingsJson); - - // Clear the setting so it no longer overflows the container - DefaultSettings.GrabTemplatesJSON = string.Empty; - DefaultSettings.Save(); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to migrate GrabTemplates from settings to file: {ex.Message}"); - } - } - // ── Read ────────────────────────────────────────────────────────────────── /// Returns all saved templates, or an empty list if none exist. public static List GetAllTemplates() { - MigrateFromSettingsIfNeeded(); - - string filePath = GetTemplatesFilePath(); - - if (!File.Exists(filePath)) - return []; - try { - string json = File.ReadAllText(filePath); + string json = ResolveTemplatesJson(); if (string.IsNullOrWhiteSpace(json)) return []; @@ -194,13 +166,22 @@ public static List GetAllTemplates() public static void SaveTemplates(List templates) { string json = JsonSerializer.Serialize(templates, JsonOptions); - string filePath = GetTemplatesFilePath(); + SaveTemplatesJson(json); + } + + internal static string GetTemplatesJsonForExport() + { + List templates = GetAllTemplates(); + return JsonSerializer.Serialize(templates, JsonOptions); + } - string? dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); + internal static void ImportTemplatesFromJson(string templatesJson) + { + List templates = string.IsNullOrWhiteSpace(templatesJson) + ? [] + : JsonSerializer.Deserialize>(templatesJson, JsonOptions) ?? []; - File.WriteAllText(filePath, json); + SaveTemplates(templates); } /// Adds a new template (or updates an existing one with the same ID). @@ -279,4 +260,78 @@ public static void RecordUsage(string templateId) template.LastUsedDate = DateTimeOffset.Now; SaveTemplates(templates); } + + private static string ResolveTemplatesJson() + { + string settingsJson = DefaultSettings.GrabTemplatesJSON; + string fileJson = TryReadTemplatesFileText(); + string preferredJson = PreferFileBackedTemplates ? fileJson : settingsJson; + string secondaryJson = PreferFileBackedTemplates ? settingsJson : fileJson; + string selectedJson = string.IsNullOrWhiteSpace(preferredJson) + ? secondaryJson + : preferredJson; + + if (string.IsNullOrWhiteSpace(selectedJson)) + return string.Empty; + + if (!string.Equals(settingsJson, selectedJson, StringComparison.Ordinal)) + SetLegacyTemplatesJson(selectedJson); + + if (!string.Equals(fileJson, selectedJson, StringComparison.Ordinal)) + TryWriteTemplatesFile(selectedJson); + + return selectedJson; + } + + private static string TryReadTemplatesFileText() + { + string filePath = GetTemplatesFilePath(); + if (!File.Exists(filePath)) + return string.Empty; + + try + { + return File.ReadAllText(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read GrabTemplates file: {ex.Message}"); + return string.Empty; + } + } + + private static void SaveTemplatesJson(string json) + { + SetLegacyTemplatesJson(json); + TryWriteTemplatesFile(json); + } + + private static void SetLegacyTemplatesJson(string json) + { + if (string.Equals(DefaultSettings.GrabTemplatesJSON, json, StringComparison.Ordinal)) + return; + + DefaultSettings.GrabTemplatesJSON = json; + DefaultSettings.Save(); + } + + private static bool TryWriteTemplatesFile(string json) + { + string filePath = GetTemplatesFilePath(); + + try + { + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, json); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to persist GrabTemplates file: {ex.Message}"); + return false; + } + } } diff --git a/Text-Grab/Utilities/LanguageUtilities.cs b/Text-Grab/Utilities/LanguageUtilities.cs index eb2db94e..bafabfaf 100644 --- a/Text-Grab/Utilities/LanguageUtilities.cs +++ b/Text-Grab/Utilities/LanguageUtilities.cs @@ -42,6 +42,15 @@ public static LanguageKind GetLanguageKind(object language) public static ILanguage GetOCRLanguage() => Singleton.Instance.GetOCRLanguage(); + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) GetPersistedLanguageIdentity(object language) + => LanguageService.GetPersistedLanguageIdentity(language); + + public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomation) NormalizePersistedLanguageIdentity( + LanguageKind languageKind, + string languageTag, + bool usedUiAutomation = false) + => LanguageService.NormalizePersistedLanguageIdentity(languageKind, languageTag, usedUiAutomation); + /// /// Checks if the current input language is Latin-based. /// diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index cc794ebf..b1fe3e69 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -283,6 +283,8 @@ public static async void GetCopyTextFromPreviousRegion() ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); HistoryInfo newPrevRegionHistory = new() { @@ -291,8 +293,9 @@ public static async void GetCopyTextFromPreviousRegion() ImageContent = Singleton.Instance.CachedBitmap, TextContent = grabbedText, PositionRect = lastFsg.PositionRect, - LanguageTag = language.LanguageTag, - LanguageKind = LanguageUtilities.GetLanguageKind(language), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, IsTable = lastFsg.IsTable, SourceMode = TextGrabMode.Fullscreen, DpiScaleFactor = lastFsg.DpiScaleFactor, @@ -319,6 +322,8 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); string grabbedText = await GetTextFromAbsoluteRectAsync(scaledRect, language); + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(language); HistoryInfo newPrevRegionHistory = new() { @@ -327,8 +332,9 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio ImageContent = Singleton.Instance.CachedBitmap, TextContent = grabbedText, PositionRect = lastFsg.PositionRect, - LanguageTag = language.LanguageTag, - LanguageKind = LanguageUtilities.GetLanguageKind(language), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, IsTable = lastFsg.IsTable, SourceMode = TextGrabMode.Fullscreen, DpiScaleFactor = lastFsg.DpiScaleFactor, diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index 00693031..3b3c91c1 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -2,20 +2,16 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using System.Text.Json; using System.Threading.Tasks; using System.Windows; using Text_Grab.Interfaces; using Text_Grab.Models; -using Text_Grab.Properties; using Wpf.Ui.Controls; namespace Text_Grab.Utilities; public class PostGrabActionManager { - private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - /// /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance. /// Also includes a ButtonInfo for each saved Grab Template. @@ -113,23 +109,11 @@ public static List GetDefaultPostGrabActions() /// public static List GetEnabledPostGrabActions() { - string json = DefaultSettings.PostGrabJSON; - - if (string.IsNullOrWhiteSpace(json)) + List customActions = AppUtilities.TextGrabSettingsService.LoadPostGrabActions(); + if (customActions.Count == 0) return GetDefaultPostGrabActions(); - try - { - List? customActions = JsonSerializer.Deserialize>(json); - if (customActions is not null && customActions.Count > 0) - return customActions; - } - catch (JsonException) - { - // If deserialization fails, return defaults - } - - return GetDefaultPostGrabActions(); + return customActions; } /// @@ -137,9 +121,7 @@ public static List GetEnabledPostGrabActions() /// public static void SavePostGrabActions(List actions) { - string json = JsonSerializer.Serialize(actions); - DefaultSettings.PostGrabJSON = json; - DefaultSettings.Save(); + AppUtilities.TextGrabSettingsService.SavePostGrabActions(actions); } /// @@ -148,25 +130,13 @@ public static void SavePostGrabActions(List actions) public static bool GetCheckState(ButtonInfo action) { // First check if there's a stored check state from last usage - string statesJson = DefaultSettings.PostGrabCheckStates; - - if (!string.IsNullOrWhiteSpace(statesJson)) + Dictionary checkStates = AppUtilities.TextGrabSettingsService.LoadPostGrabCheckStates(); + if (checkStates.Count > 0 + && checkStates.TryGetValue(action.ButtonText, out bool storedState) + && action.DefaultCheckState == DefaultCheckState.LastUsed) { - try - { - Dictionary? checkStates = JsonSerializer.Deserialize>(statesJson); - if (checkStates is not null - && checkStates.TryGetValue(action.ButtonText, out bool storedState) - && action.DefaultCheckState == DefaultCheckState.LastUsed) - { - // If the action is set to LastUsed, use the stored state - return storedState; - } - } - catch (JsonException) - { - // If deserialization fails, fall through to default behavior - } + // If the action is set to LastUsed, use the stored state + return storedState; } // Otherwise use the default check state @@ -178,25 +148,9 @@ public static bool GetCheckState(ButtonInfo action) /// public static void SaveCheckState(ButtonInfo action, bool isChecked) { - string statesJson = DefaultSettings.PostGrabCheckStates; - Dictionary checkStates = []; - - if (!string.IsNullOrWhiteSpace(statesJson)) - { - try - { - checkStates = JsonSerializer.Deserialize>(statesJson) ?? []; - } - catch (JsonException) - { - // Start fresh if deserialization fails - } - } - + Dictionary checkStates = AppUtilities.TextGrabSettingsService.LoadPostGrabCheckStates(); checkStates[action.ButtonText] = isChecked; - string updatedJson = JsonSerializer.Serialize(checkStates); - DefaultSettings.PostGrabCheckStates = updatedJson; - DefaultSettings.Save(); + AppUtilities.TextGrabSettingsService.SavePostGrabCheckStates(checkStates); } /// diff --git a/Text-Grab/Utilities/SettingsImportExportUtilities.cs b/Text-Grab/Utilities/SettingsImportExportUtilities.cs index e87b2c5d..b35ceb8b 100644 --- a/Text-Grab/Utilities/SettingsImportExportUtilities.cs +++ b/Text-Grab/Utilities/SettingsImportExportUtilities.cs @@ -23,6 +23,9 @@ public static class SettingsImportExportUtilities private const string HistoryTextOnlyFileName = "HistoryTextOnly.json"; private const string HistoryWithImageFileName = "HistoryWithImage.json"; private const string HistoryFolderName = "history"; + private const string GrabTemplatesFileName = "GrabTemplates.json"; + private const string TemplateImagesFolderName = "template-images"; + private const string ManagedSettingsFolderName = "settings-data"; /// /// Exports all application settings and optionally history to a ZIP file. @@ -36,12 +39,20 @@ public static async Task ExportSettingsToZipAsync(bool includeHistory) try { - // Export settings to JSON + // Export settings to JSON and sidecar files await ExportSettingsToJsonAsync(Path.Combine(tempDir, SettingsFileName)); + ExportManagedJsonSettingsFolder(tempDir); + await ExportGrabTemplatesAsync(tempDir); // Export history if requested if (includeHistory) { + // Flush any pending in-memory history changes to disk before + // reading the files. The lazy-loading HistoryService may have + // normalized IDs, migrated word-border data, or accepted new + // entries that haven't been written yet. + Singleton.Instance.WriteHistory(); + await ExportHistoryAsync(tempDir); } @@ -86,6 +97,9 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) await ImportSettingsFromJsonAsync(settingsPath); } + ImportManagedJsonSettingsFolder(tempDir); + await ImportGrabTemplatesAsync(tempDir); + // Import history if present string historyTextOnlyPath = Path.Combine(tempDir, HistoryTextOnlyFileName); string historyWithImagePath = Path.Combine(tempDir, HistoryWithImageFileName); @@ -107,6 +121,7 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) private static async Task ExportSettingsToJsonAsync(string filePath) { Settings settings = AppUtilities.TextGrabSettings; + SettingsService settingsService = AppUtilities.TextGrabSettingsService; Dictionary settingsDict = new(); // Iterate through all settings properties using reflection @@ -114,6 +129,9 @@ private static async Task ExportSettingsToJsonAsync(string filePath) { string propertyName = property.Name; object? value = settings[propertyName]; + if (SettingsService.IsManagedJsonSetting(propertyName)) + value = settingsService.GetManagedJsonSettingValueForExport(propertyName); + settingsDict[propertyName] = value; } @@ -187,6 +205,92 @@ private static async Task ImportSettingsFromJsonAsync(string filePath) settings.Save(); } + private static async Task ExportGrabTemplatesAsync(string tempDir) + { + string templatesJson = GrabTemplateManager.GetTemplatesJsonForExport(); + await File.WriteAllTextAsync(Path.Combine(tempDir, GrabTemplatesFileName), templatesJson); + + string sourceImagesDir = GrabTemplateManager.GetTemplateImagesFolder(); + if (!Directory.Exists(sourceImagesDir)) + return; + + string destinationImagesDir = Path.Combine(tempDir, TemplateImagesFolderName); + Directory.CreateDirectory(destinationImagesDir); + + foreach (string imagePath in Directory.GetFiles(sourceImagesDir)) + { + string destinationPath = Path.Combine(destinationImagesDir, Path.GetFileName(imagePath)); + File.Copy(imagePath, destinationPath, true); + } + } + + private static async Task ImportGrabTemplatesAsync(string tempDir) + { + string templatesPath = Path.Combine(tempDir, GrabTemplatesFileName); + string sourceImagesDir = Path.Combine(tempDir, TemplateImagesFolderName); + + if (File.Exists(templatesPath)) + { + string templatesJson = await File.ReadAllTextAsync(templatesPath); + GrabTemplateManager.ImportTemplatesFromJson(templatesJson); + } + else if (GrabTemplateManager.GetAllTemplates() is { Count: > 0 }) + { + // No templates in the ZIP — trigger a read so the dual-store sync + // reconciles the legacy setting and sidecar file for any existing + // templates that were already on this machine. + GrabTemplateManager.SaveTemplates(GrabTemplateManager.GetAllTemplates()); + } + + if (!Directory.Exists(sourceImagesDir)) + return; + + string destinationImagesDir = GrabTemplateManager.GetTemplateImagesFolder(); + Directory.CreateDirectory(destinationImagesDir); + + foreach (string imagePath in Directory.GetFiles(sourceImagesDir)) + { + string destinationPath = Path.Combine(destinationImagesDir, Path.GetFileName(imagePath)); + File.Copy(imagePath, destinationPath, true); + } + } + + private static void ExportManagedJsonSettingsFolder(string tempDir) + { + string sourceFolderPath = AppUtilities.TextGrabSettingsService.ManagedJsonSettingsFolderPath; + if (!Directory.Exists(sourceFolderPath)) + return; + + string[] sourceFiles = Directory.GetFiles(sourceFolderPath, "*.json"); + if (sourceFiles.Length == 0) + return; + + string destinationFolder = Path.Combine(tempDir, ManagedSettingsFolderName); + Directory.CreateDirectory(destinationFolder); + + foreach (string sourceFile in sourceFiles) + { + string destinationPath = Path.Combine(destinationFolder, Path.GetFileName(sourceFile)); + File.Copy(sourceFile, destinationPath, true); + } + } + + private static void ImportManagedJsonSettingsFolder(string tempDir) + { + string sourceFolder = Path.Combine(tempDir, ManagedSettingsFolderName); + if (!Directory.Exists(sourceFolder)) + return; + + string destinationFolder = AppUtilities.TextGrabSettingsService.ManagedJsonSettingsFolderPath; + Directory.CreateDirectory(destinationFolder); + + foreach (string sourceFile in Directory.GetFiles(sourceFolder, "*.json")) + { + string destinationPath = Path.Combine(destinationFolder, Path.GetFileName(sourceFile)); + File.Copy(sourceFile, destinationPath, true); + } + } + private static async Task ExportHistoryAsync(string tempDir) { // Get history file paths @@ -212,13 +316,21 @@ private static async Task ExportHistoryAsync(string tempDir) string historyDestDir = Path.Combine(tempDir, HistoryFolderName); Directory.CreateDirectory(historyDestDir); - // Copy all .bmp files from history directory - string[] imageFiles = Directory.GetFiles(historyBasePath, "*.bmp"); - foreach (string imageFile in imageFiles) + string[] historyArtifactFiles = Directory + .GetFiles(historyBasePath) + .Where(filePath => + { + string fileName = Path.GetFileName(filePath); + return !fileName.Equals(HistoryTextOnlyFileName, StringComparison.OrdinalIgnoreCase) + && !fileName.Equals(HistoryWithImageFileName, StringComparison.OrdinalIgnoreCase); + }) + .ToArray(); + + foreach (string historyFile in historyArtifactFiles) { - string fileName = Path.GetFileName(imageFile); + string fileName = Path.GetFileName(historyFile); string destPath = Path.Combine(historyDestDir, fileName); - File.Copy(imageFile, destPath, true); + File.Copy(historyFile, destPath, true); } } } @@ -249,12 +361,12 @@ private static async Task ImportHistoryAsync(string tempDir) string historySourceDir = Path.Combine(tempDir, HistoryFolderName); if (Directory.Exists(historySourceDir)) { - string[] imageFiles = Directory.GetFiles(historySourceDir, "*.bmp"); - foreach (string imageFile in imageFiles) + string[] historyArtifactFiles = Directory.GetFiles(historySourceDir); + foreach (string historyFile in historyArtifactFiles) { - string fileName = Path.GetFileName(imageFile); + string fileName = Path.GetFileName(historyFile); string destPath = Path.Combine(historyBasePath, fileName); - File.Copy(imageFile, destPath, true); + File.Copy(historyFile, destPath, true); } } diff --git a/Text-Grab/Utilities/ShortcutKeysUtilities.cs b/Text-Grab/Utilities/ShortcutKeysUtilities.cs index 5e1f133c..cdaf77ae 100644 --- a/Text-Grab/Utilities/ShortcutKeysUtilities.cs +++ b/Text-Grab/Utilities/ShortcutKeysUtilities.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Windows.Input; using Text_Grab.Models; @@ -11,34 +10,18 @@ internal class ShortcutKeysUtilities { public static void SaveShortcutKeySetSettings(IEnumerable shortcutKeySets) { - string json = JsonSerializer.Serialize(shortcutKeySets); - - // save the json string to the settings - AppUtilities.TextGrabSettings.ShortcutKeySets = json; - - // save the settings - AppUtilities.TextGrabSettings.Save(); + AppUtilities.TextGrabSettingsService.SaveShortcutKeySets(shortcutKeySets); } public static IEnumerable GetShortcutKeySetsFromSettings() { - string json = AppUtilities.TextGrabSettings.ShortcutKeySets; - List defaultKeys = ShortcutKeySet.DefaultShortcutKeySets; + List shortcutKeySets = AppUtilities.TextGrabSettingsService.LoadShortcutKeySets(); - if (string.IsNullOrWhiteSpace(json)) + if (shortcutKeySets.Count == 0) return ParseFromPreviousAndDefaultsSettings(); - // create a list of custom bottom bar items - List? shortcutKeySets = new(); - - // deserialize the json string into a list of custom bottom bar items - shortcutKeySets = JsonSerializer.Deserialize>(json); - // return the list of custom bottom bar items - if (shortcutKeySets is null || shortcutKeySets.Count == 0) - return defaultKeys; - List actionsList = shortcutKeySets.Select(x => x.Action).ToList(); return shortcutKeySets.Concat(defaultKeys.Where(x => !actionsList.Contains(x.Action)).ToList()).ToList(); } diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index ff8ab171..f49e673c 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1295,15 +1295,24 @@ private void LoadRecentTextHistory() foreach (HistoryInfo history in grabsHistories) { MenuItem menuItem = new(); + string historyId = history.ID; menuItem.Click += (sender, args) => { + HistoryInfo? selectedHistory = Singleton.Instance.GetTextHistoryById(historyId); + + if (selectedHistory is null) + { + menuItem.IsEnabled = false; + return; + } + if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) { - PassedTextControl.Text = history.TextContent; + PassedTextControl.Text = selectedHistory.TextContent; return; } - EditTextWindow etw = new(history); + EditTextWindow etw = new(selectedHistory); etw.Show(); }; @@ -3740,26 +3749,7 @@ private void PatternContextOpening(object sender, ContextMenuEventArgs e) private List LoadRegexPatterns() { List returnRegexes = []; - - // Load from settings - string regexListJson = DefaultSettings.RegexList; - - if (!string.IsNullOrWhiteSpace(regexListJson)) - { - try - { - StoredRegex[]? loadedPatterns = JsonSerializer.Deserialize(regexListJson); - if (loadedPatterns is not null) - { - foreach (StoredRegex pattern in loadedPatterns) - returnRegexes.Add(pattern); - } - } - catch (JsonException) - { - // If deserialization fails, start fresh - } - } + returnRegexes.AddRange(AppUtilities.TextGrabSettingsService.LoadStoredRegexes()); // Add default patterns if list is empty if (returnRegexes.Count == 0) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index 777e916d..840c1d39 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -1082,12 +1082,16 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool ? new Bitmap(cachedBitmap) : null; + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(selectedOcrLang); + historyInfo = new HistoryInfo { ID = Guid.NewGuid().ToString(), DpiScaleFactor = GetCurrentDeviceScale(), - LanguageTag = LanguageUtilities.GetLanguageTag(selectedOcrLang), - LanguageKind = LanguageUtilities.GetLanguageKind(selectedOcrLang), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, CaptureDateTime = DateTimeOffset.Now, PositionRect = GetHistoryPositionRect(selection), IsTable = TableToggleButton.IsChecked!.Value, diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 44d9a1e3..7e2c03fb 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -299,12 +299,9 @@ private async Task LoadContentFromHistory(HistoryInfo history) GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); - List? wbInfoList = null; + List wbInfoList = await Singleton.Instance.GetWordBorderInfosAsync(history); - if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) - wbInfoList = JsonSerializer.Deserialize>(history.WordBorderInfoJson); - - if (wbInfoList is not { Count: > 0 }) + if (wbInfoList.Count < 1) NotifyIfUiAutomationNeedsLiveSource(currentLanguage); if (history.PositionRect != Rect.Empty) @@ -325,7 +322,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) } } - if (wbInfoList is not null && wbInfoList.Count > 0) + if (wbInfoList.Count > 0) { ScaleHistoryWordBordersToCanvas(history, wbInfoList); @@ -352,6 +349,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) TableToggleButton.IsChecked = history.IsTable; UpdateFrameText(); + history.ClearTransientImage(); } private Size GetGrabFrameNonContentSize() @@ -621,17 +619,20 @@ public HistoryInfo AsHistoryItem() foreach (WordBorder wb in wordBorders) wbInfoList.Add(new WordBorderInfo(wb)); - string wbInfoJson; - try - { - wbInfoJson = JsonSerializer.Serialize(wbInfoList); - } - catch + string? wbInfoJson = null; + if (wbInfoList.Count > 0) { - wbInfoJson = string.Empty; + try + { + wbInfoJson = JsonSerializer.Serialize(wbInfoList); + } + catch + { + wbInfoJson = null; #if DEBUG - throw; + throw; #endif + } } Rect sizePosRect = new() @@ -646,14 +647,19 @@ public HistoryInfo AsHistoryItem() if (historyItem is not null) id = historyItem.ID; + (string languageTag, LanguageKind languageKind, bool usedUiAutomation) = + LanguageUtilities.GetPersistedLanguageIdentity(currentLanguage ?? CurrentLanguage); + HistoryInfo historyInfo = new() { ID = id, - LanguageTag = CurrentLanguage.LanguageTag, - LanguageKind = LanguageUtilities.GetLanguageKind(currentLanguage ?? CurrentLanguage), + LanguageTag = languageTag, + LanguageKind = languageKind, + UsedUiAutomation = usedUiAutomation, CaptureDateTime = DateTimeOffset.UtcNow, TextContent = FrameText, WordBorderInfoJson = wbInfoJson, + WordBorderInfoFileName = wbInfoJson is null ? null : historyItem?.WordBorderInfoFileName, ImageContent = bitmap, PositionRect = sizePosRect, IsTable = TableToggleButton.IsChecked!.Value, @@ -1907,6 +1913,8 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel if (ShouldSaveOnClose) Singleton.Instance.SaveToHistory(this); + historyItem?.ClearTransientImage(); + FrameText = ""; wordBorders.Clear(); UpdateFrameText(); @@ -2900,19 +2908,7 @@ private static List ParsePatternMatchesFromTemplate(string MatchCollection matches = TemplatePattern().Matches(outputTemplate); Dictionary uniquePatterns = new(StringComparer.OrdinalIgnoreCase); - // Load saved patterns for ID resolution - StoredRegex[] savedPatterns; - try - { - string json = Settings.Default.RegexList; - savedPatterns = string.IsNullOrWhiteSpace(json) - ? StoredRegex.GetDefaultPatterns() - : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns(); - } - catch - { - savedPatterns = StoredRegex.GetDefaultPatterns(); - } + StoredRegex[] savedPatterns = LoadSavedPatterns(); foreach (Match match in matches) { @@ -3013,18 +3009,7 @@ private void UpdateTemplatePickerItems() private static List LoadPatternPickerItems() { - StoredRegex[] patterns; - try - { - string json = Settings.Default.RegexList; - patterns = string.IsNullOrWhiteSpace(json) - ? StoredRegex.GetDefaultPatterns() - : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns(); - } - catch - { - patterns = StoredRegex.GetDefaultPatterns(); - } + StoredRegex[] patterns = LoadSavedPatterns(); return [.. patterns.Select(p => new InlinePickerItem(p.Name, $"{{p:{p.Name}:first}}", "Patterns"))]; @@ -3033,18 +3018,7 @@ private static List LoadPatternPickerItems() private TemplatePatternMatch? OnPatternItemSelected(InlinePickerItem item) { // Extract pattern ID by looking up the name - StoredRegex[] patterns; - try - { - string json = Settings.Default.RegexList; - patterns = string.IsNullOrWhiteSpace(json) - ? StoredRegex.GetDefaultPatterns() - : JsonSerializer.Deserialize(json) ?? StoredRegex.GetDefaultPatterns(); - } - catch - { - patterns = StoredRegex.GetDefaultPatterns(); - } + StoredRegex[] patterns = LoadSavedPatterns(); StoredRegex? storedRegex = patterns.FirstOrDefault( p => p.Name.Equals(item.DisplayName, StringComparison.OrdinalIgnoreCase)); @@ -3061,6 +3035,12 @@ private static List LoadPatternPickerItems() return dialogResult == true ? dialog.Result : null; } + private static StoredRegex[] LoadSavedPatterns() + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + return patterns.Length == 0 ? StoredRegex.GetDefaultPatterns() : patterns; + } + private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null) { RemoveTableLines();