From c22c59539fa65ae43477ee299df0df1cb6e2c84b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 21:27:41 -0500 Subject: [PATCH 01/16] Refactor history and settings to use sidecar JSON files Migrated large/transient history data (word borders) to sidecar files, reducing memory usage and improving performance. HistoryService now lazily loads/caches histories and manages cleanup of unused files. SettingsService manages large JSON settings as disk files, with migration and caching for thread safety. WebSearchUrlModel updated to use new settings methods. Overall, improves scalability and robustness for history and settings management. --- Text-Grab/Models/HistoryInfo.cs | 17 +- Text-Grab/Models/WebSearchUrlModel.cs | 14 +- Text-Grab/Services/HistoryService.cs | 420 +++++++++++++++++++--- Text-Grab/Services/SettingsService.cs | 481 +++++++++++++++++++++++++- 4 files changed, 867 insertions(+), 65 deletions(-) diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 5c422553..5a9fb7a9 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -85,7 +85,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 +97,17 @@ public Rect PositionRect #region Public Methods + public void ClearTransientImage() + { + ImageContent?.Dispose(); + ImageContent = null; + } + + public void ClearTransientWordBorderData() + { + WordBorderInfoJson = null; + } + public static bool operator !=(HistoryInfo? left, HistoryInfo? right) { return !(left == right); 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/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index a3f42fcf..7649c24c 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,37 @@ 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); + + HistoryWithImage = await LoadHistoryAsync(nameof(HistoryWithImage)); + _imageHistoryLoaded = true; + NormalizeHistoryIds(HistoryWithImage); + + if (MigrateWordBorderDataToSidecarFiles(HistoryWithImage)) + MarkHistoryDirty(); + + TouchHistoryCache(); } public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) @@ -160,9 +190,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 +216,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 +237,22 @@ 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(); + 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 +260,25 @@ 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"; + 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,6 +286,8 @@ public void SaveToHistory(EditTextWindow etwToSave) if (!DefaultSettings.UseHistory) return; + EnsureTextHistoryLoaded(); + TouchHistoryCache(); HistoryInfo historyInfo = etwToSave.AsHistoryItem(); foreach (HistoryInfo inHistoryItem in HistoryTextOnly) @@ -249,49 +298,117 @@ 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) WriteHistoryFiles(HistoryTextOnly, nameof(HistoryTextOnly), maxHistoryTextOnly); - if (HistoryWithImage.Count > 0) + if (_imageHistoryLoaded) { ClearOldImages(); + 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)) + { + string historyBasePath = await FileUtilities.GetPathToHistory(); + string wordBorderInfoPath = Path.Combine(historyBasePath, history.WordBorderInfoFileName); + + if (File.Exists(wordBorderInfoPath)) + { + await using FileStream wordBorderInfoStream = File.OpenRead(wordBorderInfoPath); + List? wordBorderInfos = + await JsonSerializer.DeserializeAsync>(wordBorderInfoStream, HistoryJsonOptions); + + return wordBorderInfos ?? []; + } + } + + if (string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) + return []; + + List? inlineWordBorderInfos = + JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); + + return inlineWordBorderInfos ?? []; + } + + 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 +452,231 @@ 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 (MigrateWordBorderDataToSidecarFiles(HistoryWithImage)) + MarkHistoryDirty(); + } + + private void EnsureTextHistoryLoaded() + { + if (_textHistoryLoaded) + return; + + HistoryTextOnly = LoadHistoryBlocking(nameof(HistoryTextOnly)); + _textHistoryLoaded = true; + NormalizeHistoryIds(HistoryTextOnly); + } + + 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)) + File.Delete(filePath); + } + + 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)) + File.Delete(wordBorderInfoFile); + } + } + + private void MarkHistoryDirty() + { + _hasPendingWrite = true; + TouchHistoryCache(); + saveTimer.Stop(); + saveTimer.Start(); + } + + private bool MigrateWordBorderDataToSidecarFiles(IEnumerable historyItems) + { + bool migratedAnyWordBorderData = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (PersistWordBorderData(historyItem)) + migratedAnyWordBorderData = true; + } + + return migratedAnyWordBorderData; + } + + private static void PersistWordBorderData(IEnumerable historyItems) + { + foreach (HistoryInfo historyItem in historyItems) + PersistWordBorderData(historyItem); + } + + private static 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; + 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/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index 73bc7d24..3987d901 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,25 +15,61 @@ 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 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; 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; - 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 // so that when app updates they can be copied forward ClassicSettings.PropertyChanged -= ClassicSettings_PropertyChanged; ClassicSettings.PropertyChanged += ClassicSettings_PropertyChanged; + + MigrateManagedJsonSettingsToFiles(); + RemoveManagedJsonSettingsFromContainer(); } private void MigrateLocalSettingsToClassic() @@ -56,6 +97,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 +114,17 @@ 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; + + return ReadManagedJsonSettingText(propertyName); + } + public T? GetSettingFromContainer(string name) { // if running as packaged try to get from local settings @@ -112,4 +173,412 @@ 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; + if (string.IsNullOrWhiteSpace(managedJsonValue)) + { + DeleteManagedJsonSettingFile(propertyName); + RemoveSettingFromContainer(propertyName); + return; + } + + if (TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) + { + ClearManagedJsonSetting(propertyName); + return; + } + + SaveSettingInContainer(propertyName, managedJsonValue); + } + + private void MigrateManagedJsonSettingsToFiles() + { + bool migratedAnySettings = false; + + foreach (string propertyName in ManagedJsonSettingFiles.Keys) + { + string managedJsonValue = ClassicSettings[propertyName] as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(managedJsonValue)) + continue; + + if (!TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) + continue; + + ClearManagedJsonSetting(propertyName); + migratedAnySettings = true; + } + + if (migratedAnySettings && _saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + private void RemoveManagedJsonSettingsFromContainer() + { + if (_localSettings is null) + return; + + foreach (string propertyName in ManagedJsonSettingFiles.Keys) + { + string filePath = GetManagedJsonSettingFilePath(propertyName); + string classicValue = ClassicSettings[propertyName] as string ?? string.Empty; + + if (File.Exists(filePath) || string.IsNullOrWhiteSpace(classicValue)) + RemoveSettingFromContainer(propertyName); + } + } + + 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); + bool persistedToFile = TryWriteManagedJsonSettingText(propertyName, json); + + lock (_managedJsonLock) + { + cachedValue = clone(cachedCopy); + } + + if (persistedToFile) + { + ClearManagedJsonSetting(propertyName); + } + else + { + SetManagedJsonSettingValue(propertyName, json); + SaveSettingInContainer(propertyName, json); + } + + if (_saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + private string ReadManagedJsonSettingText(string propertyName) + { + string filePath = GetManagedJsonSettingFilePath(propertyName); + if (File.Exists(filePath)) + { + try + { + return File.ReadAllText(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read managed setting file '{propertyName}': {ex.Message}"); + } + } + + string managedJsonValue = ClassicSettings[propertyName] as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(managedJsonValue)) + return string.Empty; + + if (TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) + { + ClearManagedJsonSetting(propertyName); + + if (_saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + return managedJsonValue; + } + + 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 ClearManagedJsonSetting(string propertyName) + { + SetManagedJsonSettingValue(propertyName, string.Empty); + RemoveSettingFromContainer(propertyName); + } + + private void SetManagedJsonSettingValue(string propertyName, string value) + { + _suppressManagedJsonPropertyChanged = true; + try + { + ClassicSettings[propertyName] = value; + } + finally + { + _suppressManagedJsonPropertyChanged = false; + } + } + + private void RemoveSettingFromContainer(string name) + { + if (_localSettings is null) + return; + + try + { + _localSettings.Values.Remove(name); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to remove setting from ApplicationDataContainer: {ex.Message}"); + } + } + + 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); } From 870a287cf107086143f2746035f551e36906239d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 21:28:02 -0500 Subject: [PATCH 02/16] Refactor settings management and history import/export Centralize settings serialization/deserialization in SettingsService, replacing scattered JSON logic in utility classes. Update history export/import to handle all relevant files except text-only/history files. Remove unused JSON options and streamline error handling for settings. Improves maintainability and reliability of user customizations. --- Text-Grab/App.xaml.cs | 2 - Text-Grab/Utilities/AppUtilities.cs | 4 +- .../Utilities/CustomBottomBarUtilities.cs | 18 +---- Text-Grab/Utilities/GrabTemplateExecutor.cs | 14 +--- Text-Grab/Utilities/PostGrabActionManager.cs | 70 ++++--------------- .../SettingsImportExportUtilities.cs | 30 +++++--- Text-Grab/Utilities/ShortcutKeysUtilities.cs | 25 ++----- 7 files changed, 45 insertions(+), 118 deletions(-) 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/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/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..e4ac5fec 100644 --- a/Text-Grab/Utilities/SettingsImportExportUtilities.cs +++ b/Text-Grab/Utilities/SettingsImportExportUtilities.cs @@ -107,6 +107,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 +115,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; } @@ -212,13 +216,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 +261,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(); } From c6b26ed73094346524feb2c036df248f9e982a55 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 21:28:20 -0500 Subject: [PATCH 03/16] Refactor regex pattern storage to use settings service Replaced manual JSON handling with AppUtilities.TextGrabSettingsService for loading and saving regex patterns. Improved error handling and code reuse. Updated history and word border info management for better robustness and memory handling. Introduced helper methods to centralize pattern loading. Overall, enhances maintainability and consistency across the app. --- .../Controls/FindAndReplaceWindow.xaml.cs | 25 ++---- Text-Grab/Controls/RegexManager.xaml.cs | 40 ++-------- Text-Grab/Views/EditTextWindow.xaml.cs | 34 +++----- Text-Grab/Views/GrabFrame.xaml.cs | 78 +++++++------------ 4 files changed, 49 insertions(+), 128 deletions(-) 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/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/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 44d9a1e3..86fa0938 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() @@ -654,6 +655,7 @@ public HistoryInfo AsHistoryItem() CaptureDateTime = DateTimeOffset.UtcNow, TextContent = FrameText, WordBorderInfoJson = wbInfoJson, + WordBorderInfoFileName = wbInfoJson is null ? null : historyItem?.WordBorderInfoFileName, ImageContent = bitmap, PositionRect = sizePosRect, IsTable = TableToggleButton.IsChecked!.Value, @@ -1907,6 +1909,8 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel if (ShouldSaveOnClose) Singleton.Instance.SaveToHistory(this); + historyItem?.ClearTransientImage(); + FrameText = ""; wordBorders.Clear(); UpdateFrameText(); @@ -2900,19 +2904,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 +3005,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 +3014,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 +3031,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(); From 3138d89aabe8a4ea40d0257b8e5b720d62507934 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 21:28:34 -0500 Subject: [PATCH 04/16] Add tests for HistoryService and SettingsService migration Introduce HistoryServiceTests and SettingsServiceTests to verify migration, lazy loading, and persistence logic. Tests cover history file updates, word border JSON migration, regex settings migration, and post-grab check state storage. Improves coverage for file operations and settings management. --- Tests/HistoryServiceTests.cs | 159 ++++++++++++++++++++++++++++++++++ Tests/SettingsServiceTests.cs | 95 ++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 Tests/HistoryServiceTests.cs create mode 100644 Tests/SettingsServiceTests.cs diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs new file mode 100644 index 00000000..979836aa --- /dev/null +++ b/Tests/HistoryServiceTests.cs @@ -0,0 +1,159 @@ +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_MigratesInlineWordBorderJsonToSidecarStorage() + { + 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.Null(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.DoesNotContain("\"WordBorderInfoJson\"", savedHistoryJson); + Assert.Contains("\"WordBorderInfoFileName\"", savedHistoryJson); + + string savedWordBorderJson = await FileUtilities.GetTextFileAsync(historyItem.WordBorderInfoFileName!, FileStorageKind.WithHistory); + Assert.Contains("hello", savedWordBorderJson); + } + + 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/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs new file mode 100644 index 00000000..07e68c39 --- /dev/null +++ b/Tests/SettingsServiceTests.cs @@ -0,0 +1,95 @@ +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_MigratesAndCachesRegexSetting() + { + Settings settings = new(); + settings.RegexList = JsonSerializer.Serialize(new[] + { + new StoredRegex + { + Id = "regex-1", + Name = "Invoice Number", + Pattern = @"INV-\d+", + Description = "test pattern" + } + }); + + SettingsService service = new( + settings, + localSettings: null, + managedJsonSettingsFolderPath: _tempFolder, + saveClassicSettingsChanges: false); + + Assert.Equal(string.Empty, settings.RegexList); + + StoredRegex[] firstRead = service.LoadStoredRegexes(); + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + + Assert.True(File.Exists(regexFilePath)); + + File.WriteAllText( + regexFilePath, + JsonSerializer.Serialize(new[] + { + new StoredRegex + { + Id = "regex-2", + Name = "Changed", + Pattern = "changed" + } + })); + + StoredRegex[] secondRead = service.LoadStoredRegexes(); + + StoredRegex initialPattern = Assert.Single(firstRead); + StoredRegex cachedPattern = Assert.Single(secondRead); + Assert.Equal("regex-1", initialPattern.Id); + Assert.Equal("regex-1", cachedPattern.Id); + } + + [Fact] + public void SavePostGrabCheckStates_WritesFileAndLeavesClassicSettingEmpty() + { + Settings settings = new(); + SettingsService service = new( + settings, + localSettings: null, + managedJsonSettingsFolderPath: _tempFolder, + saveClassicSettingsChanges: false); + + service.SavePostGrabCheckStates(new Dictionary + { + ["Fix GUIDs"] = true + }); + + Assert.Equal(string.Empty, settings.PostGrabCheckStates); + Assert.True(File.Exists(Path.Combine(_tempFolder, "PostGrabCheckStates.json"))); + Assert.True(service.LoadPostGrabCheckStates()["Fix GUIDs"]); + Assert.Contains( + "Fix GUIDs", + service.GetManagedJsonSettingValueForExport(nameof(Settings.PostGrabCheckStates))); + } +} From 6bee41d38c119e6b34bd043ee1a32f280449b0bc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 22:34:02 -0500 Subject: [PATCH 05/16] add signing --- .github/workflows/Release.yml | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 5f89f4c1..09aa4efd 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: @@ -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: | From 1e6d36237974785d80a43b40718de6cd4d2559f1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 22:42:10 -0500 Subject: [PATCH 06/16] fix selfcontained flags --- .github/workflows/Release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 09aa4efd..db3fba66 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -81,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 }} @@ -95,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 }} @@ -109,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 }} @@ -122,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 }} From 72088ed8dbf466e0878ec0d59d5dfbaf89d265c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:04:35 +0000 Subject: [PATCH 07/16] Initial plan From 3132696c3220aac660847c7a51f01a5f337a59f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:08:55 +0000 Subject: [PATCH 08/16] fix: sanitize path traversal and add exception handling in HistoryService Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/Services/HistoryService.cs | 79 +++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 7649c24c..0f28181b 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -374,26 +374,53 @@ public async Task> GetWordBorderInfosAsync(HistoryInfo hist if (!string.IsNullOrWhiteSpace(history.WordBorderInfoFileName)) { - string historyBasePath = await FileUtilities.GetPathToHistory(); - string wordBorderInfoPath = Path.Combine(historyBasePath, history.WordBorderInfoFileName); + // Sanitize the persisted file name to prevent path traversal outside the history directory + string sanitizedFileName = Path.GetFileName(history.WordBorderInfoFileName); - if (File.Exists(wordBorderInfoPath)) + if (!string.IsNullOrWhiteSpace(sanitizedFileName) + && string.Equals(Path.GetExtension(sanitizedFileName), ".json", StringComparison.OrdinalIgnoreCase)) { - await using FileStream wordBorderInfoStream = File.OpenRead(wordBorderInfoPath); - List? wordBorderInfos = - await JsonSerializer.DeserializeAsync>(wordBorderInfoStream, HistoryJsonOptions); - - return wordBorderInfos ?? []; + 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 []; - List? inlineWordBorderInfos = - JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); + try + { + List? inlineWordBorderInfos = + JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); - return inlineWordBorderInfos ?? []; + return inlineWordBorderInfos ?? []; + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize inline word border info for history item '{history.ID}': {ex}"); + return []; + } } public void ReleaseLoadedHistories() @@ -551,8 +578,21 @@ private static void DeleteHistoryFile(string? historyFileName) string historyBasePath = GetHistoryPathBlocking(); string filePath = Path.Combine(historyBasePath, Path.GetFileName(historyFileName)); - if (File.Exists(filePath)) + 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) @@ -574,7 +614,20 @@ private void DeleteUnusedWordBorderFiles(IEnumerable historyItems) string fileName = Path.GetFileName(wordBorderInfoFile); if (!expectedFileNames.Contains(fileName)) - File.Delete(wordBorderInfoFile); + { + 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}"); + } + } } } From a0d362e0e33aed4ade820c169dcf7d4e4cf6d7cc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 12 Mar 2026 22:55:27 -0500 Subject: [PATCH 09/16] Add tests for managed JSON settings import/export Added three WpfFact tests to verify correct export and import of managed JSON settings (e.g., regex lists, post-grab check states), including round-trip and legacy inline storage scenarios. Ensured all managed setting keys are present in exports and that legacy imports are routed to sidecar files. Added necessary using statements for new test coverage. --- Tests/SettingsImportExportTests.cs | 172 +++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/Tests/SettingsImportExportTests.cs b/Tests/SettingsImportExportTests.cs index 2e37f487..e9be258d 100644 --- a/Tests/SettingsImportExportTests.cs +++ b/Tests/SettingsImportExportTests.cs @@ -1,5 +1,7 @@ using System.IO; using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Services; using Text_Grab.Utilities; namespace Tests; @@ -145,4 +147,174 @@ 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); + } + } } From 8f035f454a2683d19d17918f329874ee29079bd5 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:09:08 -0500 Subject: [PATCH 10/16] feat: add EnableFileBackedManagedSettings and refactor SettingsService to dual-store Introduce the EnableFileBackedManagedSettings setting (default false) so the preference is snapshot-captured at startup. SettingsService now reads from and writes to both the legacy ClassicSettings/ApplicationDataContainer store and the sidecar JSON file simultaneously, back-filling whichever store is stale and using the preferred store as the authority. The old one-time migration helpers (MigrateManagedJsonSettingsToFiles, RemoveManagedJsonSettingsFromContainer, ClearManagedJsonSetting) are removed in favour of the symmetric dual-write path. A read-only export variant (ReadManagedJsonSettingTextForExport) prevents side-effects during settings export. Also changes UiAutomationEnabled default from true to false in preparation for the Direct Text Beta gating. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/App.config | 7 +- Text-Grab/Properties/Settings.Designer.cs | 14 +- Text-Grab/Properties/Settings.settings | 7 +- Text-Grab/Services/SettingsService.cs | 177 ++++++++++------------ 4 files changed, 103 insertions(+), 102 deletions(-) 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/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/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index 3987d901..04c52f40 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -30,6 +30,7 @@ internal class SettingsService : IDisposable 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; @@ -42,6 +43,10 @@ internal class SettingsService : IDisposable public Properties.Settings ClassicSettings; + internal bool IsFileBackedManagedSettingsEnabled => _preferFileBackedManagedSettings; + + internal string ManagedJsonSettingsFolderPath => _managedJsonSettingsFolderPath; + public SettingsService() : this( Properties.Settings.Default, @@ -59,6 +64,7 @@ internal SettingsService( _localSettings = localSettings; _managedJsonSettingsFolderPath = managedJsonSettingsFolderPath ?? GetManagedJsonSettingsFolderPath(); _saveClassicSettingsChanges = saveClassicSettingsChanges; + _preferFileBackedManagedSettings = ClassicSettings.EnableFileBackedManagedSettings; if (ClassicSettings.FirstRun && _localSettings is not null && _localSettings.Values.Count > 0) MigrateLocalSettingsToClassic(); @@ -67,9 +73,6 @@ internal SettingsService( // so that when app updates they can be copied forward ClassicSettings.PropertyChanged -= ClassicSettings_PropertyChanged; ClassicSettings.PropertyChanged += ClassicSettings_PropertyChanged; - - MigrateManagedJsonSettingsToFiles(); - RemoveManagedJsonSettingsFromContainer(); } private void MigrateLocalSettingsToClassic() @@ -122,7 +125,8 @@ internal string GetManagedJsonSettingValueForExport(string propertyName) if (!IsManagedJsonSetting(propertyName)) return ClassicSettings[propertyName] as string ?? string.Empty; - return ReadManagedJsonSettingText(propertyName); + // Use the read-only path so that an export never mutates existing settings. + return ReadManagedJsonSettingTextForExport(propertyName); } public T? GetSettingFromContainer(string name) @@ -281,58 +285,22 @@ 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); - RemoveSettingFromContainer(propertyName); - return; - } - - if (TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) - { - ClearManagedJsonSetting(propertyName); + SaveSettingInContainer(propertyName, string.Empty); return; } + TryWriteManagedJsonSettingText(propertyName, managedJsonValue); SaveSettingInContainer(propertyName, managedJsonValue); } - private void MigrateManagedJsonSettingsToFiles() - { - bool migratedAnySettings = false; - - foreach (string propertyName in ManagedJsonSettingFiles.Keys) - { - string managedJsonValue = ClassicSettings[propertyName] as string ?? string.Empty; - if (string.IsNullOrWhiteSpace(managedJsonValue)) - continue; - - if (!TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) - continue; - - ClearManagedJsonSetting(propertyName); - migratedAnySettings = true; - } - - if (migratedAnySettings && _saveClassicSettingsChanges) - ClassicSettings.Save(); - } - - private void RemoveManagedJsonSettingsFromContainer() - { - if (_localSettings is null) - return; - - foreach (string propertyName in ManagedJsonSettingFiles.Keys) - { - string filePath = GetManagedJsonSettingFilePath(propertyName); - string classicValue = ClassicSettings[propertyName] as string ?? string.Empty; - - if (File.Exists(filePath) || string.IsNullOrWhiteSpace(classicValue)) - RemoveSettingFromContainer(propertyName); - } - } - private T LoadManagedJson( string propertyName, Func emptyFactory, @@ -377,55 +345,91 @@ private void SaveManagedJson( { T cachedCopy = clone(value); string json = JsonSerializer.Serialize(cachedCopy); - bool persistedToFile = TryWriteManagedJsonSettingText(propertyName, json); lock (_managedJsonLock) { cachedValue = clone(cachedCopy); } - if (persistedToFile) - { - ClearManagedJsonSetting(propertyName); - } - else - { - SetManagedJsonSettingValue(propertyName, json); - SaveSettingInContainer(propertyName, json); - } + // 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)) + if (!File.Exists(filePath)) + return string.Empty; + + try { - try - { - return File.ReadAllText(filePath); - } - catch (IOException ex) - { - Debug.WriteLine($"Failed to read managed setting file '{propertyName}': {ex.Message}"); - } + return File.ReadAllText(filePath); } - - string managedJsonValue = ClassicSettings[propertyName] as string ?? string.Empty; - if (string.IsNullOrWhiteSpace(managedJsonValue)) + catch (IOException ex) + { + Debug.WriteLine($"Failed to read managed setting file '{propertyName}': {ex.Message}"); return string.Empty; + } + } - if (TryWriteManagedJsonSettingText(propertyName, managedJsonValue)) - { - ClearManagedJsonSetting(propertyName); + private void BackfillClassicManagedJsonSetting(string propertyName, string value) + { + SetManagedJsonSettingValue(propertyName, value); + SaveSettingInContainer(propertyName, value); - if (_saveClassicSettingsChanges) - ClassicSettings.Save(); - } + 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 managedJsonValue; + return selectedValue ?? string.Empty; } private bool TryWriteManagedJsonSettingText(string propertyName, string value) @@ -459,12 +463,6 @@ private void DeleteManagedJsonSettingFile(string propertyName) } } - private void ClearManagedJsonSetting(string propertyName) - { - SetManagedJsonSettingValue(propertyName, string.Empty); - RemoveSettingFromContainer(propertyName); - } - private void SetManagedJsonSettingValue(string propertyName, string value) { _suppressManagedJsonPropertyChanged = true; @@ -478,21 +476,6 @@ private void SetManagedJsonSettingValue(string propertyName, string value) } } - private void RemoveSettingFromContainer(string name) - { - if (_localSettings is null) - return; - - try - { - _localSettings.Values.Remove(name); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to remove setting from ApplicationDataContainer: {ex.Message}"); - } - } - private string GetManagedJsonSettingFilePath(string propertyName) => Path.Combine(_managedJsonSettingsFolderPath, ManagedJsonSettingFiles[propertyName]); From d9305e1b98d0f0c51d9c148c26544391d1d1806b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:09:37 -0500 Subject: [PATCH 11/16] fix: normalize UiAutomation language to its OCR fallback on persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiAutomationLang is a UI-only abstraction that cannot be serialized into a history entry or settings value — persisting its tag caused HistoryInfo to fail to reconstruct an ILanguage on load and broke LastUsedLang restoration. Changes: - Add UsedUiAutomation flag to HistoryInfo (JSON-ignored when false) so history can round-trip correctly without storing the sentinel tag. - Add GetPersistedLanguageIdentity / NormalizePersistedLanguageIdentity helpers to LanguageService (forwarded via LanguageUtilities) that swap UiAutomationLang for its OCR fallback language at the point of persistence. - Apply normalization in FullscreenGrab, GrabFrame, and OcrUtilities when building HistoryInfo, and in HistoryService when loading or saving history lists. - Rename MigrateWordBorderDataToSidecarFiles → EnsureWordBorderSidecarFiles to better reflect that it is now called on every load, not just migration. - Add GetCurrentInputLanguageTag() helper that falls back to CultureInfo.CurrentUICulture so LanguageService is resilient when InputLanguageManager is unavailable in tests. - Rename Direct Text display name to "Direct Text (Beta)" via a const. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Models/HistoryInfo.cs | 22 ++++-- Text-Grab/Models/UiAutomationLang.cs | 7 +- Text-Grab/Services/HistoryService.cs | 66 +++++++++++++++-- Text-Grab/Services/LanguageService.cs | 72 ++++++++++++++++--- Text-Grab/Utilities/LanguageUtilities.cs | 9 +++ Text-Grab/Utilities/OcrUtilities.cs | 14 ++-- .../Views/FullscreenGrab.SelectionStyles.cs | 8 ++- Text-Grab/Views/GrabFrame.xaml.cs | 8 ++- 8 files changed, 175 insertions(+), 31 deletions(-) diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 5a9fb7a9..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")), }; } @@ -99,7 +105,11 @@ public Rect PositionRect public void ClearTransientImage() { - ImageContent?.Dispose(); + // 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; } 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/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 0f28181b..20e81efb 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -157,12 +157,16 @@ public async Task LoadHistories() 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 (MigrateWordBorderDataToSidecarFiles(HistoryWithImage)) + if (EnsureWordBorderSidecarFiles(HistoryWithImage)) MarkHistoryDirty(); TouchHistoryCache(); @@ -244,6 +248,7 @@ public void SaveToHistory(GrabFrame grabFrameToSave) if (string.IsNullOrEmpty(historyInfo.ID)) historyInfo.ID = Guid.NewGuid().ToString(); + NormalizeHistoryCompatibilityData(historyInfo); PersistWordBorderData(historyInfo); if (historyInfo.ImageContent is not null && !string.IsNullOrWhiteSpace(historyInfo.ImagePath)) @@ -272,6 +277,7 @@ public void SaveToHistory(HistoryInfo infoFromFullscreenGrab) infoFromFullscreenGrab.ImagePath = $"{imgRandomName}.bmp"; + NormalizeHistoryCompatibilityData(infoFromFullscreenGrab); PersistWordBorderData(infoFromFullscreenGrab); infoFromFullscreenGrab.ClearTransientImage(); HistoryWithImage.Add(infoFromFullscreenGrab); @@ -289,6 +295,7 @@ public void SaveToHistory(EditTextWindow etwToSave) EnsureTextHistoryLoaded(); TouchHistoryCache(); HistoryInfo historyInfo = etwToSave.AsHistoryItem(); + NormalizeHistoryCompatibilityData(historyInfo); foreach (HistoryInfo inHistoryItem in HistoryTextOnly) { @@ -314,11 +321,15 @@ public void WriteHistory() return; if (_textHistoryLoaded) + { + NormalizeHistoryCompatibilityData(HistoryTextOnly); WriteHistoryFiles(HistoryTextOnly, nameof(HistoryTextOnly), maxHistoryTextOnly); + } if (_imageHistoryLoaded) { ClearOldImages(); + NormalizeHistoryCompatibilityData(HistoryWithImage); PersistWordBorderData(HistoryWithImage); WriteHistoryFiles(HistoryWithImage, nameof(HistoryWithImage), maxHistoryWithImages); DeleteUnusedWordBorderFiles(HistoryWithImage); @@ -513,8 +524,10 @@ private void EnsureImageHistoryLoaded() HistoryWithImage = LoadHistoryBlocking(nameof(HistoryWithImage)); _imageHistoryLoaded = true; NormalizeHistoryIds(HistoryWithImage); + if (NormalizeHistoryCompatibilityData(HistoryWithImage)) + MarkHistoryDirty(); - if (MigrateWordBorderDataToSidecarFiles(HistoryWithImage)) + if (EnsureWordBorderSidecarFiles(HistoryWithImage)) MarkHistoryDirty(); } @@ -526,6 +539,8 @@ private void EnsureTextHistoryLoaded() HistoryTextOnly = LoadHistoryBlocking(nameof(HistoryTextOnly)); _textHistoryLoaded = true; NormalizeHistoryIds(HistoryTextOnly); + if (NormalizeHistoryCompatibilityData(HistoryTextOnly)) + MarkHistoryDirty(); } private void HistoryCacheReleaseTimer_Tick(object? sender, EventArgs e) @@ -639,7 +654,7 @@ private void MarkHistoryDirty() saveTimer.Start(); } - private bool MigrateWordBorderDataToSidecarFiles(IEnumerable historyItems) + private bool EnsureWordBorderSidecarFiles(IEnumerable historyItems) { bool migratedAnyWordBorderData = false; @@ -652,13 +667,47 @@ private bool MigrateWordBorderDataToSidecarFiles(IEnumerable histor return migratedAnyWordBorderData; } - private static void PersistWordBorderData(IEnumerable historyItems) + 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 static bool PersistWordBorderData(HistoryInfo historyItem) + private bool PersistWordBorderData(HistoryInfo historyItem) { if (string.IsNullOrWhiteSpace(historyItem.WordBorderInfoJson)) return false; @@ -676,7 +725,12 @@ private static bool PersistWordBorderData(HistoryInfo historyItem) } historyItem.WordBorderInfoFileName = wordBorderInfoFileName; - historyItem.ClearTransientWordBorderData(); + + // 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; } 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/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/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 86fa0938..7e2c03fb 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -647,11 +647,15 @@ 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, From e9708c6d9ef2872d0e06eaef4cda6aef9138162c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:10:07 -0500 Subject: [PATCH 12/16] refactor: GrabTemplateManager to dual-store with resolve/sync and export helpers Remove the one-shot MigrateFromSettingsIfNeeded path and replace it with a symmetric dual-store that mirrors SettingsService: ResolveTemplatesJson reads from both the legacy GrabTemplatesJSON setting and the sidecar JSON file, picks the preferred source based on EnableFileBackedManagedSettings, and back-fills the staler store. Writes go to both stores via SaveTemplatesJson. Add GetTemplatesJsonForExport and ImportTemplatesFromJson for use by the settings import/export path. Add TestPreferFileBackedMode and TestImagesFolderPath seams so tests can exercise both storage paths without touching production paths. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Utilities/GrabTemplateManager.cs | 173 ++++++++++++++------- 1 file changed, 114 insertions(+), 59 deletions(-) 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; + } + } } From 664dbc1b49c68a9bb3bf89ccd6cc6316ed788358 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:10:24 -0500 Subject: [PATCH 13/16] feat: include managed settings folder and grab templates in import/export Export now copies the managed JSON settings sidecar folder (settings-data/*.json) and the GrabTemplates JSON + template-images folder into the ZIP archive. Import restores them symmetrically before loading history, ensuring the dual-store is consistent on the target machine. Also flush pending in-memory history via WriteHistory() before export so the ZIP always contains the latest state, and extract ExportSettingsAsync as a shared helper so both the button handler and the new backup hyperlink share one code path. Co-Authored-By: Claude Sonnet 4.6 --- .../SettingsImportExportUtilities.cs | 102 +++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Utilities/SettingsImportExportUtilities.cs b/Text-Grab/Utilities/SettingsImportExportUtilities.cs index e4ac5fec..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); @@ -191,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 From 6c4fe81b918bce40de8f1f93fca780d03d2ad68e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:11:19 -0500 Subject: [PATCH 14/16] feat: add experimental file-backed settings toggle to Danger Settings Add an orange-bordered warning panel in DangerSettings with a ToggleSwitch that enables/disables the new EnableFileBackedManagedSettings preference. The toggle shows a restart-required dialog on change and links to a "Backup your settings" hyperlink that reuses the existing export flow. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Pages/DangerSettings.xaml | 33 ++++++++++++++++++++++ Text-Grab/Pages/DangerSettings.xaml.cs | 39 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) 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(); + } } From b50c7267f3f85f49d26111a6bd6d35489511b073 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:14:44 -0500 Subject: [PATCH 15/16] feat: move Direct Text section to bottom of Language Settings and mark as Beta Relocate the Direct Text controls to the end of the page so the stable OCR language options appear first. Rename section heading to "Direct Text (Beta)" and add an orange warning banner. Wrap advanced Direct Text toggles in UiAutomationAdvancedOptionsPanel and collapse it when the feature is disabled, keeping the UI clean for users who don't use it. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Pages/LanguageSettings.xaml | 141 +++++++++++++---------- Text-Grab/Pages/LanguageSettings.xaml.cs | 10 ++ 2 files changed, 87 insertions(+), 64 deletions(-) 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}\""; From 0ac9a1287a7d648ed107804c7a5ed9d9b103af48 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 15 Mar 2026 19:17:53 -0500 Subject: [PATCH 16/16] test: add settings isolation collection and expand coverage for dual-store changes Add SettingsIsolationCollection so tests that mutate settings run serially and restore original values on teardown. Expand tests for: - SettingsService: dual-store read/write and export-safe read paths - GrabTemplateManager: file-backed mode seam, export/import helpers - HistoryService: NormalizeHistoryCompatibilityData, EnsureWordBorderSidecarFiles - LanguageService: GetPersistedLanguageIdentity, NormalizePersistedLanguageIdentity - CaptureLanguageUtilities: UiAutomation include/exclude based on setting - SettingsImportExport: managed settings folder and grab templates round-trip - FilesIo: additional path/IO edge cases Co-Authored-By: Claude Sonnet 4.6 --- Tests/CaptureLanguageUtilitiesTests.cs | 45 ++++++- Tests/FilesIoTests.cs | 30 +++++ Tests/GrabTemplateManagerTests.cs | 91 ++++++++++---- Tests/HistoryServiceTests.cs | 41 ++++++- Tests/LanguageServiceTests.cs | 84 +++++++------ Tests/SettingsImportExportTests.cs | 79 +++++++++++++ Tests/SettingsIsolationCollection.cs | 6 + Tests/SettingsServiceTests.cs | 157 +++++++++++++++++-------- 8 files changed, 423 insertions(+), 110 deletions(-) create mode 100644 Tests/SettingsIsolationCollection.cs 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 index 979836aa..c2965c80 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -94,7 +94,7 @@ await SaveHistoryFileAsync( } [WpfFact] - public async Task ImageHistory_MigratesInlineWordBorderJsonToSidecarStorage() + public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarStorage() { string inlineWordBorderJson = JsonSerializer.Serialize( new List @@ -127,7 +127,7 @@ await SaveHistoryFileAsync( HistoryService historyService = new(); HistoryInfo historyItem = Assert.Single(historyService.GetRecentGrabs()); - Assert.Null(historyItem.WordBorderInfoJson); + Assert.Equal(inlineWordBorderJson, historyItem.WordBorderInfoJson); Assert.Equal("image-with-borders.wordborders.json", historyItem.WordBorderInfoFileName); List wordBorderInfos = await historyService.GetWordBorderInfosAsync(historyItem); @@ -139,13 +139,48 @@ await SaveHistoryFileAsync( historyService.ReleaseLoadedHistories(); string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); - Assert.DoesNotContain("\"WordBorderInfoJson\"", savedHistoryJson); + 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); 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 e9be258d..c2191879 100644 --- a/Tests/SettingsImportExportTests.cs +++ b/Tests/SettingsImportExportTests.cs @@ -1,11 +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] @@ -317,4 +320,80 @@ public async Task LegacyExportWithInlineManagedSettingsIsImportedToSidecarFiles( 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 index 07e68c39..bab8d29e 100644 --- a/Tests/SettingsServiceTests.cs +++ b/Tests/SettingsServiceTests.cs @@ -23,73 +23,132 @@ public void Dispose() } [Fact] - public void LoadStoredRegexes_MigratesAndCachesRegexSetting() + public void LoadStoredRegexes_DefaultModePrefersLegacyAndKeepsLegacyPopulated() { - Settings settings = new(); - settings.RegexList = JsonSerializer.Serialize(new[] + Settings settings = new() { - new StoredRegex - { - Id = "regex-1", - Name = "Invoice Number", - Pattern = @"INV-\d+", - Description = "test pattern" - } - }); + EnableFileBackedManagedSettings = false, + RegexList = SerializeRegexes("legacy-regex") + }; + string regexFilePath = Path.Combine(_tempFolder, "RegexList.json"); + File.WriteAllText(regexFilePath, SerializeRegexes("sidecar-regex")); - SettingsService service = new( - settings, - localSettings: null, - managedJsonSettingsFolderPath: _tempFolder, - saveClassicSettingsChanges: false); + SettingsService service = CreateService(settings); - Assert.Equal(string.Empty, settings.RegexList); + StoredRegex loadedRegex = Assert.Single(service.LoadStoredRegexes()); - StoredRegex[] firstRead = 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")); - Assert.True(File.Exists(regexFilePath)); + SettingsService service = CreateService(settings); - File.WriteAllText( - regexFilePath, - JsonSerializer.Serialize(new[] - { - new StoredRegex - { - Id = "regex-2", - Name = "Changed", - Pattern = "changed" - } - })); - - StoredRegex[] secondRead = service.LoadStoredRegexes(); - - StoredRegex initialPattern = Assert.Single(firstRead); - StoredRegex cachedPattern = Assert.Single(secondRead); - Assert.Equal("regex-1", initialPattern.Id); - Assert.Equal("regex-1", cachedPattern.Id); + 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 SavePostGrabCheckStates_WritesFileAndLeavesClassicSettingEmpty() + public void LoadStoredRegexes_FileBackedModePrefersSidecarAndBackfillsLegacy() { - Settings settings = new(); - SettingsService service = new( - settings, - localSettings: null, - managedJsonSettingsFolderPath: _tempFolder, - saveClassicSettingsChanges: false); + 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 }); - Assert.Equal(string.Empty, settings.PostGrabCheckStates); - Assert.True(File.Exists(Path.Combine(_tempFolder, "PostGrabCheckStates.json"))); + 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"]); - Assert.Contains( - "Fix GUIDs", - service.GetManagedJsonSettingValueForExport(nameof(Settings.PostGrabCheckStates))); } + + [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" + } + }); }